From dd9e43d9a09e6634ae71c319367830f958277c96 Mon Sep 17 00:00:00 2001 From: Justin Copeland Date: Mon, 13 Jul 2026 14:30:08 -0700 Subject: [PATCH 1/3] fix: anchor left+right tab-stop columns (signature blocks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paragraphs mixing a left tab stop with a right-aligned tab stop — the classic two-column signature block — were excluded from the anchored tab renderer by the `hasLeft && (hasCenter || hasRight)` guard and fell back to the plain tab path, which treats every tab as left-aligned. The right tab was rendered as a left tab, mis-wrapping the columns vs Word. Add a `left-right` anchored mode: when a paragraph has a left tab + a right tab (no center) and >= 2 tab characters, render a 3-zone grid (margin / left-tab / right-tab-right-aligned), reusing buildAnchoredTabZones and the same grid-anchoring approach as center-right. Fixes extend-hq/react-docx#13. Co-Authored-By: Justin Copeland Co-Authored-By: Claude Opus 4.8 --- packages/react-viewer/src/editor.tsx | 102 ++++++++++++++++++++-- tests/unit/left-right-tab-columns.test.ts | 64 ++++++++++++++ 2 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 tests/unit/left-right-tab-columns.test.ts diff --git a/packages/react-viewer/src/editor.tsx b/packages/react-viewer/src/editor.tsx index 3ca7e46..46c4b4f 100644 --- a/packages/react-viewer/src/editor.tsx +++ b/packages/react-viewer/src/editor.tsx @@ -10597,8 +10597,13 @@ function estimateWrappedLineCountForParagraph( !useTabLeaderLayout && anchoredTabLayout === "center"; const useRightTabLayout = !useTabLeaderLayout && anchoredTabLayout === "right"; + const useLeftRightTabLayout = + !useTabLeaderLayout && anchoredTabLayout === "left-right"; const useAnchoredTabLayout = - useCenterRightTabLayout || useCenterTabLayout || useRightTabLayout; + useCenterRightTabLayout || + useCenterTabLayout || + useRightTabLayout || + useLeftRightTabLayout; const paragraphEligibleForPlainPretextLineCount = !paragraph.style?.indent && firstLineMaxWidthPx === maxLineWidthPx && @@ -17037,7 +17042,12 @@ function paragraphTabCharacterCount(paragraph: ParagraphNode): number { }, 0); } -type ParagraphAnchoredTabLayout = "none" | "center-right" | "center" | "right"; +type ParagraphAnchoredTabLayout = + | "none" + | "center-right" + | "center" + | "right" + | "left-right"; function paragraphAnchoredTabLayout( paragraph: ParagraphNode, @@ -17052,6 +17062,14 @@ function paragraphAnchoredTabLayout( const tabCount = paragraphTabCharacterCount(paragraph); const withinHeaderFooter = options?.withinHeaderFooter === true; + // A left tab combined with a right tab (no center) is the classic two-column + // signature-block layout: a left column at the left tab and a right-aligned + // trailer at the right tab. Anchor it instead of falling back to the plain + // left-only tab path (which would treat the right tab as left and mis-wrap). + if (hasLeft && hasRight && !hasCenter && tabCount >= 2) { + return "left-right"; + } + if (hasLeft && (hasCenter || hasRight)) { return "none"; } @@ -17077,7 +17095,7 @@ function paragraphAnchoredTabLayout( function paragraphFirstTabStopPx( paragraph: ParagraphNode, - alignment: "center" | "right" + alignment: "left" | "center" | "right" ): number | undefined { return (paragraph.style?.tabStops ?? []) .filter((tabStop) => tabStop.alignment === alignment) @@ -19219,8 +19237,13 @@ function renderParagraphRuns( !useTabLeaderLayout && anchoredTabLayout === "center"; const useRightTabLayout = !useTabLeaderLayout && anchoredTabLayout === "right"; + const useLeftRightTabLayout = + !useTabLeaderLayout && anchoredTabLayout === "left-right"; const useAnchoredTabLayout = - useCenterRightTabLayout || useCenterTabLayout || useRightTabLayout; + useCenterRightTabLayout || + useCenterTabLayout || + useRightTabLayout || + useLeftRightTabLayout; const pageFieldSequence = paragraphPageFieldSequence(paragraph); const pageFieldValueSequence = paragraphPageFieldValueSequence(paragraph); const styleRefFieldValueSequence = @@ -20436,6 +20459,74 @@ function renderParagraphRuns( appendTrackedDeletionSegments(trailingTarget, `${keyPrefix}-tail`); } + if (useLeftRightTabLayout) { + // Two-column signature-block layout: zone 0 at the left margin, zone 1 + // left-aligned starting at the left tab, zone 2 right-aligned ending at the + // right tab. Grid columns are anchored at the tab-stop positions. + const zones = buildAnchoredTabZones(3); + const leftStopPx = Math.max( + 0, + Math.round(paragraphFirstTabStopPx(paragraph, "left") ?? 0) + ); + const rightStopPx = Math.max( + leftStopPx, + Math.round(paragraphFirstTabStopPx(paragraph, "right") ?? leftStopPx) + ); + + return ( +
+ + {zones[0]} + + + {zones[1]} + + + {zones[2]} + +
+ ); + } + if (useCenterRightTabLayout) { const zones = buildAnchoredTabZones(3); const centerStopPx = Math.max( @@ -49104,7 +49195,8 @@ export function DocxEditorViewer({ paragraphUsesTabLeaders(paragraph) || anchoredTabLayout === "center-right" || anchoredTabLayout === "center" || - anchoredTabLayout === "right"; + anchoredTabLayout === "right" || + anchoredTabLayout === "left-right"; const hasFixedPositionWrappedImage = paragraph.children.some( (child) => child.type === "image" && isFixedPositionWrappedFloatingImage(child) diff --git a/tests/unit/left-right-tab-columns.test.ts b/tests/unit/left-right-tab-columns.test.ts new file mode 100644 index 0000000..2c40689 --- /dev/null +++ b/tests/unit/left-right-tab-columns.test.ts @@ -0,0 +1,64 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { 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"; + +// Layout measures via OffscreenCanvas; provide a deterministic stub for node. +beforeAll(() => { + const makeContext = () => ({ font: "", measureText: (t: string) => ({ width: t.length * 7 }) }); + (globalThis as unknown as { OffscreenCanvas: unknown }).OffscreenCanvas = class { + getContext() { + return makeContext(); + } + }; +}); + +// Regression for extend-hq/react-docx#13: a paragraph mixing a LEFT tab stop and +// a RIGHT-aligned tab stop (the classic two-column signature block) must use the +// anchored tab layout — not fall back to the plain left-only tab path that +// treats the right tab as left and mis-wraps. +function buildModel(): DocModel { + return { + nodes: [ + { + type: "paragraph", + style: { + tabStops: [ + { alignment: "left", positionTwips: 5760 }, + { alignment: "right", positionTwips: 9240 }, + ], + }, + children: [ + { + type: "text", + text: + "Client A\tCompany B Legal Name LLC United States On behalf of itself and its affiliates\tOn behalf of itself and its affiliates", + }, + ], + }, + ], + 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" }); +} + +describe("left+right tab-stop columns (signature block)", () => { + it("renders via the anchored left-right tab layout with three zones", () => { + const html = renderToStaticMarkup(React.createElement(Viewer, { model: buildModel() })); + expect(html).toContain('data-docx-tab-layout="left-right"'); + expect(html).toMatch(/data-docx-tab-zone="0"/); + expect(html).toMatch(/data-docx-tab-zone="1"/); + expect(html).toMatch(/data-docx-tab-zone="2"/); + }); +}); From c59b4afe6fee9b8e89755e686b28bb4b4ca459f1 Mon Sep 17 00:00:00 2001 From: Justin Copeland Date: Mon, 13 Jul 2026 14:46:16 -0700 Subject: [PATCH 2/3] fix: render left+right tab columns as a flowing 2-column block Refine the left-right anchored layout: collapse the trailing right tab (Word drops it once the column content runs past it) and render everything after the first tab as a single left-aligned, normally-wrapping right column, instead of a fixed right-aligned zone that overlapped the column text. Verified in the playground against the signature-block repro. Co-Authored-By: Justin Copeland Co-Authored-By: Claude Opus 4.8 --- packages/react-viewer/src/editor.tsx | 68 ++++++++++------------- tests/unit/left-right-tab-columns.test.ts | 3 +- 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/packages/react-viewer/src/editor.tsx b/packages/react-viewer/src/editor.tsx index 46c4b4f..9eb7d36 100644 --- a/packages/react-viewer/src/editor.tsx +++ b/packages/react-viewer/src/editor.tsx @@ -20460,18 +20460,17 @@ function renderParagraphRuns( } if (useLeftRightTabLayout) { - // Two-column signature-block layout: zone 0 at the left margin, zone 1 - // left-aligned starting at the left tab, zone 2 right-aligned ending at the - // right tab. Grid columns are anchored at the tab-stop positions. - const zones = buildAnchoredTabZones(3); + // Two-column signature-block layout: zone 0 at the left margin, and + // everything after the first tab as a left-aligned right column anchored at + // the left tab. Word collapses the trailing right tab once the column's + // content runs past it (the usual case for party names), so the content + // simply left-flows and wraps within the right column — matching Word and + // avoiding the overlap a fixed right-aligned zone would cause. + const zones = buildAnchoredTabZones(2); const leftStopPx = Math.max( 0, Math.round(paragraphFirstTabStopPx(paragraph, "left") ?? 0) ); - const rightStopPx = Math.max( - leftStopPx, - Math.round(paragraphFirstTabStopPx(paragraph, "right") ?? leftStopPx) - ); return (
- - {zones[0]} - + {zones[0].length > 0 ? ( + + {zones[0]} + + ) : null} {zones[1]} - - {zones[2]} -
); } diff --git a/tests/unit/left-right-tab-columns.test.ts b/tests/unit/left-right-tab-columns.test.ts index 2c40689..54aabfd 100644 --- a/tests/unit/left-right-tab-columns.test.ts +++ b/tests/unit/left-right-tab-columns.test.ts @@ -54,11 +54,10 @@ function Viewer({ model }: { model: DocModel }): React.JSX.Element { } describe("left+right tab-stop columns (signature block)", () => { - it("renders via the anchored left-right tab layout with three zones", () => { + it("renders via the anchored left-right tab layout (left margin + right column)", () => { const html = renderToStaticMarkup(React.createElement(Viewer, { model: buildModel() })); expect(html).toContain('data-docx-tab-layout="left-right"'); expect(html).toMatch(/data-docx-tab-zone="0"/); expect(html).toMatch(/data-docx-tab-zone="1"/); - expect(html).toMatch(/data-docx-tab-zone="2"/); }); }); From c9b1dee8bd0ee725d109033aaeb684d3068325bc Mon Sep 17 00:00:00 2001 From: Justin Copeland Date: Mon, 13 Jul 2026 15:01:14 -0700 Subject: [PATCH 3/3] fix: make anchored tab columns line-break aware A line break inside a tab-column paragraph is a shared row boundary: it must start a new line in every column and return to the first column. Tokenize the anchored zone builder on both tabs and newlines (newline =>
into all zones + reset to column 0), and render the left-right zones as blocks so those breaks render. This makes the common two-line signature block (party name / 'On behalf of...') lay out as aligned two-column rows. Regression test covers the two-row case; full unit suite green. Co-Authored-By: Justin Copeland Co-Authored-By: Claude Opus 4.8 --- packages/react-viewer/src/editor.tsx | 81 ++++++++++++++--------- tests/unit/left-right-tab-columns.test.ts | 5 +- 2 files changed, 52 insertions(+), 34 deletions(-) diff --git a/packages/react-viewer/src/editor.tsx b/packages/react-viewer/src/editor.tsx index 9eb7d36..3291f70 100644 --- a/packages/react-viewer/src/editor.tsx +++ b/packages/react-viewer/src/editor.tsx @@ -20246,12 +20246,21 @@ function renderParagraphRuns( renderNumberingIntoTarget(zones[0], `${keyPrefix}-numbering-anchored`); + // A line break (`\n`, e.g. a ``) is a shared row boundary: it starts + // a new visual line in EVERY column and returns the cursor to the first + // column. Emit a
into all zones and reset the active column. + const emitRowBreakToAllZones = (breakKey: string): void => { + zones.forEach((zone, zoneIdx) => { + zone.push(React.createElement("br", { key: `${breakKey}-z${zoneIdx}` })); + }); + activeZone = 0; + }; + paragraph.children.forEach((child, childIndex) => { const zoneIndex = Math.max(0, Math.min(zoneCount - 1, activeZone)); - const zoneTarget = zones[zoneIndex]; const key = `${keyPrefix}-anchored-run-${childIndex}`; appendTrackedDeletionSegments( - zoneTarget, + zones[zoneIndex], `${key}-before`, child.type === "text" || child.type === "form-field" ? child.style @@ -20265,47 +20274,46 @@ function renderParagraphRuns( : child.type === "form-field" ? formFieldDisplayValue(child) : undefined; - if (typeof rawText !== "string" || !rawText.includes("\t")) { - if (child.type === "text") { - const resolvedText = resolveFieldText(rawText ?? "", zoneIndex); - renderRun( - zoneTarget, - child, - key, - attachTextToPreviousCheckbox(paragraph, childIndex, resolvedText), - trackedInlineChange, - childIndex - ); - } else { - renderRun( - zoneTarget, - child, - key, - undefined, - trackedInlineChange, - childIndex - ); - } + + if (typeof rawText !== "string") { + // Non-text child (image, etc.) — render into the current column. + renderRun( + zones[Math.max(0, Math.min(zoneCount - 1, activeZone))], + child, + key, + undefined, + trackedInlineChange, + childIndex + ); consumeTrackedVisibleChild(child); return; } - const parts = rawText.split("\t"); - parts.forEach((part, partIndex) => { - const currentZone = Math.max(0, Math.min(zoneCount - 1, activeZone)); - if (part.length > 0) { + // Tokenize on tabs and line breaks, keeping the delimiters: a tab advances + // to the next column; a newline is a shared row break. + const tokens = rawText.split(/(\t|\n)/); + tokens.forEach((token, tokenIndex) => { + if (token === "\t") { + if (activeZone < zoneCount - 1) { + activeZone += 1; + } + return; + } + if (token === "\n") { + emitRowBreakToAllZones(`${key}-br-${tokenIndex}`); + return; + } + if (token.length > 0) { + const currentZone = Math.max(0, Math.min(zoneCount - 1, activeZone)); renderRun( zones[currentZone], child, - `${key}-part-${partIndex}`, - resolveFieldText(part, currentZone), + `${key}-part-${tokenIndex}`, + resolveFieldText(token, currentZone), trackedInlineChange, childIndex ); } - if (partIndex < parts.length - 1 && activeZone < zoneCount - 1) { - activeZone += 1; - } }); consumeTrackedVisibleChild(child); }); @@ -20487,10 +20495,17 @@ function renderParagraphRuns( ) split the left column into lines too. + display: "block", + minWidth: 0, + whiteSpace: "pre-wrap", + wordBreak: "normal", + overflowWrap: "normal", gridColumn: "1 / 2", gridRow: "1 / 2", justifySelf: "start", + textAlign: "left", }} > {zones[0]} diff --git a/tests/unit/left-right-tab-columns.test.ts b/tests/unit/left-right-tab-columns.test.ts index 54aabfd..36534e5 100644 --- a/tests/unit/left-right-tab-columns.test.ts +++ b/tests/unit/left-right-tab-columns.test.ts @@ -29,11 +29,14 @@ function buildModel(): DocModel { { alignment: "right", positionTwips: 9240 }, ], }, + // Two rows (split by the line break) x two columns (split by the tab): + // Client A | Company B Legal Name LLC + // On behalf of itself and ... | On behalf of itself and ... children: [ { type: "text", text: - "Client A\tCompany B Legal Name LLC United States On behalf of itself and its affiliates\tOn behalf of itself and its affiliates", + "Client A\tCompany B Legal Name LLC\nOn behalf of itself and its affiliates\tOn behalf of itself and its affiliates", }, ], },