From 27d443b70e39e497e9cbc0eed2100bcef8ce959e Mon Sep 17 00:00:00 2001 From: Justin Copeland Date: Mon, 13 Jul 2026 15:50:11 -0700 Subject: [PATCH 1/3] fix: measure tab-stop column runs with canvas metrics Plain tab-stop columns positioned the run after a tab using a per-character heuristic for the running line width, while the run itself lays out at real canvas width. On long lower-case runs the heuristic over-estimates, so wrapped/broken second lines drifted left of the stop (e.g. a signature block's second party line landing ~58px short). Track the running width with the same canvas metrics the text renders at for paragraphs that carry explicit tab stops, in both render paths, so tabbed columns land on their stop across every line. Refs extend-hq/react-docx#15 Co-Authored-By: Justin Copeland Co-Authored-By: Claude Opus 4.8 --- packages/react-viewer/src/editor.tsx | 66 +++++++++++-- .../unit/plain-tab-line-break-columns.test.ts | 97 +++++++++++++++++++ 2 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 tests/unit/plain-tab-line-break-columns.test.ts diff --git a/packages/react-viewer/src/editor.tsx b/packages/react-viewer/src/editor.tsx index 3ca7e46..2e30a35 100644 --- a/packages/react-viewer/src/editor.tsx +++ b/packages/react-viewer/src/editor.tsx @@ -8698,6 +8698,35 @@ function updateEstimatedLineWidthPxForText( return estimateTextAdvanceWidthPx(trailingSegment, style); } +// Canvas-accurate analog of updateEstimatedLineWidthPxForText. The per-character +// heuristic used above over-estimates long lowercase runs, which is harmless for +// wrap counting but shifts explicit tab-stop columns: a tab is rendered as a +// fixed-width spacer of (tabStop - runningWidth), while the preceding text lays +// out at its real width, so any running-width error drags the column off the +// stop. Paragraphs with explicit tab stops therefore track their running width +// with the same canvas metrics the text actually renders at, keeping tabbed +// columns aligned across wrapped/broken lines (extend-hq/react-docx#15). +function updateMeasuredLineWidthPxForText( + currentLineWidthPx: number, + text: string, + style: TextRunNode["style"] | FormFieldRunNode["style"] | undefined, + paragraphBaseFontPx: number +): number { + if (!text) { + return currentLineWidthPx; + } + + if (!text.includes("\n")) { + return ( + currentLineWidthPx + measureTextWidthPx(text, style, paragraphBaseFontPx) + ); + } + + const segments = text.split("\n"); + const trailingSegment = segments[segments.length - 1] ?? ""; + return measureTextWidthPx(trailingSegment, style, paragraphBaseFontPx); +} + function resolveTabSpacerWidthPx( tabStopPositionsPx: number[], currentLineWidthPx: number, @@ -19238,6 +19267,8 @@ function renderParagraphRuns( Number.isFinite(value) && (value as number) > 0 ) .sort((left, right) => left - right); + const hasExplicitTabStops = tabStopPositionsPx.length > 0; + const paragraphBaseFontPx = paragraphBaseFontSizePx(paragraph); let hasTabSplit = false; let tabLeaderColor: string | undefined; const showTrackedChanges = options?.showTrackedChanges === true; @@ -19366,11 +19397,18 @@ function renderParagraphRuns( if (!shouldTrackTabLineWidth) { return; } - approximateLineWidthPx = updateEstimatedLineWidthPxForText( - approximateLineWidthPx, - text, - style - ); + approximateLineWidthPx = hasExplicitTabStops + ? updateMeasuredLineWidthPxForText( + approximateLineWidthPx, + text, + style, + paragraphBaseFontPx + ) + : updateEstimatedLineWidthPxForText( + approximateLineWidthPx, + text, + style + ); }; const trackInlineAdvance = (widthPx: number): void => { if (!shouldTrackTabLineWidth) { @@ -48877,16 +48915,24 @@ export function DocxEditorViewer({ ) .sort((left, right) => left - right); const compactTabStopFieldLayout = tabStopPositionsPx.length > 0; + const paragraphBaseFontPx = paragraphBaseFontSizePx(paragraph); let approximateLineWidthPx = 0; const trackTextAdvance = ( text: string, style?: TextRunNode["style"] | FormFieldRunNode["style"] ): void => { - approximateLineWidthPx = updateEstimatedLineWidthPxForText( - approximateLineWidthPx, - text, - style - ); + approximateLineWidthPx = compactTabStopFieldLayout + ? updateMeasuredLineWidthPxForText( + approximateLineWidthPx, + text, + style, + paragraphBaseFontPx + ) + : updateEstimatedLineWidthPxForText( + approximateLineWidthPx, + text, + style + ); }; const trackInlineAdvance = (widthPx: number): void => { approximateLineWidthPx += Math.max(0, Math.round(widthPx)); diff --git a/tests/unit/plain-tab-line-break-columns.test.ts b/tests/unit/plain-tab-line-break-columns.test.ts new file mode 100644 index 0000000..c9b4679 --- /dev/null +++ b/tests/unit/plain-tab-line-break-columns.test.ts @@ -0,0 +1,97 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { DocModel } from "@extend-ai/react-docx-doc-model"; +import { DocxEditorViewer, useDocxEditor } from "../../packages/react-viewer/src/editor"; + +// Deterministic canvas metrics (20px per character, font-independent) stand in +// for the width the browser actually lays text out at. measureTextWidthPx reads +// them via document.createElement("canvas").getContext("2d"); the per-character +// heuristic the renderer used for tab tracking before the fix would produce a +// different width, so asserting the spacer against these metrics proves canvas +// measurement — not the heuristic — drives tab-column placement. +const PX_PER_CHAR = 20; +const originalDocument = (globalThis as { document?: unknown }).document; + +beforeAll(() => { + (globalThis as { document?: unknown }).document = { + createElement: () => ({ + getContext: () => ({ + font: "", + measureText: (text: string) => ({ width: text.length * PX_PER_CHAR }), + }), + }), + }; +}); + +afterAll(() => { + (globalThis as { document?: unknown }).document = originalDocument; +}); + +const LEFT_TAB_TWIPS = 5760; // 5760 / 1440 * 96 = 384px +const LEFT_TAB_PX = 384; + +// A single LEFT tab stop with a line break: the classic left-only two-column +// signature block (variant C). The two lines' pre-tab text differ in width, so a +// tab spacer sized from the over-estimating heuristic drags the second column +// off the stop. The spacer must instead be (stop - measuredWidth) on every line. +function buildModel(): DocModel { + return { + nodes: [ + { + type: "paragraph", + style: { tabStops: [{ alignment: "left", positionTwips: LEFT_TAB_TWIPS }] }, + children: [ + { type: "text", text: "AAAA" }, // line 1 pre-tab: 4 chars + { type: "text", text: "\t" }, + { type: "text", text: "BBBB" }, + { type: "text", text: "\n" }, + { type: "text", text: "CCCCCCCCCC" }, // line 2 pre-tab: 10 chars + { type: "text", text: "\t" }, + { type: "text", text: "DDDD" }, + ], + }, + ], + metadata: { + sourceParts: 1, + warnings: [], + headerSections: [], + footerSections: [], + paragraphStyles: [], + }, + }; +} + +function Viewer({ model }: { model: DocModel }): React.JSX.Element { + const editor = useDocxEditor({ starterModel: model }); + return React.createElement(DocxEditorViewer, { editor, mode: "read-only" }); +} + +function tabSpacerWidths(html: string): number[] { + // Each tab renders as a span carrying data-docx-tab-char="true" with an inline + // pixel width. Collect them in document order. + const widths: number[] = []; + const tabSpanRe = /data-docx-tab-char="true"[^>]*style="([^"]*)"/g; + let match: RegExpExecArray | null; + while ((match = tabSpanRe.exec(html)) !== null) { + const widthMatch = /(?:^|;)\s*width:\s*([0-9.]+)px/.exec(match[1]); + if (widthMatch) { + widths.push(Math.round(Number(widthMatch[1]))); + } + } + return widths; +} + +describe("plain left-tab columns across a line break (signature block)", () => { + it("sizes each tab spacer from the measured pre-tab width so both columns land on the stop", () => { + const html = renderToStaticMarkup(React.createElement(Viewer, { model: buildModel() })); + const spacers = tabSpacerWidths(html); + + expect(spacers).toHaveLength(2); + // Column offset = preTabWidth + spacerWidth must equal the tab stop on BOTH + // lines. preTabWidth is the canvas-measured width (chars * PX_PER_CHAR); the + // pre-fix heuristic broke this equality on the wider second line. + expect("AAAA".length * PX_PER_CHAR + spacers[0]).toBe(LEFT_TAB_PX); + expect("CCCCCCCCCC".length * PX_PER_CHAR + spacers[1]).toBe(LEFT_TAB_PX); + }); +}); From 8a5304f921d7b1d9720e03fef07b66b9e0d3d8ed Mon Sep 17 00:00:00 2001 From: Justin Copeland Date: Mon, 13 Jul 2026 20:53:17 -0700 Subject: [PATCH 2/3] fix: align plain-tab segments at right/center tab stops The plain-tab path advanced to every stop and left-aligned the following text, so a segment after a right (or center) tab stop was never actually right-aligned. Size the tab spacer so the following text segment ends at (right) or straddles (center) the stop, measured with the same canvas metrics the text renders at, when it fits before the stop; otherwise fall back to the plain left-advance (matching Word, which degrades an overrun tab to left). Applied to both the read-only and editable render paths. This unifies the signature-block cases: a short right-column trailer right-aligns at its stop, while a long wrapping run still flows and returns to the margin. Regression test in tests/unit/right-tab-alignment.test.ts. Refs extend-hq/react-docx#15 Co-Authored-By: Justin Copeland Co-Authored-By: Claude Opus 4.8 --- packages/react-viewer/src/editor.tsx | 130 ++++++++++++++++++++++++- tests/unit/right-tab-alignment.test.ts | 113 +++++++++++++++++++++ 2 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 tests/unit/right-tab-alignment.test.ts diff --git a/packages/react-viewer/src/editor.tsx b/packages/react-viewer/src/editor.tsx index 2e30a35..8daabaf 100644 --- a/packages/react-viewer/src/editor.tsx +++ b/packages/react-viewer/src/editor.tsx @@ -22,6 +22,7 @@ import { type ParagraphIndent, type ImageRunNode, type ParagraphNode, + type ParagraphTabStop, type TableBorderSet, type TableBorderStyle, type TableCellStyle, @@ -8727,6 +8728,42 @@ function updateMeasuredLineWidthPxForText( return measureTextWidthPx(trailingSegment, style, paragraphBaseFontPx); } +// Canvas-measured width of the text run that follows a tab, up to (but not +// including) the next tab or line break. Used to right/center-align the segment +// after a right/center tab stop: the tab spacer is sized so the segment ends at +// (right) or straddles (center) the stop. Stops at the first non-text child so +// alignment is only attempted for plain-text segments (extend-hq/react-docx#15). +function measureFollowingTabSegmentWidthPx( + children: ParagraphNode["children"], + startIndex: number, + paragraphBaseFontPx: number +): number { + let widthPx = 0; + for (let index = startIndex; index < children.length; index += 1) { + const child = children[index]; + if (child.type !== "text") { + break; + } + const text = child.text ?? ""; + if (text === "\t") { + break; + } + const newlineIndex = text.indexOf("\n"); + if (newlineIndex >= 0) { + return ( + widthPx + + measureTextWidthPx( + text.slice(0, newlineIndex), + child.style, + paragraphBaseFontPx + ) + ); + } + widthPx += measureTextWidthPx(text, child.style, paragraphBaseFontPx); + } + return widthPx; +} + function resolveTabSpacerWidthPx( tabStopPositionsPx: number[], currentLineWidthPx: number, @@ -19267,6 +19304,20 @@ function renderParagraphRuns( Number.isFinite(value) && (value as number) > 0 ) .sort((left, right) => left - right); + const tabStopsWithAlignPx = (paragraph.style?.tabStops ?? []) + .map((tabStopEntry) => ({ + posPx: twipsToPixels(tabStopEntry.positionTwips), + align: tabStopEntry.alignment ?? "left", + })) + .filter( + ( + entry + ): entry is { + posPx: number; + align: NonNullable; + } => Number.isFinite(entry.posPx) && (entry.posPx as number) > 0 + ) + .sort((left, right) => left.posPx - right.posPx); const hasExplicitTabStops = tabStopPositionsPx.length > 0; const paragraphBaseFontPx = paragraphBaseFontSizePx(paragraph); let hasTabSplit = false; @@ -19423,6 +19474,32 @@ function renderParagraphRuns( fallbackTabWidthPx, checkboxChoiceRow ); + // Alignment-aware tab spacer (see the read-only renderer): right/center stops + // size the spacer so the following segment ends at / straddles the stop when + // it fits, else fall back to the plain left-advance. + const resolveAlignedTabWidthPx = (tabChildIndex: number): number => { + if (tabChildIndex < 0) { + return resolveNextTabWidthPx(); + } + const stop = tabStopsWithAlignPx.find( + (candidate) => candidate.posPx > approximateLineWidthPx + 0.5 + ); + if (!stop || (stop.align !== "right" && stop.align !== "center")) { + return resolveNextTabWidthPx(); + } + const gapPx = stop.posPx - approximateLineWidthPx; + const segmentWidthPx = measureFollowingTabSegmentWidthPx( + paragraph.children, + tabChildIndex + 1, + paragraphBaseFontPx + ); + const offsetPx = + stop.align === "right" ? segmentWidthPx : segmentWidthPx / 2; + if (gapPx - offsetPx >= 8) { + return Math.round(gapPx - offsetPx); + } + return resolveNextTabWidthPx(); + }; const appendPlainTextWithSoftBreakControl = ( target: React.ReactNode[], keySeed: string, @@ -19494,9 +19571,10 @@ function renderParagraphRuns( }; const tabTextStyle = ( style: TextRunNode["style"] | FormFieldRunNode["style"], - textStyle: React.CSSProperties + textStyle: React.CSSProperties, + tabChildIndex = -1 ): React.CSSProperties => { - const tabWidthPx = resolveNextTabWidthPx(); + const tabWidthPx = resolveAlignedTabWidthPx(tabChildIndex); trackInlineAdvance(tabWidthPx); const hasUnderline = Boolean(style?.underline); return { @@ -19733,7 +19811,7 @@ function renderParagraphRuns( {"\u00a0"} @@ -20139,7 +20217,7 @@ function renderParagraphRuns( {"\u00a0"} @@ -48914,6 +48992,23 @@ export function DocxEditorViewer({ Number.isFinite(value) && (value as number) > 0 ) .sort((left, right) => left - right); + // Tab stops with their alignment preserved (sorted by position), so the + // plain-tab renderer can right/center-align the segment that follows a + // right/center stop instead of always left-aligning at the position. + const tabStopsWithAlignPx = (paragraph.style?.tabStops ?? []) + .map((tabStopEntry) => ({ + posPx: twipsToPixels(tabStopEntry.positionTwips), + align: tabStopEntry.alignment ?? "left", + })) + .filter( + ( + entry + ): entry is { + posPx: number; + align: NonNullable; + } => Number.isFinite(entry.posPx) && (entry.posPx as number) > 0 + ) + .sort((left, right) => left.posPx - right.posPx); const compactTabStopFieldLayout = tabStopPositionsPx.length > 0; const paragraphBaseFontPx = paragraphBaseFontSizePx(paragraph); let approximateLineWidthPx = 0; @@ -48943,6 +49038,31 @@ export function DocxEditorViewer({ approximateLineWidthPx, fallbackTabWidthPx ); + // Alignment-aware tab spacer: for a right/center stop, size the spacer so + // the following text segment ends at (right) or straddles (center) the + // stop. Falls back to the plain left-advance when there is no explicit + // stop, the stop is left-aligned, or the segment is too wide to fit before + // the stop (matching Word, which degrades an overrun right tab to left). + const resolveAlignedTabWidthPx = (tabChildIndex: number): number => { + const stop = tabStopsWithAlignPx.find( + (candidate) => candidate.posPx > approximateLineWidthPx + 0.5 + ); + if (!stop || (stop.align !== "right" && stop.align !== "center")) { + return resolveNextTabWidthPx(); + } + const gapPx = stop.posPx - approximateLineWidthPx; + const segmentWidthPx = measureFollowingTabSegmentWidthPx( + previewParagraph.children, + tabChildIndex + 1, + paragraphBaseFontPx + ); + const offsetPx = + stop.align === "right" ? segmentWidthPx : segmentWidthPx / 2; + if (gapPx - offsetPx >= 8) { + return Math.round(gapPx - offsetPx); + } + return resolveNextTabWidthPx(); + }; const appendInteractiveTextWithSoftBreakControl = ( keySeed: string, text: string, @@ -51476,7 +51596,7 @@ export function DocxEditorViewer({ renderedText ); if (renderedText === "\t") { - const tabWidthPx = resolveNextTabWidthPx(); + const tabWidthPx = resolveAlignedTabWidthPx(childIndex); nodes.push( { + (globalThis as { document?: unknown }).document = { + createElement: () => ({ + getContext: () => ({ + font: "", + measureText: (text: string) => ({ width: text.length * PX_PER_CHAR }), + }), + }), + }; +}); + +afterAll(() => { + (globalThis as { document?: unknown }).document = originalDocument; +}); + +const LEFT_TAB_TWIPS = 5760; // 384px +const RIGHT_TAB_TWIPS = 9240; // 616px +const LEFT_TAB_PX = 384; +const RIGHT_TAB_PX = 616; + +// left + right stops with two tabs (three segments) render through the plain-tab +// path ("none" anchored layout). The segment after the RIGHT tab should +// right-align so it ends at the right stop — this is the signature-block case +// (extend-hq/react-docx#15). A single right tab instead hits the dedicated +// "right" anchored layout, so we deliberately use two tabs here. +function buildModel(trailer: string): DocModel { + return { + nodes: [ + { + type: "paragraph", + style: { + tabStops: [ + { alignment: "left", positionTwips: LEFT_TAB_TWIPS }, + { alignment: "right", positionTwips: RIGHT_TAB_TWIPS }, + ], + }, + children: [ + { type: "text", text: "AA" }, // 40px + { type: "text", text: "\t" }, + { type: "text", text: "BB" }, // 40px -> lands at left stop 384, ends 424 + { type: "text", text: "\t" }, + { type: "text", text: trailer }, + ], + }, + ], + metadata: { + sourceParts: 1, + warnings: [], + headerSections: [], + footerSections: [], + paragraphStyles: [], + }, + }; +} + +function Viewer({ model }: { model: DocModel }): React.JSX.Element { + const editor = useDocxEditor({ starterModel: model }); + return React.createElement(DocxEditorViewer, { editor, mode: "read-only" }); +} + +function tabSpacerWidths(html: string): number[] { + const widths: number[] = []; + const re = /data-docx-tab-char="true"[^>]*style="([^"]*)"/g; + let match: RegExpExecArray | null; + while ((match = re.exec(html)) !== null) { + const w = /(?:^|;)\s*width:\s*([0-9.]+)px/.exec(match[1]); + if (w) { + widths.push(Math.round(Number(w[1]))); + } + } + return widths; +} + +describe("right tab-stop alignment (plain-tab path, left+right stops)", () => { + it("right-aligns a fitting trailer so it ends at the right stop", () => { + const trailer = "CC"; // 40px, fits before the right stop + const html = renderToStaticMarkup( + React.createElement(Viewer, { model: buildModel(trailer) }) + ); + const spacers = tabSpacerWidths(html); + expect(spacers).toHaveLength(2); + // First tab left-aligns "BB" at the left stop. + expect("AA".length * PX_PER_CHAR + spacers[0]).toBe(LEFT_TAB_PX); + // Second tab right-aligns the trailer to END at the right stop: + // widthBeforeSecondTab(424) + spacer + trailer must equal the right stop. + const beforeSecondTabPx = LEFT_TAB_PX + "BB".length * PX_PER_CHAR; // 384 + 40 + const trailerPx = trailer.length * PX_PER_CHAR; + expect(beforeSecondTabPx + spacers[1] + trailerPx).toBe(RIGHT_TAB_PX); + }); + + it("degrades to left-advance when the trailer cannot fit before the right stop", () => { + const trailer = "Z".repeat(40); // 800px, far wider than the remaining gap + const html = renderToStaticMarkup( + React.createElement(Viewer, { model: buildModel(trailer) }) + ); + const spacers = tabSpacerWidths(html); + expect(spacers).toHaveLength(2); + const beforeSecondTabPx = LEFT_TAB_PX + "BB".length * PX_PER_CHAR; // 424 + // Falls back to the plain left gap to the right stop, not gap - trailer. + expect(spacers[1]).toBe(RIGHT_TAB_PX - beforeSecondTabPx); // 192 + }); +}); From a8afb62c45884939c019ecb19419e45656471e05 Mon Sep 17 00:00:00 2001 From: Justin Copeland Date: Mon, 13 Jul 2026 22:50:11 -0700 Subject: [PATCH 3/3] fix: wrap-aware tab tracking across all render paths + shared helpers Two correctness gaps and a structural cleanup for plain tab columns: - Wrap-aware running width: the tab tracker only reset at explicit line breaks, so a right/center tab after a run that WRAPS to the margin (e.g. a signature block whose right column is one continuous run) resolved past the stop and the trailer landed mid-line. Simulate the browser's greedy word-wrap (canvas metrics, content-box width) so the tab resolves on the correct visual line. Matches Word. - Tracked-changes path: the read-only viewer delegates to renderParagraphRuns when tracked changes / comments / special tab layouts are shown (the redline browse view's path), which had its own tab tracker that bypassed the above. Threaded the content-box width through ParagraphRunRenderOptions and made it wrap-aware too. - Refactor: the tab-stop parsing, wrap-aware advance, and aligned-spacer resolution were duplicated per render path (how these bugs slipped through one path at a time). Extracted buildTabStopsWithAlignPx / advanceTabLineWidthPx / resolveAlignedTabSpacerPx as the single source of truth; both paths now call them. Refs extend-hq/react-docx#15 Co-Authored-By: Justin Copeland Co-Authored-By: Claude Opus 4.8 --- packages/react-viewer/src/editor.tsx | 334 +++++++++++++++++---------- 1 file changed, 218 insertions(+), 116 deletions(-) diff --git a/packages/react-viewer/src/editor.tsx b/packages/react-viewer/src/editor.tsx index 8daabaf..7896162 100644 --- a/packages/react-viewer/src/editor.tsx +++ b/packages/react-viewer/src/editor.tsx @@ -8764,6 +8764,54 @@ function measureFollowingTabSegmentWidthPx( return widthPx; } +// Advance the running width for `text` while simulating the browser's greedy +// word-wrap: when adding a word would overflow the content width, the word +// starts a new visual line and the running width resets to that word's width. +// This keeps the running width equal to the CURRENT visual line's width, which +// is what a right/center tab after a wrapped run needs to align correctly — the +// plain tracker only reset at explicit breaks and drifted past the tab stop. +// Canvas metrics match the browser's layout closely enough that the simulated +// wrap points line up with the real ones (extend-hq/react-docx#15). +function advanceWrapAwareLineWidthPx( + currentLineWidthPx: number, + text: string, + style: TextRunNode["style"] | FormFieldRunNode["style"] | undefined, + paragraphBaseFontPx: number, + maxLineWidthPx: number +): number { + if (!text) { + return currentLineWidthPx; + } + let widthPx = currentLineWidthPx; + const lines = text.split("\n"); + lines.forEach((line, lineIndex) => { + if (lineIndex > 0) { + widthPx = 0; // explicit break resets to the margin + } + if (!line) { + return; + } + // Split into whitespace-preserving tokens so words wrap at spaces. + for (const token of line.split(/(\s+)/)) { + if (!token) { + continue; + } + const tokenWidthPx = measureTextWidthPx(token, style, paragraphBaseFontPx); + const isWhitespace = token.trim().length === 0; + if ( + !isWhitespace && + widthPx > 0 && + widthPx + tokenWidthPx > maxLineWidthPx + ) { + widthPx = tokenWidthPx; // word wraps to a new line at the margin + } else { + widthPx += tokenWidthPx; + } + } + }); + return widthPx; +} + function resolveTabSpacerWidthPx( tabStopPositionsPx: number[], currentLineWidthPx: number, @@ -8797,6 +8845,133 @@ function resolveTabSpacerWidthPx( return Math.max(8, Math.round(projectedStop - currentLineWidthPx)); } +// --------------------------------------------------------------------------- +// Shared plain-tab layout logic (extend-hq/react-docx#15). +// +// A paragraph is rendered through more than one code path (the fast read-only +// inline path, and the markup-aware `renderParagraphRuns` used for editing, +// tracked changes, comments, and special tab layouts). These helpers are the +// single source of truth for how tab stops position text, so a fix lands once +// instead of being duplicated per path (which is how alignment/wrap bugs kept +// slipping through one path). +// --------------------------------------------------------------------------- + +type TabStopWithAlignPx = { + posPx: number; + align: NonNullable; +}; + +// Tab stops with alignment preserved, sorted by position. +function buildTabStopsWithAlignPx( + paragraph: ParagraphNode +): TabStopWithAlignPx[] { + return (paragraph.style?.tabStops ?? []) + .map((tabStopEntry) => ({ + posPx: twipsToPixels(tabStopEntry.positionTwips), + align: tabStopEntry.alignment ?? "left", + })) + .filter( + (entry): entry is TabStopWithAlignPx => + Number.isFinite(entry.posPx) && (entry.posPx as number) > 0 + ) + .sort((left, right) => left.posPx - right.posPx); +} + +// Advance the running line width for rendered text. Paragraphs without explicit +// tab stops keep the cheap per-character estimate; those with stops track the +// canvas-accurate width the text actually renders at (so tab columns land on +// their stop), and, when a content-box width is known, simulate the browser's +// word-wrap so a tab after a wrapped run resolves on the correct visual line. +function advanceTabLineWidthPx(params: { + currentLineWidthPx: number; + text: string; + style: TextRunNode["style"] | FormFieldRunNode["style"] | undefined; + paragraphBaseFontPx: number; + hasExplicitTabStops: boolean; + contentWidthPx?: number; +}): number { + const { + currentLineWidthPx, + text, + style, + paragraphBaseFontPx, + hasExplicitTabStops, + contentWidthPx, + } = params; + if (!hasExplicitTabStops) { + return updateEstimatedLineWidthPxForText(currentLineWidthPx, text, style); + } + if (contentWidthPx !== undefined && Number.isFinite(contentWidthPx)) { + return advanceWrapAwareLineWidthPx( + currentLineWidthPx, + text, + style, + paragraphBaseFontPx, + contentWidthPx + ); + } + return updateMeasuredLineWidthPxForText( + currentLineWidthPx, + text, + style, + paragraphBaseFontPx + ); +} + +// Width of the spacer that renders a tab. For a right/center stop the following +// segment is right-aligned / centered on the stop when it fits before it; +// otherwise (left stop, no stop, or an overrun that cannot fit) it degrades to +// the plain left-advance, matching Word. +function resolveAlignedTabSpacerPx(params: { + tabStopsWithAlignPx: TabStopWithAlignPx[]; + tabStopPositionsPx: number[]; + currentLineWidthPx: number; + children: ParagraphNode["children"]; + tabChildIndex: number; + paragraphBaseFontPx: number; + fallbackTabWidthPx: number; + fixedFallbackTab?: boolean; +}): number { + const { + tabStopsWithAlignPx, + tabStopPositionsPx, + currentLineWidthPx, + children, + tabChildIndex, + paragraphBaseFontPx, + fallbackTabWidthPx, + fixedFallbackTab = false, + } = params; + const plainSpacerPx = (): number => + resolveTabSpacerWidthPx( + tabStopPositionsPx, + currentLineWidthPx, + fallbackTabWidthPx, + fixedFallbackTab + ); + if (tabChildIndex < 0) { + return plainSpacerPx(); + } + const stop = tabStopsWithAlignPx.find( + (candidate) => candidate.posPx > currentLineWidthPx + 0.5 + ); + if (!stop || (stop.align !== "right" && stop.align !== "center")) { + return plainSpacerPx(); + } + const gapPx = stop.posPx - currentLineWidthPx; + const segmentWidthPx = measureFollowingTabSegmentWidthPx( + children, + tabChildIndex + 1, + paragraphBaseFontPx + ); + const offsetPx = + stop.align === "right" ? segmentWidthPx : segmentWidthPx / 2; + if (gapPx - offsetPx >= 8) { + return Math.round(gapPx - offsetPx); + } + return plainSpacerPx(); +} + function estimateInteractiveFieldWidthPx(field: FormFieldRunNode): number { if (field.fieldType === "checkbox") { return resolveCheckboxFieldWidthPx(field); @@ -19243,6 +19418,10 @@ interface ParagraphRunRenderOptions { paragraphOriginLeftPx?: number; paragraphOriginTopPx?: number; imageFilterSuffix?: string; + // Content-box width (the wrap boundary) so tab tracking can be wrap-aware for + // right/center tab alignment after a wrapped run. When omitted, the tracker + // falls back to cumulative width (extend-hq/react-docx#15). + paragraphContentWidthPx?: number; } function renderParagraphRuns( @@ -19304,20 +19483,7 @@ function renderParagraphRuns( Number.isFinite(value) && (value as number) > 0 ) .sort((left, right) => left - right); - const tabStopsWithAlignPx = (paragraph.style?.tabStops ?? []) - .map((tabStopEntry) => ({ - posPx: twipsToPixels(tabStopEntry.positionTwips), - align: tabStopEntry.alignment ?? "left", - })) - .filter( - ( - entry - ): entry is { - posPx: number; - align: NonNullable; - } => Number.isFinite(entry.posPx) && (entry.posPx as number) > 0 - ) - .sort((left, right) => left.posPx - right.posPx); + const tabStopsWithAlignPx = buildTabStopsWithAlignPx(paragraph); const hasExplicitTabStops = tabStopPositionsPx.length > 0; const paragraphBaseFontPx = paragraphBaseFontSizePx(paragraph); let hasTabSplit = false; @@ -19448,18 +19614,14 @@ function renderParagraphRuns( if (!shouldTrackTabLineWidth) { return; } - approximateLineWidthPx = hasExplicitTabStops - ? updateMeasuredLineWidthPxForText( - approximateLineWidthPx, - text, - style, - paragraphBaseFontPx - ) - : updateEstimatedLineWidthPxForText( - approximateLineWidthPx, - text, - style - ); + approximateLineWidthPx = advanceTabLineWidthPx({ + currentLineWidthPx: approximateLineWidthPx, + text, + style, + paragraphBaseFontPx, + hasExplicitTabStops, + contentWidthPx: options?.paragraphContentWidthPx, + }); }; const trackInlineAdvance = (widthPx: number): void => { if (!shouldTrackTabLineWidth) { @@ -19467,39 +19629,17 @@ function renderParagraphRuns( } approximateLineWidthPx += Math.max(0, Math.round(widthPx)); }; - const resolveNextTabWidthPx = (): number => - resolveTabSpacerWidthPx( + const resolveAlignedTabWidthPx = (tabChildIndex: number): number => + resolveAlignedTabSpacerPx({ + tabStopsWithAlignPx, tabStopPositionsPx, - approximateLineWidthPx, + currentLineWidthPx: approximateLineWidthPx, + children: paragraph.children, + tabChildIndex, + paragraphBaseFontPx, fallbackTabWidthPx, - checkboxChoiceRow - ); - // Alignment-aware tab spacer (see the read-only renderer): right/center stops - // size the spacer so the following segment ends at / straddles the stop when - // it fits, else fall back to the plain left-advance. - const resolveAlignedTabWidthPx = (tabChildIndex: number): number => { - if (tabChildIndex < 0) { - return resolveNextTabWidthPx(); - } - const stop = tabStopsWithAlignPx.find( - (candidate) => candidate.posPx > approximateLineWidthPx + 0.5 - ); - if (!stop || (stop.align !== "right" && stop.align !== "center")) { - return resolveNextTabWidthPx(); - } - const gapPx = stop.posPx - approximateLineWidthPx; - const segmentWidthPx = measureFollowingTabSegmentWidthPx( - paragraph.children, - tabChildIndex + 1, - paragraphBaseFontPx - ); - const offsetPx = - stop.align === "right" ? segmentWidthPx : segmentWidthPx / 2; - if (gapPx - offsetPx >= 8) { - return Math.round(gapPx - offsetPx); - } - return resolveNextTabWidthPx(); - }; + fixedFallbackTab: checkboxChoiceRow, + }); const appendPlainTextWithSoftBreakControl = ( target: React.ReactNode[], keySeed: string, @@ -48992,23 +49132,7 @@ export function DocxEditorViewer({ Number.isFinite(value) && (value as number) > 0 ) .sort((left, right) => left - right); - // Tab stops with their alignment preserved (sorted by position), so the - // plain-tab renderer can right/center-align the segment that follows a - // right/center stop instead of always left-aligning at the position. - const tabStopsWithAlignPx = (paragraph.style?.tabStops ?? []) - .map((tabStopEntry) => ({ - posPx: twipsToPixels(tabStopEntry.positionTwips), - align: tabStopEntry.alignment ?? "left", - })) - .filter( - ( - entry - ): entry is { - posPx: number; - align: NonNullable; - } => Number.isFinite(entry.posPx) && (entry.posPx as number) > 0 - ) - .sort((left, right) => left.posPx - right.posPx); + const tabStopsWithAlignPx = buildTabStopsWithAlignPx(paragraph); const compactTabStopFieldLayout = tabStopPositionsPx.length > 0; const paragraphBaseFontPx = paragraphBaseFontSizePx(paragraph); let approximateLineWidthPx = 0; @@ -49016,53 +49140,30 @@ export function DocxEditorViewer({ text: string, style?: TextRunNode["style"] | FormFieldRunNode["style"] ): void => { - approximateLineWidthPx = compactTabStopFieldLayout - ? updateMeasuredLineWidthPxForText( - approximateLineWidthPx, - text, - style, - paragraphBaseFontPx - ) - : updateEstimatedLineWidthPxForText( - approximateLineWidthPx, - text, - style - ); + // paragraphContentWidthPx (declared below) is the wrap boundary; this + // closure only runs while rendering children, after it is initialized. + approximateLineWidthPx = advanceTabLineWidthPx({ + currentLineWidthPx: approximateLineWidthPx, + text, + style, + paragraphBaseFontPx, + hasExplicitTabStops: compactTabStopFieldLayout, + contentWidthPx: paragraphContentWidthPx, + }); }; const trackInlineAdvance = (widthPx: number): void => { approximateLineWidthPx += Math.max(0, Math.round(widthPx)); }; - const resolveNextTabWidthPx = (): number => - resolveTabSpacerWidthPx( + const resolveAlignedTabWidthPx = (tabChildIndex: number): number => + resolveAlignedTabSpacerPx({ + tabStopsWithAlignPx, tabStopPositionsPx, - approximateLineWidthPx, - fallbackTabWidthPx - ); - // Alignment-aware tab spacer: for a right/center stop, size the spacer so - // the following text segment ends at (right) or straddles (center) the - // stop. Falls back to the plain left-advance when there is no explicit - // stop, the stop is left-aligned, or the segment is too wide to fit before - // the stop (matching Word, which degrades an overrun right tab to left). - const resolveAlignedTabWidthPx = (tabChildIndex: number): number => { - const stop = tabStopsWithAlignPx.find( - (candidate) => candidate.posPx > approximateLineWidthPx + 0.5 - ); - if (!stop || (stop.align !== "right" && stop.align !== "center")) { - return resolveNextTabWidthPx(); - } - const gapPx = stop.posPx - approximateLineWidthPx; - const segmentWidthPx = measureFollowingTabSegmentWidthPx( - previewParagraph.children, - tabChildIndex + 1, - paragraphBaseFontPx - ); - const offsetPx = - stop.align === "right" ? segmentWidthPx : segmentWidthPx / 2; - if (gapPx - offsetPx >= 8) { - return Math.round(gapPx - offsetPx); - } - return resolveNextTabWidthPx(); - }; + currentLineWidthPx: approximateLineWidthPx, + children: previewParagraph.children, + tabChildIndex, + paragraphBaseFontPx, + fallbackTabWidthPx, + }); const appendInteractiveTextWithSoftBreakControl = ( keySeed: string, text: string, @@ -49304,6 +49405,7 @@ export function DocxEditorViewer({ tocLinkColorByLevel, paragraphOriginLeftPx: bodyParagraphOriginLeftPx, paragraphOriginTopPx: bodyParagraphOriginTopPx, + paragraphContentWidthPx, } ); }