From f144519ba1f17350713d15a56b9eaaedbac5d7ed Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 3 Jun 2026 09:14:41 -0300 Subject: [PATCH 01/26] fix(layout-engine): balance explicit equal-width continuous columns balanceSectionOnPage skipped every section with equalWidth=false plus explicit widths, so continuous newspaper sections declared as with equal children (the common case) never balanced and rendered single-column. Narrow the skip to GENUINELY-unequal widths: explicit widths that are all equal now balance like implicit equal columns. Genuinely-unequal widths still fill column-by-column (Word parity, unchanged). (SD-2324) --- .../src/column-balancing.test.ts | 29 +++++++++++++++++++ .../layout-engine/src/column-balancing.ts | 22 ++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/column-balancing.test.ts b/packages/layout-engine/layout-engine/src/column-balancing.test.ts index 9b308ce7d1..3efa938364 100644 --- a/packages/layout-engine/layout-engine/src/column-balancing.test.ts +++ b/packages/layout-engine/layout-engine/src/column-balancing.test.ts @@ -441,6 +441,35 @@ describe('balanceSectionOnPage', () => { expect(result).toBeNull(); }); + it('balances explicit columns that declare EQUAL widths (equalWidth=0 with equal w:col widths)', () => { + // SD-2324: continuous newspaper sections commonly use `` + // with explicit `` children that are all EQUAL (e.g. 4×2340). The unequal-width + // skip must NOT catch these — they balance like implicit equal columns. Genuinely-unequal + // widths (the test above, [200,376]) are still skipped. + const top = 96; + const { fragments, measureMap, blockSectionMap } = buildSectionFixture(2, 6, 20, top); + + const result = balanceSectionOnPage({ + fragments, + sectionIndex: 2, + sectionColumns: { count: 2, gap: 48, width: 288, equalWidth: false, widths: [288, 288] }, + sectionHasExplicitColumnBreak: false, + blockSectionMap, + margins: { left: 96 }, + topMargin: top, + columnWidth: 288, + availableHeight: 60, + measureMap, + }); + + expect(result).not.toBeNull(); + expect(result!.maxY).toBe(top + 60); + const col0 = fragments.filter((f) => f.x === 96).length; + const col1 = fragments.filter((f) => f.x === 96 + 288 + 48).length; + expect(col0).toBe(3); + expect(col1).toBe(3); + }); + it('only moves fragments of the target section when the page has mixed sections', () => { // Page has 3 fragments in section 1 (already positioned in col 0) and 6 in section 2. // Balancing section 2 must not touch section 1 fragments. diff --git a/packages/layout-engine/layout-engine/src/column-balancing.ts b/packages/layout-engine/layout-engine/src/column-balancing.ts index a367d3c823..113f41963a 100644 --- a/packages/layout-engine/layout-engine/src/column-balancing.ts +++ b/packages/layout-engine/layout-engine/src/column-balancing.ts @@ -670,15 +670,33 @@ export interface BalanceSectionOnPageArgs { * Guards (skip balancing when): * - Section has <= 1 column (nothing to balance) * - Section contains an explicit column break (author intent wins) - * - Section uses unequal column widths (Word doesn't rebalance these) + * - Section uses GENUINELY-unequal column widths (Word fills these column-by-column; + * explicit widths that are all equal still balance — SD-2324) * - No fragments on this page belong to the section */ +/** True when every explicit column width is equal within a sub-pixel tolerance. */ +function allColumnWidthsEqual(widths: number[]): boolean { + if (widths.length <= 1) return true; + const first = widths[0]; + return widths.every((w) => Math.abs(w - first) <= 0.5); +} + export function balanceSectionOnPage(args: BalanceSectionOnPageArgs): { maxY: number } | null { const { sectionColumns, sectionHasExplicitColumnBreak, sectionIndex, blockSectionMap, fragments } = args; if (sectionColumns.count <= 1) return null; if (sectionHasExplicitColumnBreak) return null; - if (sectionColumns.equalWidth === false && Array.isArray(sectionColumns.widths) && sectionColumns.widths.length > 0) { + // Genuinely-unequal explicit widths: Word fills these column-by-column rather than + // rebalancing, and the height-balancer measures each fragment at a single width so it + // can't reflow per column. Explicit widths that are all EQUAL (equalWidth="0" with every + // equal — the common continuous newspaper case) DO balance like implicit + // equal columns. (SD-2324) + if ( + sectionColumns.equalWidth === false && + Array.isArray(sectionColumns.widths) && + sectionColumns.widths.length > 0 && + !allColumnWidthsEqual(sectionColumns.widths) + ) { return null; } From 5b96267df21e3960638a52a9bc7bd9dc62b14099 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 3 Jun 2026 09:14:53 -0300 Subject: [PATCH 02/26] fix(layout-adapter): honor per-column w:space for unequal columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ECMA-376 §17.6.4, when columns are not equal width (w:equalWidth=0) the section-level w:cols/@w:space is ignored and the inter-column gap comes from each . extractColumns used the section space, over-spacing explicit columns so their widths scaled down to fit and diverged from Word (e.g. the 2002 ISDA sections). Use the per-column w:space for unequal columns; equal-width columns keep the section space. Advances SD-2629 for the uniform-spacing case. (SD-2324) --- .../sections/extraction.test.ts | 40 +++++++++++++++++++ .../layout-adapter/sections/extraction.ts | 12 ++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts index 713a8a3746..c40f9234a0 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts @@ -288,6 +288,46 @@ describe('extraction', () => { }); }); + it('uses per-column w:space and ignores section w:space for unequal columns (ECMA-376 §17.6.4)', () => { + // SD-2324: the reported ISDA sections are + // with explicit children. Per §17.6.4, when columns are NOT equal width + // the section-level w:space is ignored — the inter-column gap is each column's own w:space. + // So the gap must be 0 (from the children), not 48px (from the 720-twip section space). + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '4', 'w:equalWidth': '0', 'w:space': '720' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '2340', 'w:space': '0' } }, + { name: 'w:col', attributes: { 'w:w': '2340', 'w:space': '0' } }, + { name: 'w:col', attributes: { 'w:w': '2340', 'w:space': '0' } }, + { name: 'w:col', attributes: { 'w:w': '2340', 'w:space': '0' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 4, + gap: 0, + withSeparator: false, + widths: [156, 156, 156, 156], + equalWidth: false, + }); + }); + it('should handle section with only normalized margins and no sectPr elements', () => { const para: PMNode = { type: 'paragraph', diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts index 234d260aa5..f080155611 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts @@ -255,9 +255,15 @@ function extractColumns(elements: SectionElement[]): ColumnLayout | undefined { ? true : undefined; const columnChildren = Array.isArray(cols.elements) ? cols.elements.filter((child) => child?.name === 'w:col') : []; - const gapTwips = - cols.attributes['w:space'] ?? - columnChildren.find((child) => child?.attributes?.['w:space'] != null)?.attributes?.['w:space']; + // Per ECMA-376 §17.6.4, when columns are NOT equal width (`w:equalWidth="0"`), the + // section-level `w:cols/@w:space` is IGNORED — inter-column spacing comes from each + // ``'s own `w:space`. Only equal-width columns use the section space. Using the + // section space for explicit columns over-spaces them (forcing the widths to scale + // down to fit) and diverges from Word. (SD-2324; per-column-distinct spacing is SD-2629.) + const firstChildSpace = columnChildren.find((child) => child?.attributes?.['w:space'] != null)?.attributes?.[ + 'w:space' + ]; + const gapTwips = equalWidth === false ? (firstChildSpace ?? 0) : (cols.attributes['w:space'] ?? firstChildSpace); const gapInches = parseColumnGap(gapTwips as string | number | undefined); const widths = columnChildren .map((child) => Number(child.attributes?.['w:w'])) From 40d4aa2b885c3e197813c623d1d6b409b51e57c5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 20:08:06 -0300 Subject: [PATCH 03/26] fix(columns): equal-mode column correctness + explicit count cap (SD-2324) Equal-width sections (w:equalWidth="1" or omitted) now match Word: extraction drops child widths and takes the gap from the section w:space (default 720), and normalizeColumnLayout honours per-column widths only when w:equalWidth="0". For explicit columns (w:equalWidth="0"), cap the count to min(w:num, valid child-width count) at the source, so a w:num larger than the provided widths no longer creates surplus 1px phantom columns in the fill loop (which reads the raw count). A matching clamp in normalizeColumnLayout stays as a defensive net. --- .../contracts/src/column-layout.test.ts | 32 +++ .../contracts/src/column-layout.ts | 18 +- .../sections/extraction.test.ts | 212 ++++++++++++++++++ .../layout-adapter/sections/extraction.ts | 33 ++- 4 files changed, 283 insertions(+), 12 deletions(-) diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts index f2107b9887..308db41899 100644 --- a/packages/layout-engine/contracts/src/column-layout.test.ts +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -95,6 +95,38 @@ describe('normalizeColumnLayout', () => { }); }); + it('ignores widths when equalWidth is omitted and divides evenly (SD-2324: omitted = equal mode)', () => { + // Omitted equalWidth is equal mode in Word; any widths present are not authoritative. + expect(normalizeColumnLayout({ count: 2, gap: 24, widths: [100, 200] }, 624)).toEqual({ + count: 2, + gap: 24, + widths: [300, 300], + width: 300, + }); + }); + + it('ignores widths when equalWidth is true and divides evenly (SD-2324)', () => { + expect(normalizeColumnLayout({ count: 2, gap: 24, widths: [100, 200], equalWidth: true }, 624)).toEqual({ + count: 2, + gap: 24, + widths: [300, 300], + equalWidth: true, + width: 300, + }); + }); + + it('clamps count to the explicit-widths length when w:num exceeds it (SD-2324 F8)', () => { + // w:num="4" with only two explicit widths: the surplus columns have no width and must not + // be synthesized as ~0px slivers (the F8 phantom-column bug). Clamp to the two real columns. + expect(normalizeColumnLayout({ count: 4, gap: 48, widths: [192, 384], equalWidth: false }, 624)).toEqual({ + count: 2, + gap: 48, + widths: [192, 384], + equalWidth: false, + width: 384, + }); + }); + it('falls back to a single column when there is no usable content width', () => { expect(normalizeColumnLayout({ count: 3, gap: 24 }, 0, 0.01)).toEqual({ count: 1, diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 7dc794c592..9369e32ed4 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -30,14 +30,24 @@ export function normalizeColumnLayout( epsilon = 0.0001, ): NormalizedColumnLayout { const rawCount = input && Number.isFinite(input.count) ? Math.floor(input.count) : 1; - const count = Math.max(1, rawCount || 1); + let count = Math.max(1, rawCount || 1); const gap = Math.max(0, input?.gap ?? 0); - const totalGap = gap * (count - 1); - const availableWidth = contentWidth - totalGap; + // Honor per-column widths ONLY in explicit mode (`equalWidth === false`). In equal mode + // (true or omitted) Word ignores child widths and divides the content area evenly, so any + // widths that reach here are not authoritative and must not drive geometry. (SD-2324) const explicitWidths = - Array.isArray(input?.widths) && input.widths.length > 0 + input?.equalWidth === false && Array.isArray(input?.widths) && input.widths.length > 0 ? input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0) : []; + // Explicit columns are defined by their widths. When the section declares more + // columns than it supplies widths (e.g. w:num="4" with two ), the surplus columns + // have no width and previously padded to ~0px, rendering as 1px slivers of vertical text + // (SD-2324 F8). Clamp the count to the widths actually provided so every column renders. + if (explicitWidths.length > 0 && explicitWidths.length < count) { + count = explicitWidths.length; + } + const totalGap = gap * (count - 1); + const availableWidth = contentWidth - totalGap; let widths = explicitWidths.length > 0 diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts index c40f9234a0..436f63cb26 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts @@ -328,6 +328,218 @@ describe('extraction', () => { }); }); + it('drops child widths and uses the section gap when w:equalWidth="1" (equal mode, Word ignores children)', () => { + // SD-2324: Word treats w:equalWidth="1" as equal mode regardless of any children. + // It ignores child widths/spaces, derives equal columns from w:num, and the gap from w:cols/@w:space. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:equalWidth': '1', 'w:space': '720' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '2880' } }, + { name: 'w:col', attributes: { 'w:w': '5760' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + // No widths emitted; gap from the 720-twip section space (48px). Word equalizes such columns. + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 48, + withSeparator: false, + equalWidth: true, + }); + }); + + it('drops child widths when w:equalWidth is omitted (omitted defaults to equal mode, like Word)', () => { + // SD-2324: an omitted w:equalWidth is equal mode in Word (verified: EvenlySpaced=true). Child + // values must NOT leak through as explicit widths. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '720' } }, + { name: 'w:col', attributes: { 'w:w': '5760' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + // No widths, no equalWidth field; gap from the section space (48px). + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 48, + withSeparator: false, + }); + }); + + it('ignores child w:space and defaults the gap to 720 twips in equal mode (SD-2324 gap-half)', () => { + // SD-2324: in equal mode the gap comes from w:cols/@w:space only. With the section space omitted, + // it defaults to 720 twips (48px) even though the children declare w:space="0". Consulting the + // child space here (the pre-fix behavior) would wrongly yield a 0px gap. Verified against Word. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '0' } }, + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '0' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 48, // 720-twip default, NOT the child w:space of 0 + withSeparator: false, + }); + }); + + it('keeps explicit child widths with a 0 gap when w:equalWidth="0" and no child w:space (SD-2324 F5)', () => { + // Explicit mode is unchanged by the equal-mode fix: child widths are honored, and an absent child + // w:space yields a 0 gap (CT_Column/@space default 0), not the 720-twip section default. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:equalWidth': '0' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '4680' } }, + { name: 'w:col', attributes: { 'w:w': '4680' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 0, + withSeparator: false, + widths: [312, 312], + equalWidth: false, + }); + }); + + it('resolves explicit-mode count as min(w:num default 1, valid child-width count); omitted num stays 1 (SD-2324, Word-verified)', () => { + // Word caps the explicit column count to the children actually provided. With w:num + // omitted (default 1) and 3 children, Word renders 1 column (verified Count=1), not 3. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:equalWidth': '0' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '2880' } }, + { name: 'w:col', attributes: { 'w:w': '2880' } }, + { name: 'w:col', attributes: { 'w:w': '2880' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + // min(1, 3) -> count is 1 (NOT 3 from the children). + expect(result?.columnsPx?.count).toBe(1); + expect(result?.columnsPx?.equalWidth).toBe(false); + }); + + it('caps explicit count to the valid child-width count when w:num exceeds it (SD-2324 F8)', () => { + // w:num="4" with only two renders 2 columns in Word (verified), not 4. Capping the + // count at the source keeps the fill loop (which reads the raw count) from creating surplus + // 1px phantom columns. min(4, 2) -> 2. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '4', 'w:equalWidth': '0' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '720' } }, + { name: 'w:col', attributes: { 'w:w': '5760' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 48, + withSeparator: false, + widths: [192, 384], + equalWidth: false, + }); + }); + it('should handle section with only normalized margins and no sectPr elements', () => { const para: PMNode = { type: 'paragraph', diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts index f080155611..968551af08 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts @@ -245,7 +245,7 @@ function extractColumns(elements: SectionElement[]): ColumnLayout | undefined { const cols = elements.find((el) => el?.name === 'w:cols'); if (!cols?.attributes) return undefined; - const count = parseColumnCount(cols.attributes['w:num'] as string | number | undefined); + let count = parseColumnCount(cols.attributes['w:num'] as string | number | undefined); const withSeparator = parseColumnSeparator(cols.attributes['w:sep'] as string | number | undefined); const equalWidthRaw = cols.attributes['w:equalWidth']; const equalWidth = @@ -255,26 +255,43 @@ function extractColumns(elements: SectionElement[]): ColumnLayout | undefined { ? true : undefined; const columnChildren = Array.isArray(cols.elements) ? cols.elements.filter((child) => child?.name === 'w:col') : []; - // Per ECMA-376 §17.6.4, when columns are NOT equal width (`w:equalWidth="0"`), the - // section-level `w:cols/@w:space` is IGNORED — inter-column spacing comes from each - // ``'s own `w:space`. Only equal-width columns use the section space. Using the - // section space for explicit columns over-spaces them (forcing the widths to scale - // down to fit) and diverges from Word. (SD-2324; per-column-distinct spacing is SD-2629.) + // ECMA-376 §17.6.4 column mode, validated against Word (MS Word 16 oracle): + // Explicit mode (`w:equalWidth="0"`): widths and inter-column spacing come from the child + // `` elements (`w:w` + `w:space`, default 0); the section `w:cols/@w:space` is + // ignored. (Per-column distinct spacing is SD-2629; today the first child's space is + // projected as the single gap.) + // Equal mode (`w:equalWidth="1"` or omitted): Word ignores all child `` data. The + // gap comes from `w:cols/@w:space` (default 720); a child `w:space` is NOT consulted, and + // child widths are dropped so the columns divide evenly. Count comes from `w:num` + // (default 1) in equal mode, and is capped to the valid child-width count in explicit + // mode (Word renders min(num, count of with a usable w:w)). (SD-2324) + const isExplicit = equalWidth === false; const firstChildSpace = columnChildren.find((child) => child?.attributes?.['w:space'] != null)?.attributes?.[ 'w:space' ]; - const gapTwips = equalWidth === false ? (firstChildSpace ?? 0) : (cols.attributes['w:space'] ?? firstChildSpace); + const gapTwips = isExplicit ? (firstChildSpace ?? 0) : cols.attributes['w:space']; const gapInches = parseColumnGap(gapTwips as string | number | undefined); const widths = columnChildren .map((child) => Number(child.attributes?.['w:w'])) .filter((widthTwips) => Number.isFinite(widthTwips) && widthTwips > 0) .map((widthTwips) => (widthTwips / 1440) * PX_PER_INCH); + // Explicit mode: w:num is capped to the valid child-width count (widths.length), i.e. the + // number of that supplied a usable w:w. Word renders min(num, that count) (e.g. + // w:num="4" with two => 2 columns, verified vs Word). This is the authoritative + // count both the fill loop and width math read; the matching clamp in normalizeColumnLayout + // is a defensive net for any other producer. (SD-2324 F8) + if (isExplicit && widths.length > 0) { + count = Math.min(count, widths.length); + } + const result: ColumnLayout = { count, gap: gapInches * PX_PER_INCH, withSeparator, - ...(widths.length > 0 ? { widths } : {}), + // Only explicit columns carry per-column widths; equal mode divides evenly (Word ignores + // child `w:w` when equalWidth is "1" or omitted). + ...(isExplicit && widths.length > 0 ? { widths } : {}), ...(equalWidth !== undefined ? { equalWidth } : {}), }; From 9ad7abddd5bcb34e20e2688678e885ac82535d68 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 21:28:35 -0300 Subject: [PATCH 04/26] test(columns): cover valid-width count cap, equal-mode count, and absent w:cols (SD-2324) Adds three extraction unit tests for the landed column fix: count caps to the valid child-width count (four but two usable w:w -> 2), equal mode takes the count from w:num (count 3, no children), and a section without yields no columnsPx. --- .../sections/extraction.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts index 436f63cb26..67e5d66319 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts @@ -540,6 +540,86 @@ describe('extraction', () => { }); }); + it('caps to the valid child-width count, ignoring with no usable w:w (SD-2324)', () => { + // Four but only two carry a usable w:w; the count caps to those two (widths.length), + // not the raw four children. min(4, 2) -> 2. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '4', 'w:equalWidth': '0' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '2880' } }, + { name: 'w:col', attributes: { 'w:w': '5760' } }, + { name: 'w:col', attributes: { 'w:w': '0' } }, + { name: 'w:col', attributes: {} }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 0, + withSeparator: false, + widths: [192, 384], + equalWidth: false, + }); + }); + + it('takes the count from w:num in equal mode (count 3, no children) (SD-2324)', () => { + // Equal mode (omitted equalWidth) takes the count straight from w:num and the gap from the + // section w:space (720 twips -> 48px); no per-column widths are emitted. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [{ name: 'w:cols', attributes: { 'w:num': '3', 'w:space': '720' } }], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ count: 3, gap: 48, withSeparator: false }); + }); + + it('returns no columnsPx when the section has no element (SD-2324)', () => { + // A sectPr without must not synthesize a column layout. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [{ name: 'w:pgSz', attributes: { 'w:w': '12240', 'w:h': '15840' } }], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result).not.toBeNull(); + expect(result?.columnsPx).toBeUndefined(); + }); + it('should handle section with only normalized margins and no sectPr elements', () => { const para: PMNode = { type: 'paragraph', From 965328352476bf259cb7b849cb5561bdbee8080e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 22:02:16 -0300 Subject: [PATCH 05/26] feat(columns): add per-column gaps field to ColumnLayout (SD-2629 foundation) Additive, no behavior change: introduces `gaps?: number[]` (inter-column gaps, length count-1) on the ColumnLayout contract and teaches cloneColumnLayout to carry it. No producer emits it and no consumer reads it yet; geometry helpers, extraction, consumer migration, and the no-scale/per-gap semantic flip follow in subsequent steps (see tmp/SD-2629-state.md). --- packages/layout-engine/contracts/src/column-layout.ts | 1 + packages/layout-engine/contracts/src/index.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 9369e32ed4..8aa1f5c374 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -18,6 +18,7 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { count: columns.count, gap: columns.gap, ...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}), + ...(Array.isArray(columns.gaps) ? { gaps: [...columns.gaps] } : {}), ...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}), ...(columns.withSeparator !== undefined ? { withSeparator: columns.withSeparator } : {}), } diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 9f701475b4..d4ab2133c1 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1714,6 +1714,12 @@ export type ColumnLayout = { withSeparator?: boolean; widths?: number[]; equalWidth?: boolean; + /** + * Per-column inter-column gaps in px, length `count - 1`: the gap after each column except the + * last. Explicit mode (`equalWidth === false`) only, derived from each ``; equal + * mode uses the scalar `gap`. When absent, consumers fall back to the uniform `gap`. (SD-2629) + */ + gaps?: number[]; }; /** From 583b63ea85659bb21b19ffb5b001cd973fb7a5b6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 22:11:29 -0300 Subject: [PATCH 06/26] feat(columns): resolved column-geometry API + helpers (SD-2629 step 1) Additive, behavior-preserving. Adds ColumnGeometry and getColumnGeometry() (the resolved consumer API: content-relative x, width, gapAfter, separatorX), plus getColumnX/getColumnWidth/getColumnGapAfter/getColumnSeparatorPositions/ getColumnAtX (originX-explicit) and columnLayoutsEqual (gaps-aware). Geometry mirrors today's normalized widths + scalar gap; raw per-column `gaps` do not drive it until the step-4 semantic flip. normalizeColumnLayout output shape is unchanged, so no consumer or existing test is affected. --- .../contracts/src/column-layout.test.ts | 78 ++++++++++++- .../contracts/src/column-layout.ts | 105 ++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts index 308db41899..036e1c399e 100644 --- a/packages/layout-engine/contracts/src/column-layout.test.ts +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from 'vitest'; import type { ColumnLayout } from './index.js'; -import { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; +import { + cloneColumnLayout, + columnLayoutsEqual, + getColumnAtX, + getColumnGapAfter, + getColumnGeometry, + getColumnSeparatorPositions, + getColumnWidth, + getColumnX, + normalizeColumnLayout, + widthsEqual, +} from './column-layout.js'; describe('widthsEqual', () => { it('treats two missing width arrays as equal', () => { @@ -135,3 +146,68 @@ describe('normalizeColumnLayout', () => { }); }); }); + +describe('getColumnGeometry + geometry helpers (SD-2629, behavior-preserving)', () => { + it('mirrors equal-width normalized output (uniform gap, content-relative x)', () => { + const geom = getColumnGeometry(normalizeColumnLayout({ count: 2, gap: 24 }, 624)); + expect(geom).toEqual([ + { index: 0, x: 0, width: 300, gapAfter: 24 }, + { index: 1, x: 324, width: 300, gapAfter: 0 }, + ]); + }); + + it('mirrors explicit (scaled) widths', () => { + const geom = getColumnGeometry( + normalizeColumnLayout({ count: 2, gap: 24, widths: [100, 200], equalWidth: false }, 624), + ); + expect(geom).toEqual([ + { index: 0, x: 0, width: 200, gapAfter: 24 }, + { index: 1, x: 224, width: 400, gapAfter: 0 }, + ]); + }); + + it('reflects the F8 count clamp (4 declared, 2 widths => 2 columns)', () => { + const geom = getColumnGeometry( + normalizeColumnLayout({ count: 4, gap: 48, widths: [192, 384], equalWidth: false }, 624), + ); + expect(geom).toHaveLength(2); + expect(geom.map((c) => c.width)).toEqual([192, 384]); + }); + + it('places a separator centered in the gap after each non-last column', () => { + const geom = getColumnGeometry(normalizeColumnLayout({ count: 2, gap: 24, withSeparator: true }, 624)); + expect(geom[0].separatorX).toBe(312); + expect(geom[1].separatorX).toBeUndefined(); + expect(getColumnSeparatorPositions(geom, 96)).toEqual([408]); + }); + + it('resolves width / x / gap / column-at-x with an explicit originX', () => { + const geom = getColumnGeometry(normalizeColumnLayout({ count: 2, gap: 24 }, 624)); + expect(getColumnWidth(geom, 1)).toBe(300); + expect(getColumnX(geom, 1, 96)).toBe(420); + expect(getColumnGapAfter(geom, 0)).toBe(24); + expect(getColumnGapAfter(geom, 1)).toBe(0); + expect(getColumnAtX(geom, 96 + 330, 96)).toBe(1); + expect(getColumnAtX(geom, 96 + 100, 96)).toBe(0); + }); + + it('does NOT let per-column gaps drive geometry yet (step 1 is behavior-preserving)', () => { + // `gaps` is raw explicit-mode input; geometry still uses the scalar gap until the step-4 flip. + const geom = getColumnGeometry({ count: 2, gap: 24, widths: [300, 300], gaps: [999], width: 300 }); + expect(geom[0].gapAfter).toBe(24); + }); +}); + +describe('columnLayoutsEqual', () => { + it('treats layouts differing only by gaps as not equal', () => { + const a: ColumnLayout = { count: 2, gap: 24, widths: [200, 400], gaps: [24], equalWidth: false }; + const b: ColumnLayout = { count: 2, gap: 24, widths: [200, 400], gaps: [48], equalWidth: false }; + expect(columnLayoutsEqual(a, b)).toBe(false); + expect(columnLayoutsEqual(a, { ...a, gaps: [24] })).toBe(true); + }); + + it('matches on the full shape and handles missing inputs', () => { + expect(columnLayoutsEqual(undefined, undefined)).toBe(true); + expect(columnLayoutsEqual({ count: 2, gap: 24 }, { count: 3, gap: 24 })).toBe(false); + }); +}); diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 8aa1f5c374..3837f41e39 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -1,5 +1,20 @@ import type { ColumnLayout } from './index.js'; +/** + * Resolved geometry for a single column. `x` and `separatorX` are CONTENT-RELATIVE (measured from + * the content-area left edge); add the content-left / left margin to get an absolute page x. This + * is the single source every column consumer should read for positioning. (SD-2629) + */ +export type ColumnGeometry = { + index: number; + x: number; + width: number; + /** Gap after this column; 0 for the last column. */ + gapAfter: number; + /** Separator x (content-relative); present only when a separator line is drawn after this column. */ + separatorX?: number; +}; + export type NormalizedColumnLayout = ColumnLayout & { width: number }; export function widthsEqual(a?: number[], b?: number[]): boolean { @@ -25,6 +40,26 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { : { count: 1, gap: 0 }; } +/** + * Build resolved per-column geometry from already-resolved widths and the uniform scalar gap. + * SD-2629 step 1 keeps this behavior-preserving: it mirrors today's normalized output (scaled + * widths, uniform gap). Per-column `gaps` do NOT drive geometry until the semantic flip (step 4). + */ +function buildColumnGeometry(widths: number[], gap: number, withSeparator: boolean): ColumnGeometry[] { + const geometry: ColumnGeometry[] = []; + let x = 0; + for (let i = 0; i < widths.length; i += 1) { + const width = widths[i]; + const isLast = i === widths.length - 1; + const gapAfter = isLast ? 0 : gap; + const col: ColumnGeometry = { index: i, x, width, gapAfter }; + if (withSeparator && !isLast) col.separatorX = x + width + gap / 2; + geometry.push(col); + x += width + gapAfter; + } + return geometry; +} + export function normalizeColumnLayout( input: ColumnLayout | undefined, contentWidth: number, @@ -87,3 +122,73 @@ export function normalizeColumnLayout( width, }; } + +/** + * Resolve per-column geometry for an already-normalized layout. This is the SD-2629 consumer API: + * fill/positioning/separators/hit-testing/footnotes/floating anchors/balancing should read this + * single source rather than re-deriving from `widths`/`gap`. Behavior-preserving in step 1: it + * mirrors today's normalized widths + scalar gap; per-column `gaps` drive it only after the flip. + */ +export function getColumnGeometry(normalized: NormalizedColumnLayout): ColumnGeometry[] { + const widths = + Array.isArray(normalized.widths) && normalized.widths.length > 0 ? normalized.widths : [normalized.width]; + return buildColumnGeometry(widths, normalized.gap, Boolean(normalized.withSeparator)); +} + +// --------------------------------------------------------------------------- +// Resolved-geometry consumer API (SD-2629). All x values are CONTENT-RELATIVE; +// callers pass the content-left / left margin as `originX` to get an absolute page x. +// --------------------------------------------------------------------------- + +function clampColumnIndex(geometry: ColumnGeometry[], index: number): number { + if (geometry.length === 0) return 0; + return Math.max(0, Math.min(index, geometry.length - 1)); +} + +/** Width of the column at `index` (px). */ +export function getColumnWidth(geometry: ColumnGeometry[], index: number): number { + return geometry[clampColumnIndex(geometry, index)]?.width ?? 0; +} + +/** Left edge of the column at `index`, as `originX + content-relative x`. */ +export function getColumnX(geometry: ColumnGeometry[], index: number, originX = 0): number { + return originX + (geometry[clampColumnIndex(geometry, index)]?.x ?? 0); +} + +/** Gap after the column at `index` (0 for the last column). */ +export function getColumnGapAfter(geometry: ColumnGeometry[], index: number): number { + return geometry[clampColumnIndex(geometry, index)]?.gapAfter ?? 0; +} + +/** Absolute x of each separator line (only columns that draw one), as `originX + content-relative`. */ +export function getColumnSeparatorPositions(geometry: ColumnGeometry[], originX = 0): number[] { + return geometry + .filter((col) => typeof col.separatorX === 'number') + .map((col) => originX + (col.separatorX as number)); +} + +/** Index of the column containing absolute `x` (clicks in a gap map to the preceding column). */ +export function getColumnAtX(geometry: ColumnGeometry[], x: number, originX = 0): number { + if (geometry.length === 0) return 0; + const cx = x - originX; + let result = 0; + for (const col of geometry) { + if (cx >= col.x) result = col.index; + else break; + } + return result; +} + +/** Structural equality of two column layouts, including per-column `gaps`. */ +export function columnLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + return ( + a.count === b.count && + a.gap === b.gap && + a.equalWidth === b.equalWidth && + Boolean(a.withSeparator) === Boolean(b.withSeparator) && + widthsEqual(a.widths, b.widths) && + widthsEqual(a.gaps, b.gaps) + ); +} From 7f7954d8438c1eabf0d2ff0bb6dccebc993f9968 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 22:23:29 -0300 Subject: [PATCH 07/26] feat(columns): geometry API + resolveColumnMode exports, gaps in equality (SD-2629) Barrel was missing the geometry helpers and columnLayoutsEqual, so the API added in 583b63ea8 was unreachable for consumers. resolveColumnMode unifies the explicit/equal decision (explicit iff equalWidth===false AND usable widths) shared by extraction, normalization, and geometry. gaps added to every column equality/cache site in-place (behavior-preserving: gaps is undefined until extraction emits it in step 2). --- .../contracts/src/column-layout.test.ts | 24 +++++++++++++++ .../contracts/src/column-layout.ts | 29 ++++++++++++++----- packages/layout-engine/contracts/src/index.ts | 16 ++++++++-- .../layout-engine/layout-engine/src/index.ts | 4 ++- .../layout-engine/src/section-breaks.ts | 9 +++--- .../v1/core/layout-adapter/sections/breaks.ts | 3 +- 6 files changed, 70 insertions(+), 15 deletions(-) diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts index 036e1c399e..f58f003e54 100644 --- a/packages/layout-engine/contracts/src/column-layout.test.ts +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -10,6 +10,7 @@ import { getColumnWidth, getColumnX, normalizeColumnLayout, + resolveColumnMode, widthsEqual, } from './column-layout.js'; @@ -211,3 +212,26 @@ describe('columnLayoutsEqual', () => { expect(columnLayoutsEqual({ count: 2, gap: 24 }, { count: 3, gap: 24 })).toBe(false); }); }); + +describe('resolveColumnMode (SD-2629)', () => { + it('is explicit only when equalWidth is false AND usable widths exist', () => { + expect(resolveColumnMode({ count: 2, gap: 24, widths: [100, 200], equalWidth: false })).toBe('explicit'); + }); + + it('is equal when equalWidth is true, even with widths present', () => { + expect(resolveColumnMode({ count: 2, gap: 24, widths: [100, 200], equalWidth: true })).toBe('equal'); + }); + + it('is equal when equalWidth is omitted (Word divides evenly)', () => { + expect(resolveColumnMode({ count: 2, gap: 24, widths: [100, 200] })).toBe('equal'); + }); + + it('is equal when explicit mode is declared but no usable widths are supplied', () => { + expect(resolveColumnMode({ count: 2, gap: 24, equalWidth: false })).toBe('equal'); + expect(resolveColumnMode({ count: 2, gap: 24, widths: [0, -5], equalWidth: false })).toBe('equal'); + }); + + it('is equal for missing input', () => { + expect(resolveColumnMode(undefined)).toBe('equal'); + }); +}); diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 3837f41e39..aa62daae5d 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -27,6 +27,24 @@ export function widthsEqual(a?: number[], b?: number[]): boolean { return true; } +/** + * Usable explicit widths: finite and > 0. Empty unless explicit mode applies. (SD-2629) + */ +function usableExplicitWidths(input: ColumnLayout | undefined): number[] { + if (!input || input.equalWidth !== false || !Array.isArray(input.widths)) return []; + return input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0); +} + +/** + * Resolved column mode. Explicit ONLY when `equalWidth === false` AND at least one usable child + * width exists; otherwise equal mode. In equal mode Word ignores any child `w:col/@w` and divides + * the content area evenly, so this is the single explicit/equal decision shared by extraction, + * normalization, and geometry. (SD-2324 / SD-2629) + */ +export function resolveColumnMode(input: ColumnLayout | undefined): 'explicit' | 'equal' { + return usableExplicitWidths(input).length > 0 ? 'explicit' : 'equal'; +} + export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { return columns ? { @@ -68,13 +86,10 @@ export function normalizeColumnLayout( const rawCount = input && Number.isFinite(input.count) ? Math.floor(input.count) : 1; let count = Math.max(1, rawCount || 1); const gap = Math.max(0, input?.gap ?? 0); - // Honor per-column widths ONLY in explicit mode (`equalWidth === false`). In equal mode - // (true or omitted) Word ignores child widths and divides the content area evenly, so any - // widths that reach here are not authoritative and must not drive geometry. (SD-2324) - const explicitWidths = - input?.equalWidth === false && Array.isArray(input?.widths) && input.widths.length > 0 - ? input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0) - : []; + // Honor per-column widths ONLY in explicit mode (`equalWidth === false` with usable widths). + // In equal mode (true or omitted) Word ignores child widths and divides the content area evenly, + // so any widths that reach here are not authoritative and must not drive geometry. (SD-2324) + const explicitWidths = usableExplicitWidths(input); // Explicit columns are defined by their widths. When the section declares more // columns than it supplies widths (e.g. w:num="4" with two ), the surplus columns // have no width and previously padded to ~0px, rendering as 1px slivers of vertical text diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index d4ab2133c1..8200c7a3f3 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -99,8 +99,20 @@ export type { LayoutStoryLocator, } from './layout-identity.js'; import type { LayoutSourceIdentity } from './layout-identity.js'; -export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; -export type { NormalizedColumnLayout } from './column-layout.js'; +export { + cloneColumnLayout, + columnLayoutsEqual, + getColumnAtX, + getColumnGapAfter, + getColumnGeometry, + getColumnSeparatorPositions, + getColumnWidth, + getColumnX, + normalizeColumnLayout, + resolveColumnMode, + widthsEqual, +} from './column-layout.js'; +export type { ColumnGeometry, NormalizedColumnLayout } from './column-layout.js'; export { composeAuthorColorResolver, fallbackAuthorColor, diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 89f356b7b3..99cdd53282 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1123,7 +1123,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options block.columns.gap !== next.activeColumns.gap || Boolean(block.columns.withSeparator) !== Boolean(next.activeColumns.withSeparator) || block.columns.equalWidth !== next.activeColumns.equalWidth || - !widthsEqual(block.columns.widths, next.activeColumns.widths))) || + !widthsEqual(block.columns.widths, next.activeColumns.widths) || + !widthsEqual(block.columns.gaps, next.activeColumns.gaps))) || (!block.columns && (next.activeColumns.count > 1 || Boolean(next.activeColumns.withSeparator))); // Schedule section index change for next page (enables section-aware page numbering) const sectionIndexRaw = block.attrs?.sectionIndex; @@ -1777,6 +1778,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options cachedColumnsState.colsConfig?.gap === colsConfig.gap && cachedColumnsState.colsConfig?.equalWidth === colsConfig.equalWidth && widthsEqual(cachedColumnsState.colsConfig?.widths, colsConfig.widths) && + widthsEqual(cachedColumnsState.colsConfig?.gaps, colsConfig.gaps) && cachedColumnsState.normalized ) { return cachedColumnsState.normalized; diff --git a/packages/layout-engine/layout-engine/src/section-breaks.ts b/packages/layout-engine/layout-engine/src/section-breaks.ts index 3fce475a66..2cf82de337 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.ts @@ -57,15 +57,16 @@ function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout { function isColumnConfigChanging(blockColumns: ColumnLayout | undefined, activeColumns: ColumnLayout): boolean { if (blockColumns) { // Explicit column change: any of count, gap, separator presence, equalWidth, - // or widths differs. withSeparator must be included because a sep-only toggle - // still needs a new column region so the renderer can draw (or stop drawing) - // the separator from the toggle point onward. + // widths, or per-column gaps differs. withSeparator must be included because a + // sep-only toggle still needs a new column region so the renderer can draw (or + // stop drawing) the separator from the toggle point onward. return ( blockColumns.count !== activeColumns.count || blockColumns.gap !== activeColumns.gap || Boolean(blockColumns.withSeparator) !== Boolean(activeColumns.withSeparator) || blockColumns.equalWidth !== activeColumns.equalWidth || - !widthsEqual(blockColumns.widths, activeColumns.widths) + !widthsEqual(blockColumns.widths, activeColumns.widths) || + !widthsEqual(blockColumns.gaps, activeColumns.gaps) ); } // No columns specified = reset to single column (OOXML default). diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts index a841c3debb..976329e76f 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts @@ -90,7 +90,8 @@ export function signaturesEqual(a: SectionSignature, b: SectionSignature): boole a.columnsPx.count === b.columnsPx.count && a.columnsPx.gap === b.columnsPx.gap && a.columnsPx.equalWidth === b.columnsPx.equalWidth && - widthsEqual(a.columnsPx.widths, b.columnsPx.widths) + widthsEqual(a.columnsPx.widths, b.columnsPx.widths) && + widthsEqual(a.columnsPx.gaps, b.columnsPx.gaps) ); const numberingEq = From 5781d578076975b58be302857c00be2ffdf4addb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 22:34:31 -0300 Subject: [PATCH 08/26] feat(columns): extraction emits per-column gaps from paired records (SD-2629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse as {width, space} records so a dropped or zero-width column never desyncs spacing from its column, then slice widths and gaps to the resolved count (gaps.length === count-1; the last column's space is never a gap). Per-column w:col/@space defaults to 0 per ECMA-376 §17.6.3, not parseColumnGap's 720tw section default. The scalar gap is unchanged (first-gap fallback). This activates the step-1 equality/cache sites, which now compare per-column gaps. --- .../sections/extraction.test.ts | 44 +++++++++++++++ .../layout-adapter/sections/extraction.ts | 55 +++++++++++++------ 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts index 67e5d66319..ba58720ca1 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts @@ -284,6 +284,7 @@ describe('extraction', () => { gap: 101.53333333333333, withSeparator: false, widths: [72, 497.26666666666665], + gaps: [101.53333333333333], equalWidth: false, }); }); @@ -324,6 +325,7 @@ describe('extraction', () => { gap: 0, withSeparator: false, widths: [156, 156, 156, 156], + gaps: [0, 0, 0], equalWidth: false, }); }); @@ -466,6 +468,7 @@ describe('extraction', () => { gap: 0, withSeparator: false, widths: [312, 312], + gaps: [0], equalWidth: false, }); }); @@ -536,6 +539,7 @@ describe('extraction', () => { gap: 48, withSeparator: false, widths: [192, 384], + gaps: [48], equalWidth: false, }); }); @@ -574,6 +578,46 @@ describe('extraction', () => { gap: 0, withSeparator: false, widths: [192, 384], + gaps: [0], + equalWidth: false, + }); + }); + + it('emits per-column gaps in explicit mode, dropping the last column space (SD-2629 F9)', () => { + // equalWidth="0", w:num="3" with child spaces [0, 720, 9999]: a gap is the space AFTER each + // non-last column, so gaps.length === count-1 === 2 and the third column's 9999-twip space is + // never a gap. Widths stay 192px each (2880 twips). This is the F9 geometry target. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '3', 'w:equalWidth': '0' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '0' } }, + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '720' } }, + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '9999' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 3, + gap: 0, + withSeparator: false, + widths: [192, 192, 192], + gaps: [0, 48], equalWidth: false, }); }); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts index 968551af08..197dd098c4 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts @@ -258,40 +258,61 @@ function extractColumns(elements: SectionElement[]): ColumnLayout | undefined { // ECMA-376 §17.6.4 column mode, validated against Word (MS Word 16 oracle): // Explicit mode (`w:equalWidth="0"`): widths and inter-column spacing come from the child // `` elements (`w:w` + `w:space`, default 0); the section `w:cols/@w:space` is - // ignored. (Per-column distinct spacing is SD-2629; today the first child's space is - // projected as the single gap.) + // ignored. Per-column distinct spacing is emitted as `gaps` (length count-1; the last + // column's space is never a gap). The scalar `gap` stays the first-gap fallback for + // consumers not yet reading geometry. (SD-2629) // Equal mode (`w:equalWidth="1"` or omitted): Word ignores all child `` data. The // gap comes from `w:cols/@w:space` (default 720); a child `w:space` is NOT consulted, and // child widths are dropped so the columns divide evenly. Count comes from `w:num` // (default 1) in equal mode, and is capped to the valid child-width count in explicit // mode (Word renders min(num, count of with a usable w:w)). (SD-2324) const isExplicit = equalWidth === false; + const toPx = (twips: number) => (twips / TWIPS_PER_INCH) * PX_PER_INCH; + + // Build valid records, preserving the (width, space) pairing so a dropped/zero-width + // column never desyncs the per-column spacing from its column. "Valid" = usable w:w (finite, + // > 0), matching the Word count rule min(num, valid-width count). w:col/@space is the space + // AFTER a column and defaults to 0 (ECMA-376 §17.6.3); it must NOT borrow parseColumnGap's + // section-gap default. (SD-2629) + const columnRecords = columnChildren + .map((child) => { + const widthTwips = Number(child.attributes?.['w:w']); + const spaceTwips = Number(child.attributes?.['w:space']); + return { widthTwips, spaceTwips: Number.isFinite(spaceTwips) && spaceTwips > 0 ? spaceTwips : 0 }; + }) + .filter((record) => Number.isFinite(record.widthTwips) && record.widthTwips > 0); + + // Explicit mode: cap w:num to the valid-width count (Word renders min(num, that count); e.g. + // w:num="4" with two => 2 columns, verified vs Word). This is the authoritative count + // both the fill loop and width math read; the matching clamp in normalizeColumnLayout is a + // defensive net for any other producer. (SD-2324 F8) + if (isExplicit && columnRecords.length > 0) { + count = Math.min(count, columnRecords.length); + } + + // Slice to the resolved count: widths[i] for i < count; gaps[i] = space after column i for + // i < count-1, so gaps.length === count-1 (the last column has no following gap). Slicing the + // VALID records (not raw children) keeps gaps capped at count-1, never the raw child count. + const widths = columnRecords.slice(0, count).map((record) => toPx(record.widthTwips)); + const gaps = columnRecords.slice(0, Math.max(0, count - 1)).map((record) => toPx(record.spaceTwips)); + + // Scalar gap is UNCHANGED from prior behavior: the first child's w:space in explicit mode, the + // section w:cols/@w:space otherwise. Per-column `gaps` above are the SD-2629 source; the scalar + // stays the single-gap fallback for consumers not yet migrated to geometry (flip is step 4). const firstChildSpace = columnChildren.find((child) => child?.attributes?.['w:space'] != null)?.attributes?.[ 'w:space' ]; const gapTwips = isExplicit ? (firstChildSpace ?? 0) : cols.attributes['w:space']; const gapInches = parseColumnGap(gapTwips as string | number | undefined); - const widths = columnChildren - .map((child) => Number(child.attributes?.['w:w'])) - .filter((widthTwips) => Number.isFinite(widthTwips) && widthTwips > 0) - .map((widthTwips) => (widthTwips / 1440) * PX_PER_INCH); - - // Explicit mode: w:num is capped to the valid child-width count (widths.length), i.e. the - // number of that supplied a usable w:w. Word renders min(num, that count) (e.g. - // w:num="4" with two => 2 columns, verified vs Word). This is the authoritative - // count both the fill loop and width math read; the matching clamp in normalizeColumnLayout - // is a defensive net for any other producer. (SD-2324 F8) - if (isExplicit && widths.length > 0) { - count = Math.min(count, widths.length); - } const result: ColumnLayout = { count, gap: gapInches * PX_PER_INCH, withSeparator, - // Only explicit columns carry per-column widths; equal mode divides evenly (Word ignores - // child `w:w` when equalWidth is "1" or omitted). + // Only explicit columns carry per-column widths/gaps; equal mode divides evenly (Word ignores + // child `w:w`/`w:space` when equalWidth is "1" or omitted). ...(isExplicit && widths.length > 0 ? { widths } : {}), + ...(isExplicit && gaps.length > 0 ? { gaps } : {}), ...(equalWidth !== undefined ? { equalWidth } : {}), }; From 89f1c1c386465c80855e9681736a2cebb2e86c7d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 22:40:42 -0300 Subject: [PATCH 09/26] fix(columns): explicit scalar gap from first valid column, not first raw w:space child (SD-2629) The scalar gap (the single-gap fallback read by consumers not yet on geometry) was taken from the first raw declaring a w:space, which can be a dropped/zero-width column or a later one. Per the record model it must be the first VALID column's own space (=== gaps[0]). This removes the scalar-vs-gaps divergence before consumers migrate. Changes no existing test (every current explicit test has column 0's space equal to the first declared space); only the divergent case differs, now correct. Test added. --- .../sections/extraction.test.ts | 39 +++++++++++++++++++ .../layout-adapter/sections/extraction.ts | 17 ++++---- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts index ba58720ca1..e32e1e1567 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts @@ -622,6 +622,45 @@ describe('extraction', () => { }); }); + it('derives the explicit scalar gap from the first valid column, ignoring a preceding invalid child (SD-2629)', () => { + // A leading with no usable w:w is dropped from the record model. The scalar gap (the + // single-gap fallback) must come from the first VALID column's own w:space (720tw -> 48px), + // NOT that dropped column's w:space (1440tw -> 96px). The scalar gap and gaps[0] agree. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:equalWidth': '0' }, + elements: [ + { name: 'w:col', attributes: { 'w:space': '1440' } }, + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '720' } }, + { name: 'w:col', attributes: { 'w:w': '2880' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 48, + withSeparator: false, + widths: [192, 192], + gaps: [48], + equalWidth: false, + }); + }); + it('takes the count from w:num in equal mode (count 3, no children) (SD-2324)', () => { // Equal mode (omitted equalWidth) takes the count straight from w:num and the gap from the // section w:space (720 twips -> 48px); no per-column widths are emitted. diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts index 197dd098c4..67f3eafec3 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts @@ -296,18 +296,17 @@ function extractColumns(elements: SectionElement[]): ColumnLayout | undefined { const widths = columnRecords.slice(0, count).map((record) => toPx(record.widthTwips)); const gaps = columnRecords.slice(0, Math.max(0, count - 1)).map((record) => toPx(record.spaceTwips)); - // Scalar gap is UNCHANGED from prior behavior: the first child's w:space in explicit mode, the - // section w:cols/@w:space otherwise. Per-column `gaps` above are the SD-2629 source; the scalar - // stays the single-gap fallback for consumers not yet migrated to geometry (flip is step 4). - const firstChildSpace = columnChildren.find((child) => child?.attributes?.['w:space'] != null)?.attributes?.[ - 'w:space' - ]; - const gapTwips = isExplicit ? (firstChildSpace ?? 0) : cols.attributes['w:space']; - const gapInches = parseColumnGap(gapTwips as string | number | undefined); + // Scalar gap is the single-gap fallback for consumers not yet reading geometry: in explicit mode + // the first VALID column's own space (=== gaps[0]), NOT the first raw child that declares a + // w:space — a dropped or zero-width column must never contribute a gap. Equal mode uses the + // section w:cols/@w:space (default 720). The flip to per-column geometry is step 4. (SD-2629) + const gapPx = isExplicit + ? toPx(columnRecords[0]?.spaceTwips ?? 0) + : parseColumnGap(cols.attributes['w:space'] as string | number | undefined) * PX_PER_INCH; const result: ColumnLayout = { count, - gap: gapInches * PX_PER_INCH, + gap: gapPx, withSeparator, // Only explicit columns carry per-column widths/gaps; equal mode divides evenly (Word ignores // child `w:w`/`w:space` when equalWidth is "1" or omitted). From d3db07e6b040a2d09c53a24a7d9425cbc7089af0 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 22:49:48 -0300 Subject: [PATCH 10/26] feat(columns): single resolved-count authority; paginator fill stops at resolved count (SD-2629) advanceColumn read the raw w:num while normalizeColumnLayout read the widths-clamped count, so a direct layoutDocument({count:4, widths:[a,b]}) filled four columns while width math assumed two. Add resolveColumnCount as the one count authority (min(num, usable widths)); normalize and the paginator fill loop both read it. advanceColumn resolves from the same state-aware columns it already fetches, so per-state config is honored. The DOCX path is already capped at source by extraction; this guards direct engine callers. Failing test added first (fragment x positions). --- .../contracts/src/column-layout.test.ts | 28 +++++++++++++++++++ .../contracts/src/column-layout.ts | 25 +++++++++++------ packages/layout-engine/contracts/src/index.ts | 1 + .../layout-engine/src/index.test.ts | 20 +++++++++++++ .../layout-engine/src/paginator.ts | 6 +++- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts index f58f003e54..965fae87c2 100644 --- a/packages/layout-engine/contracts/src/column-layout.test.ts +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -10,6 +10,7 @@ import { getColumnWidth, getColumnX, normalizeColumnLayout, + resolveColumnCount, resolveColumnMode, widthsEqual, } from './column-layout.js'; @@ -235,3 +236,30 @@ describe('resolveColumnMode (SD-2629)', () => { expect(resolveColumnMode(undefined)).toBe('equal'); }); }); + +describe('resolveColumnCount (SD-2629)', () => { + it('clamps explicit count to the usable-width count (min(num, widths))', () => { + expect(resolveColumnCount({ count: 4, gap: 20, widths: [192, 384], equalWidth: false })).toBe(2); + expect(resolveColumnCount({ count: 4, gap: 20, widths: [192], equalWidth: false })).toBe(1); + }); + + it('keeps num when it does not exceed the usable-width count', () => { + expect(resolveColumnCount({ count: 2, gap: 20, widths: [192, 384], equalWidth: false })).toBe(2); + }); + + it('does not clamp in equal mode (no usable explicit widths)', () => { + expect(resolveColumnCount({ count: 3, gap: 20 })).toBe(3); + expect(resolveColumnCount({ count: 4, gap: 20, widths: [192, 384], equalWidth: true })).toBe(4); + expect(resolveColumnCount({ count: 4, gap: 20, equalWidth: false })).toBe(4); + }); + + it('floors to a minimum of 1', () => { + expect(resolveColumnCount({ count: 0, gap: 0 })).toBe(1); + expect(resolveColumnCount(undefined)).toBe(1); + }); + + it('agrees with normalizeColumnLayout.count (single count authority)', () => { + const input: ColumnLayout = { count: 4, gap: 20, widths: [192, 384], equalWidth: false }; + expect(normalizeColumnLayout(input, 600).count).toBe(resolveColumnCount(input)); + }); +}); diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index aa62daae5d..4f0edcd34a 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -45,6 +45,21 @@ export function resolveColumnMode(input: ColumnLayout | undefined): 'explicit' | return usableExplicitWidths(input).length > 0 ? 'explicit' : 'equal'; } +/** + * Resolved column count and the SINGLE authority for "how many columns exist": the raw `w:num` + * (default 1, floored, min 1) clamped to the usable explicit-width count in explicit mode (Word + * renders min(num, valid-width count)). Both `normalizeColumnLayout` (width math) and the paginator + * fill loop read this, so the two tracks cannot disagree — a section that declares more columns + * than it supplies widths (e.g. w:num="4" with two ) neither pads surplus columns to ~0px + * slivers nor advances the fill into non-existent columns. Content-width-independent. (SD-2324 F8 / + * SD-2629) + */ +export function resolveColumnCount(input: ColumnLayout | undefined): number { + const rawCount = input && Number.isFinite(input.count) ? Math.max(1, Math.floor(input.count)) : 1; + const explicit = usableExplicitWidths(input); + return explicit.length > 0 ? Math.min(rawCount, explicit.length) : rawCount; +} + export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { return columns ? { @@ -83,20 +98,12 @@ export function normalizeColumnLayout( contentWidth: number, epsilon = 0.0001, ): NormalizedColumnLayout { - const rawCount = input && Number.isFinite(input.count) ? Math.floor(input.count) : 1; - let count = Math.max(1, rawCount || 1); + const count = resolveColumnCount(input); const gap = Math.max(0, input?.gap ?? 0); // Honor per-column widths ONLY in explicit mode (`equalWidth === false` with usable widths). // In equal mode (true or omitted) Word ignores child widths and divides the content area evenly, // so any widths that reach here are not authoritative and must not drive geometry. (SD-2324) const explicitWidths = usableExplicitWidths(input); - // Explicit columns are defined by their widths. When the section declares more - // columns than it supplies widths (e.g. w:num="4" with two ), the surplus columns - // have no width and previously padded to ~0px, rendering as 1px slivers of vertical text - // (SD-2324 F8). Clamp the count to the widths actually provided so every column renders. - if (explicitWidths.length > 0 && explicitWidths.length < count) { - count = explicitWidths.length; - } const totalGap = gap * (count - 1); const availableWidth = contentWidth - totalGap; diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 8200c7a3f3..e954195b1e 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -109,6 +109,7 @@ export { getColumnWidth, getColumnX, normalizeColumnLayout, + resolveColumnCount, resolveColumnMode, widthsEqual, } from './column-layout.js'; diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 32c4be6e8b..76aa76d83e 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -279,6 +279,26 @@ describe('layoutDocument', () => { }); }); + it('caps the fill at the resolved column count when w:num exceeds the supplied widths (SD-2629)', () => { + // count:4 but only two explicit widths -> the resolved count is 2 (Word renders min(num, + // widths)). The fill loop must advance through 2 columns then start a new page, NOT into + // phantom columns 3-4. Before SD-2629, advanceColumn read the raw count (4) while width math + // read the clamped count (2): two answers for "how many columns exist". + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 4, gap: 20, widths: [192, 384], equalWidth: false }, + }; + + // Eight 350px lines: each 720px column fits two, so a 2-column page holds four lines -> exactly + // two pages. Under the bug (4 columns), all eight fit on one page across four column positions. + const layout = layoutDocument([block], [makeMeasure([350, 350, 350, 350, 350, 350, 350, 350])], options); + + const columnXs = new Set(layout.pages.flatMap((page) => page.fragments.map((fragment) => Math.round(fragment.x)))); + expect(columnXs.size).toBe(2); + expect(layout.pages).toHaveLength(2); + }); + it('does not set "page.columns" on single column layout', () => { const options: LayoutOptions = { pageSize: { w: 600, h: 800 }, diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts index ffa75aad49..1bafd86f07 100644 --- a/packages/layout-engine/layout-engine/src/paginator.ts +++ b/packages/layout-engine/layout-engine/src/paginator.ts @@ -1,3 +1,4 @@ +import { resolveColumnCount } from '@superdoc/contracts'; import type { ColumnLayout, Page, PageMargins } from '@superdoc/contracts'; export type NormalizedColumns = ColumnLayout & { width: number }; @@ -168,7 +169,10 @@ export function createPaginator(opts: PaginatorOptions) { const advanceColumn = (state: PageState): PageState => { const activeCols = getActiveColumnsForState(state); - if (state.columnIndex < activeCols.count - 1) { + // Use the RESOLVED count (clamped to usable explicit widths), not the raw w:num, so the fill + // loop and the width math (normalizeColumnLayout) agree on how many columns exist. Without this + // the loop advances into columns that have no width — the SD-2629 two-track count bug. + if (state.columnIndex < resolveColumnCount(activeCols) - 1) { // Snapshot max Y before resetting cursor for the next column state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); state.columnIndex += 1; From d411e5b55380aaf20c8ed2f87245a57cd808d508 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 22:55:12 -0300 Subject: [PATCH 11/26] chore(columns): replace em dashes in comments with hyphens/colons (SD-2629) --- packages/layout-engine/contracts/src/column-layout.ts | 2 +- packages/layout-engine/layout-engine/src/paginator.ts | 2 +- .../src/editors/v1/core/layout-adapter/sections/extraction.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 4f0edcd34a..ce9172d827 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -49,7 +49,7 @@ export function resolveColumnMode(input: ColumnLayout | undefined): 'explicit' | * Resolved column count and the SINGLE authority for "how many columns exist": the raw `w:num` * (default 1, floored, min 1) clamped to the usable explicit-width count in explicit mode (Word * renders min(num, valid-width count)). Both `normalizeColumnLayout` (width math) and the paginator - * fill loop read this, so the two tracks cannot disagree — a section that declares more columns + * fill loop read this, so the two tracks cannot disagree: a section that declares more columns * than it supplies widths (e.g. w:num="4" with two ) neither pads surplus columns to ~0px * slivers nor advances the fill into non-existent columns. Content-width-independent. (SD-2324 F8 / * SD-2629) diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts index 1bafd86f07..c7b2269c92 100644 --- a/packages/layout-engine/layout-engine/src/paginator.ts +++ b/packages/layout-engine/layout-engine/src/paginator.ts @@ -171,7 +171,7 @@ export function createPaginator(opts: PaginatorOptions) { const activeCols = getActiveColumnsForState(state); // Use the RESOLVED count (clamped to usable explicit widths), not the raw w:num, so the fill // loop and the width math (normalizeColumnLayout) agree on how many columns exist. Without this - // the loop advances into columns that have no width — the SD-2629 two-track count bug. + // the loop advances into columns that have no width (the SD-2629 two-track count bug). if (state.columnIndex < resolveColumnCount(activeCols) - 1) { // Snapshot max Y before resetting cursor for the next column state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts index 67f3eafec3..305680bf43 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts @@ -298,7 +298,7 @@ function extractColumns(elements: SectionElement[]): ColumnLayout | undefined { // Scalar gap is the single-gap fallback for consumers not yet reading geometry: in explicit mode // the first VALID column's own space (=== gaps[0]), NOT the first raw child that declares a - // w:space — a dropped or zero-width column must never contribute a gap. Equal mode uses the + // w:space; a dropped or zero-width column must never contribute a gap. Equal mode uses the // section w:cols/@w:space (default 720). The flip to per-column geometry is step 4. (SD-2629) const gapPx = isExplicit ? toPx(columnRecords[0]?.spaceTwips ?? 0) From bf1b32d79451af580ccf0f01770106484d0257b2 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 22:59:18 -0300 Subject: [PATCH 12/26] feat(columns): route all raw column-count decisions through resolveColumnCount (SD-2629) The column break handler advanced into phantom columns on an explicit the same way advanceColumn did (read raw w:num). Route it plus the balancing gates, the region force-page check, and the page/document column-metadata gates through resolveColumnCount, so every count DECISION uses the single authority. Behavior-preserving for the DOCX path (extraction caps count at source); hardens direct callers. Column-break test added. The change-detection comparisons stay raw for now (resolved-equality pass is separate). --- .../layout-engine/src/index.test.ts | 26 +++++++++++++++++++ .../layout-engine/layout-engine/src/index.ts | 21 +++++++++------ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 76aa76d83e..d5c9c98511 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -2176,6 +2176,32 @@ describe('layoutDocument', () => { expect(p2.y).toBe(options.margins!.top); }); + it('treats the last resolved column as last for column breaks when w:num exceeds widths (SD-2629)', () => { + // count:4 but two explicit widths -> resolved count 2. The first break moves to column 1 (the + // last resolved column); the second must start a new page, NOT advance into a phantom column + // 2. Mirror of the advanceColumn fix for explicit handling. + const blocks: FlowBlock[] = [ + { kind: 'columnBreak', id: 'br-1' } as ColumnBreakBlock, + { kind: 'columnBreak', id: 'br-2' } as ColumnBreakBlock, + { kind: 'paragraph', id: 'p2', runs: [] }, + ]; + + const measures: Measure[] = [{ kind: 'columnBreak' }, { kind: 'columnBreak' }, makeMeasure([40])]; + + const options: LayoutOptions = { + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + columns: { count: 4, gap: 48, widths: [192, 384], equalWidth: false }, + }; + + const layout = layoutDocument(blocks, measures, options); + + expect(layout.pages.length).toBe(2); + const p2 = layout.pages[1].fragments.find((f) => f.blockId === 'p2') as ParaFragment; + expect(p2.x).toBeCloseTo(options.margins!.left); + expect(p2.y).toBe(options.margins!.top); + }); + it('starts a new page when columnBreak occurs in last column', () => { const blocks: FlowBlock[] = [ // First columnBreak moves to column 2, second starts a new page diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 99cdd53282..88455c1e6e 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -29,7 +29,12 @@ import type { FlowMode, NormalizedColumnLayout, } from '@superdoc/contracts'; -import { buildLayoutSourceIdentityForFragment, normalizeColumnLayout, getFragmentZIndex } from '@superdoc/contracts'; +import { + buildLayoutSourceIdentityForFragment, + normalizeColumnLayout, + getFragmentZIndex, + resolveColumnCount, +} from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { @@ -1192,7 +1197,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options page.orientation = activeOrientation; } - if (activeColumns.count > 1) { + if (resolveColumnCount(activeColumns) > 1) { page.columns = cloneColumnLayout(activeColumns); } @@ -2306,7 +2311,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const willBalance = endingSectionIndex !== null && !!endingSectionColumns && - endingSectionColumns.count > 1 && + resolveColumnCount(endingSectionColumns) > 1 && !sectionHasExplicitColumnBreak.has(endingSectionIndex); // Balance BEFORE any forced page break. After balancing, all of the @@ -2358,7 +2363,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options alreadyBalancedSections.add(endingSectionIndex!); } } - if (balanceResult === null && columnIndexBefore >= newColumns.count) { + if (balanceResult === null && columnIndexBefore >= resolveColumnCount(newColumns)) { // No balancing applied (either willBalance was false, or // balanceSectionOnPage skipped late). Reducing column count without // balancing means starting the new region at col 0 could overwrite @@ -2836,7 +2841,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const state = paginator.ensurePage(); const activeCols = getActiveColumnsForState(state); - if (state.columnIndex < activeCols.count - 1) { + if (state.columnIndex < resolveColumnCount(activeCols) - 1) { // Not in last column: advance to next column advanceColumn(state); } else { @@ -3000,7 +3005,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if ( sectionColumnsMap.size === 0 && !documentHasAnySectionBreak && - activeColumns.count > 1 && + resolveColumnCount(activeColumns) > 1 && !documentHasExplicitColumnBreak ) { sectionColumnsMap.set(FALLBACK_SECTION_IDX, cloneColumnLayout(activeColumns)); @@ -3010,7 +3015,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } for (const [sectionIdx, sectionCols] of sectionColumnsMap) { - if (sectionCols.count <= 1) continue; + if (resolveColumnCount(sectionCols) <= 1) continue; if (sectionHasExplicitColumnBreak.has(sectionIdx)) continue; if (alreadyBalancedSections.has(sectionIdx)) continue; @@ -3226,7 +3231,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // after processing sections. Page/region-specific column changes are encoded // implicitly via fragment positions. Consumers should not assume this is // a static document-wide value. - columns: activeColumns.count > 1 ? cloneColumnLayout(activeColumns) : undefined, + columns: resolveColumnCount(activeColumns) > 1 ? cloneColumnLayout(activeColumns) : undefined, }; } From c15b5a4ac49aec26fe4bf6d071f4aae1f8727617 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 23:06:18 -0300 Subject: [PATCH 13/26] feat(columns): resolve render-facing column metadata, no phantom columns (SD-2629) page.columns and layout.columns gated on the resolved count but still cloned the raw config, so a direct count:4 / widths:[192,384] layout advertised columns.count===4 while rendering two. Add resolveColumnLayout (count clamped to resolveColumnCount; explicit widths/gaps sliced to that count; not scaled - that stays normalize's job) and use it for both metadata sites. Behavior-identical for the DOCX path (extraction caps at source) and every count<=widths case; only the direct count>widths metadata changes, toward what actually renders. Tests added. --- .../contracts/src/column-layout.test.ts | 27 +++++++++++++++++++ .../contracts/src/column-layout.ts | 15 +++++++++++ packages/layout-engine/contracts/src/index.ts | 1 + .../layout-engine/src/index.test.ts | 12 +++++++++ .../layout-engine/layout-engine/src/index.ts | 6 +++-- 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts index 965fae87c2..1712101e3c 100644 --- a/packages/layout-engine/contracts/src/column-layout.test.ts +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -11,6 +11,7 @@ import { getColumnX, normalizeColumnLayout, resolveColumnCount, + resolveColumnLayout, resolveColumnMode, widthsEqual, } from './column-layout.js'; @@ -263,3 +264,29 @@ describe('resolveColumnCount (SD-2629)', () => { expect(normalizeColumnLayout(input, 600).count).toBe(resolveColumnCount(input)); }); }); + +describe('resolveColumnLayout (SD-2629)', () => { + it('clamps count without advertising phantom columns (count:4 with two widths -> 2)', () => { + expect(resolveColumnLayout({ count: 4, gap: 20, widths: [192, 384], equalWidth: false })).toEqual({ + count: 2, + gap: 20, + widths: [192, 384], + equalWidth: false, + }); + }); + + it('slices surplus widths/gaps when num is below the supplied widths', () => { + expect( + resolveColumnLayout({ count: 2, gap: 20, widths: [100, 200, 300, 400], gaps: [10, 20, 30], equalWidth: false }), + ).toEqual({ count: 2, gap: 20, widths: [100, 200], gaps: [10], equalWidth: false }); + }); + + it('leaves an already-consistent config unchanged', () => { + const input: ColumnLayout = { count: 2, gap: 20, widths: [100, 400], equalWidth: false, withSeparator: true }; + expect(resolveColumnLayout(input)).toEqual(input); + }); + + it('does not slice in equal mode (no explicit widths)', () => { + expect(resolveColumnLayout({ count: 3, gap: 20 })).toEqual({ count: 3, gap: 20 }); + }); +}); diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index ce9172d827..6027e24eff 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -73,6 +73,21 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { : { count: 1, gap: 0 }; } +/** + * Resolve an authored column config to what actually renders: count clamped to resolveColumnCount, + * and explicit widths/gaps sliced to that count (NOT scaled to a content width; that is + * normalizeColumnLayout's job). Use for render-facing metadata (page.columns / layout.columns) so + * it never advertises phantom columns, e.g. count:4 with two widths becomes count:2. (SD-2629) + */ +export function resolveColumnLayout(input: ColumnLayout): ColumnLayout { + const count = resolveColumnCount(input); + const resolved = cloneColumnLayout(input); + resolved.count = count; + if (Array.isArray(resolved.widths)) resolved.widths = resolved.widths.slice(0, count); + if (Array.isArray(resolved.gaps)) resolved.gaps = resolved.gaps.slice(0, Math.max(0, count - 1)); + return resolved; +} + /** * Build resolved per-column geometry from already-resolved widths and the uniform scalar gap. * SD-2629 step 1 keeps this behavior-preserving: it mirrors today's normalized output (scaled diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e954195b1e..2c6f891d84 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -110,6 +110,7 @@ export { getColumnX, normalizeColumnLayout, resolveColumnCount, + resolveColumnLayout, resolveColumnMode, widthsEqual, } from './column-layout.js'; diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index d5c9c98511..86308634d0 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -299,6 +299,18 @@ describe('layoutDocument', () => { expect(layout.pages).toHaveLength(2); }); + it('resolves page/document column metadata to the rendered count, not the raw w:num (SD-2629)', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 4, gap: 20, widths: [192, 384], equalWidth: false }, + }; + const layout = layoutDocument([block], [makeMeasure([350])], options); + // count:4 with two widths renders two columns; metadata must not advertise four. + expect(layout.columns).toEqual({ count: 2, gap: 20, widths: [192, 384], equalWidth: false }); + expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, widths: [192, 384], equalWidth: false }); + }); + it('does not set "page.columns" on single column layout', () => { const options: LayoutOptions = { pageSize: { w: 600, h: 800 }, diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 88455c1e6e..bd7283d2b4 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -34,6 +34,7 @@ import { normalizeColumnLayout, getFragmentZIndex, resolveColumnCount, + resolveColumnLayout, } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; @@ -1198,7 +1199,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } if (resolveColumnCount(activeColumns) > 1) { - page.columns = cloneColumnLayout(activeColumns); + // Render-facing metadata: resolve so it never advertises more columns than render (SD-2629). + page.columns = resolveColumnLayout(activeColumns); } // Set vertical alignment from active section state @@ -3231,7 +3233,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // after processing sections. Page/region-specific column changes are encoded // implicitly via fragment positions. Consumers should not assume this is // a static document-wide value. - columns: resolveColumnCount(activeColumns) > 1 ? cloneColumnLayout(activeColumns) : undefined, + columns: resolveColumnCount(activeColumns) > 1 ? resolveColumnLayout(activeColumns) : undefined, }; } From 282fde691ffc39669b71e21599921834f798c2df Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 23:13:01 -0300 Subject: [PATCH 14/26] fix(columns): resolveColumnLayout drops equal-mode widths; resolve region metadata (SD-2629) Two gaps in the metadata pass: (1) resolveColumnLayout sliced but kept widths/gaps in equal mode, yet the DOM painter treats any columns.widths as explicit, so equal-mode metadata carrying stray widths would misrender separators - now dropped when resolveColumnMode is not explicit. (2) page.columnRegions serialized raw region columns, and the renderer prefers columnRegions over page.columns, so a mid-page continuous-break region could still advertise phantom columns - now resolved at serialization. Tests added for both. --- .../contracts/src/column-layout.test.ts | 10 +++++++ .../contracts/src/column-layout.ts | 18 ++++++++---- .../layout-engine/src/index.test.ts | 28 +++++++++++++++++++ .../layout-engine/layout-engine/src/index.ts | 4 ++- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts index 1712101e3c..d6fa9f240b 100644 --- a/packages/layout-engine/contracts/src/column-layout.test.ts +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -289,4 +289,14 @@ describe('resolveColumnLayout (SD-2629)', () => { it('does not slice in equal mode (no explicit widths)', () => { expect(resolveColumnLayout({ count: 3, gap: 20 })).toEqual({ count: 3, gap: 20 }); }); + + it('drops stray widths/gaps in equal mode (the renderer would treat any widths as explicit)', () => { + expect(resolveColumnLayout({ count: 2, gap: 20, widths: [100, 200], gaps: [10], equalWidth: true })).toEqual({ + count: 2, + gap: 20, + equalWidth: true, + }); + // Omitted equalWidth is equal mode too. + expect(resolveColumnLayout({ count: 2, gap: 20, widths: [100, 200] })).toEqual({ count: 2, gap: 20 }); + }); }); diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 6027e24eff..27e7868b07 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -75,16 +75,24 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { /** * Resolve an authored column config to what actually renders: count clamped to resolveColumnCount, - * and explicit widths/gaps sliced to that count (NOT scaled to a content width; that is - * normalizeColumnLayout's job). Use for render-facing metadata (page.columns / layout.columns) so - * it never advertises phantom columns, e.g. count:4 with two widths becomes count:2. (SD-2629) + * and per-column data reconciled with the mode. In explicit mode widths/gaps are sliced to the + * resolved count (drop surplus); in equal mode they are dropped entirely, because Word ignores + * child widths/spaces and divides evenly, and consumers like the DOM painter treat any `widths` as + * explicit. NOT scaled to a content width; that is normalizeColumnLayout's job. Use for + * render-facing metadata (page.columns / layout.columns / columnRegions) so it never advertises + * phantom columns or stray explicit widths, e.g. count:4 with two widths becomes count:2. (SD-2629) */ export function resolveColumnLayout(input: ColumnLayout): ColumnLayout { const count = resolveColumnCount(input); const resolved = cloneColumnLayout(input); resolved.count = count; - if (Array.isArray(resolved.widths)) resolved.widths = resolved.widths.slice(0, count); - if (Array.isArray(resolved.gaps)) resolved.gaps = resolved.gaps.slice(0, Math.max(0, count - 1)); + if (resolveColumnMode(input) === 'explicit') { + if (Array.isArray(resolved.widths)) resolved.widths = resolved.widths.slice(0, count); + if (Array.isArray(resolved.gaps)) resolved.gaps = resolved.gaps.slice(0, Math.max(0, count - 1)); + } else { + delete resolved.widths; + delete resolved.gaps; + } return resolved; } diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 86308634d0..1e1d6258b4 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -336,6 +336,34 @@ describe('layoutDocument', () => { expect(layout.columns).toEqual({ count: 2, gap: 20, withSeparator: false }); }); + it('resolves mid-page region column metadata to the rendered count (SD-2629)', () => { + // A continuous break to count:4 with two widths must surface as a 2-column region, not 4 - the + // renderer prefers columnRegions over page.columns and reads the config raw. + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'intro', runs: [] }, + { + kind: 'sectionBreak', + id: 'sb-continuous', + type: 'continuous', + columns: { count: 4, gap: 20, widths: [192, 384], equalWidth: false }, + }, + { kind: 'paragraph', id: 'body', runs: [] }, + ]; + const measures: Measure[] = [makeMeasure([30]), { kind: 'sectionBreak' }, makeMeasure([30, 30, 30])]; + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20 }, + }; + + const layout = layoutDocument(blocks, measures, options); + const regions = layout.pages[0].columnRegions; + expect(regions).toBeDefined(); + const last = regions![regions!.length - 1]; + expect(last.columns.count).toBe(2); + expect(last.columns.widths).toEqual([192, 384]); + }); + it('emits page.columnRegions for continuous section breaks that change column config mid-page', () => { // Two sections on the same page: first 2-col with separator, then a // continuous break that switches to 3-col still with separator. The diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index bd7283d2b4..de8f8992ba 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -3198,7 +3198,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options regions.push({ yStart: start.y, yEnd: end ? end.y : state.contentBottom, - columns: start.columns, + // Render-facing region metadata: resolve so a count>widths region does not advertise + // phantom columns to the separator renderer, which reads these configs raw (SD-2629). + columns: resolveColumnLayout(start.columns), }); } state.page.columnRegions = regions; From 00c74e38e2168965ce871fa0e824bbbf81b24e06 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 23:25:19 -0300 Subject: [PATCH 15/26] feat(columns): state-aware column geometry helpers; getCurrentColumnWidth reads them (SD-2629) Foundation for the columnX/width migration. getColumnGeometryForState derives geometry from a state's OWN columns + page size + margins, not the global latest-section values, so positioning an older page uses that page's geometry instead of whatever section is currently active. getCurrentColumnWidth now reads columnWidthForState. Behavior-preserving for the latest state (the common path) and constant margins (all tests); more correct once section margins/page size vary. The columnX re-derivation in placement helpers and the cross-package consumers migrate next, then the old paginator.columnX wrapper is removed. --- .../layout-engine/layout-engine/src/index.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index de8f8992ba..532fdac870 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -28,11 +28,14 @@ import type { SectionNumbering, FlowMode, NormalizedColumnLayout, + ColumnGeometry, } from '@superdoc/contracts'; import { buildLayoutSourceIdentityForFragment, normalizeColumnLayout, getFragmentZIndex, + getColumnGeometry, + getColumnWidth, resolveColumnCount, resolveColumnLayout, } from '@superdoc/contracts'; @@ -1802,11 +1805,26 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options return normalized; }; + // SD-2629: state-aware resolved geometry. Derives from the SAME state's columns + page size + + // margins (NOT the global latest-section values), so positioning an older page uses that page's + // own geometry. Behavior-identical to getCurrentColumns for the latest state and constant margins, + // and more correct for older pages once section margins/size vary. + const getColumnGeometryForState = (state: PageState): ColumnGeometry[] => { + const cols = getActiveColumnsForState(state); + const pageWidth = state.page.size?.w ?? pageSize.w; + // page.margins is always set by startNewPage but optional in the type; fall back to the current + // active margins (the guard never fires at runtime). + const left = state.page.margins?.left ?? activeLeftMargin; + const right = state.page.margins?.right ?? activeRightMargin; + return getColumnGeometry(normalizeColumns(cols, pageWidth - (left + right))); + }; + + const columnWidthForState = (state: PageState, columnIndex: number = state.columnIndex): number => + getColumnWidth(getColumnGeometryForState(state), columnIndex); + const getCurrentColumnWidth = (): number => { - const cols = getCurrentColumns(); const state = states[states.length - 1] ?? null; - const columnIndex = state?.columnIndex ?? 0; - return getColumnWidthAt(cols, columnIndex); + return state ? columnWidthForState(state) : getColumnWidthAt(getCurrentColumns(), 0); }; // Helper to get column X position From fd5b55e6b2730f7f13dd334b657a68d71fb5e747 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 23:30:53 -0300 Subject: [PATCH 16/26] fix(columns): geometry-for-state uses page column snapshot, not global active (SD-2629) getColumnGeometryForState fetched columns via getActiveColumnsForState, which falls back to the global latest-section columns when no mid-page boundary is active - so an older page would be positioned with whatever section is currently active, not its own. Now it uses the active boundary if one applies, else the page's creation-time snapshot (page.columns, the resolved metadata createPage stamps). Geometry-equivalent to the global for the latest state (the only state with consumers today), and correct for older pages, which the cross-package consumers will query next. createPage sets page.columns + margins at creation, so both are available during fill. --- packages/layout-engine/layout-engine/src/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 532fdac870..dbb483cb45 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1810,9 +1810,16 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // own geometry. Behavior-identical to getCurrentColumns for the latest state and constant margins, // and more correct for older pages once section margins/size vary. const getColumnGeometryForState = (state: PageState): ColumnGeometry[] => { - const cols = getActiveColumnsForState(state); + // Columns for THIS page: the active mid-page region's config if one applies, else the page's own + // creation-time snapshot (page.columns, the resolved metadata set in createPage). NOT + // getActiveColumnsForState, which falls back to the global latest-section columns and would + // mis-position an older page once columns vary across sections. (SD-2629) + const cols = + state.activeConstraintIndex >= 0 && state.constraintBoundaries[state.activeConstraintIndex] + ? state.constraintBoundaries[state.activeConstraintIndex].columns + : (state.page.columns ?? { count: 1, gap: 0 }); const pageWidth = state.page.size?.w ?? pageSize.w; - // page.margins is always set by startNewPage but optional in the type; fall back to the current + // page.margins is always set by createPage but optional in the type; fall back to the current // active margins (the guard never fires at runtime). const left = state.page.margins?.left ?? activeLeftMargin; const right = state.page.margins?.right ?? activeRightMargin; From 664be871a7667cc9fdcdb56395cb21afdde3487c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 23:37:14 -0300 Subject: [PATCH 17/26] feat(columns): placement reads state-aware columnX(state) via columnXForState (SD-2629) The placement helpers (paragraph/drawing/image/table) now take columnX(state, index?) instead of columnX(index), and call it with their live state; the index.ts columnX alias points to columnXForState, which derives x from the page's own resolved geometry. columnWidth stays a fixed number (the step-4 boundary for per-span unequal widths). Behavior-preserving: for the latest state (the only state placement positions) columnXForState equals the old paginator.columnX. The old paginator.columnX is now unused by placement; it is removed in the final cleanup once the cross-package consumers migrate too. --- packages/layout-engine/layout-engine/src/index.ts | 12 ++++++++---- .../layout-engine/src/layout-drawing.ts | 6 +++--- .../layout-engine/layout-engine/src/layout-image.ts | 4 ++-- .../layout-engine/src/layout-paragraph.ts | 12 ++++++------ .../layout-engine/layout-engine/src/layout-table.ts | 12 ++++++------ 5 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index dbb483cb45..ed1d282867 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -36,6 +36,7 @@ import { getFragmentZIndex, getColumnGeometry, getColumnWidth, + getColumnX, resolveColumnCount, resolveColumnLayout, } from '@superdoc/contracts'; @@ -1829,13 +1830,16 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const columnWidthForState = (state: PageState, columnIndex: number = state.columnIndex): number => getColumnWidth(getColumnGeometryForState(state), columnIndex); + const columnXForState = (state: PageState, columnIndex: number = state.columnIndex): number => + getColumnX(getColumnGeometryForState(state), columnIndex, state.page.margins?.left ?? activeLeftMargin); + const getCurrentColumnWidth = (): number => { const state = states[states.length - 1] ?? null; return state ? columnWidthForState(state) : getColumnWidthAt(getCurrentColumns(), 0); }; - // Helper to get column X position - const columnX = paginator.columnX; + // Helper to get column X position (state-aware; positions the passed page state, SD-2629). + const columnX = columnXForState; const advanceColumn = paginator.advanceColumn; @@ -2688,7 +2692,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const anchorY = anchorBaseY + offsetV; floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); - const anchorX = tableBlock.anchor?.offsetH ?? columnX(state.columnIndex); + const anchorX = tableBlock.anchor?.offsetH ?? columnX(state); const tableFragment = createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY); state.page.fragments.push(tableFragment); @@ -2914,7 +2918,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } const anchorY = resolveParagraphlessAnchoredTableY(tableBlock, tableMeasure, state); - const anchorX = tableBlock.anchor?.offsetH ?? columnX(state.columnIndex); + const anchorX = tableBlock.anchor?.offsetH ?? columnX(state); floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); state.page.fragments.push(createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY)); diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index bd2ef0b859..8d3fa42b52 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts @@ -22,8 +22,8 @@ export type DrawingLayoutContext = { ensurePage: () => PageState; /** Advances to the next column or page, returning the new page state */ advanceColumn: (state: PageState) => PageState; - /** Computes the X coordinate for a given column index */ - columnX: (columnIndex: number) => number; + /** Computes the X coordinate for a column in the given page state (SD-2629). */ + columnX: (state: PageState, columnIndex?: number) => number; }; /** @@ -113,7 +113,7 @@ export function layoutDrawingBlock({ } const pmRange = extractBlockPmRange(block); - let x = columnX(state.columnIndex) + marginLeft + indentLeft; + let x = columnX(state) + marginLeft + indentLeft; if (isInlineShapeGroup && inlineParagraphAlignment) { const pIndentLeft = typeof attrs?.paragraphIndentLeft === 'number' ? attrs.paragraphIndentLeft : 0; const pIndentRight = typeof attrs?.paragraphIndentRight === 'number' ? attrs.paragraphIndentRight : 0; diff --git a/packages/layout-engine/layout-engine/src/layout-image.ts b/packages/layout-engine/layout-engine/src/layout-image.ts index b2c48f1c7f..f4697438df 100644 --- a/packages/layout-engine/layout-engine/src/layout-image.ts +++ b/packages/layout-engine/layout-engine/src/layout-image.ts @@ -10,7 +10,7 @@ export type ImageLayoutContext = { columns: NormalizedColumns; ensurePage: () => PageState; advanceColumn: (state: PageState) => PageState; - columnX: (columnIndex: number) => number; + columnX: (state: PageState, columnIndex?: number) => number; }; export function layoutImageBlock({ @@ -75,7 +75,7 @@ export function layoutImageBlock({ const fragment: ImageFragment = { kind: 'image', blockId: block.id, - x: columnX(state.columnIndex) + marginLeft, + x: columnX(state) + marginLeft, y: state.cursorY + marginTop, width, height, diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index cbcf546492..aa18da7b47 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -296,7 +296,7 @@ export type ParagraphLayoutContext = { columnWidth: number; ensurePage: () => PageState; advanceColumn: (state: PageState) => PageState; - columnX: (columnIndex: number) => number; + columnX: (state: PageState, columnIndex?: number) => number; floatManager: FloatingObjectManager; remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure; /** @@ -449,7 +449,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para { left: anchors.pageMargins.left, right: anchors.pageMargins.right }, anchors.pageWidth, ) - : columnX(state.columnIndex); + : columnX(state); const pmRange = extractBlockPmRange(entry.block); if (entry.block.kind === 'image' && entry.measure.kind === 'image') { @@ -596,7 +596,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para const maxLineWidth = lines.reduce((max, line) => Math.max(max, line.width ?? 0), 0); const fragmentWidth = maxLineWidth || columnWidth; - let x = columnX(state.columnIndex); + let x = columnX(state); if (frame.xAlign === 'right') { x += columnWidth - fragmentWidth; } else if (frame.xAlign === 'center') { @@ -1091,7 +1091,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para // Negative left indent shifts content left into page margin; negative right indent extends into right margin. // This matches Word's behavior where paragraphs with negative indents extend beyond the content area. // Adjust x position: negative indent shifts left (e.g., -48px moves fragment 48px left) - const adjustedX = columnX(state.columnIndex) + offsetX + negativeLeftIndent; + const adjustedX = columnX(state) + offsetX + negativeLeftIndent; // Expand width: negative indents on both sides expand the fragment width // (e.g., -48px left + -72px right = 120px wider) const adjustedWidth = effectiveColumnWidth - negativeLeftIndent - negativeRightIndent; @@ -1143,9 +1143,9 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para } if (floatAlignment === 'right') { - fragment.x = columnX(state.columnIndex) + offsetX + (effectiveColumnWidth - maxLineWidth); + fragment.x = columnX(state) + offsetX + (effectiveColumnWidth - maxLineWidth); } else if (floatAlignment === 'center') { - fragment.x = columnX(state.columnIndex) + offsetX + (effectiveColumnWidth - maxLineWidth) / 2; + fragment.x = columnX(state) + offsetX + (effectiveColumnWidth - maxLineWidth) / 2; } } state.page.fragments.push(fragment); diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 3f30d140cc..6ba98648f8 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -33,7 +33,7 @@ export type TableLayoutContext = { columnWidth: number; ensurePage: () => PageState; advanceColumn: (state: PageState) => PageState; - columnX: (columnIndex: number) => number; + columnX: (state: PageState, columnIndex?: number) => number; }; /** @@ -1252,7 +1252,7 @@ function layoutMonolithicTable(context: TableLayoutContext): void { state = context.ensurePage(); const height = Math.min(context.measure.totalHeight, state.contentBottom - state.cursorY); - const baseX = context.columnX(state.columnIndex); + const baseX = context.columnX(state); const baseWidth = Math.max(0, context.measure.totalWidth || context.columnWidth); const { x, width } = resolveTableFrame(baseX, context.columnWidth, baseWidth, context.block.attrs); const columnWidths = rescaleColumnWidths(context.measure.columnWidths, context.measure.totalWidth, width); @@ -1412,7 +1412,7 @@ export function layoutTableBlock({ if (block.rows.length === 0 && measure.totalHeight > 0) { const height = Math.min(measure.totalHeight, state.contentBottom - state.cursorY); - const baseX = columnX(state.columnIndex); + const baseX = columnX(state); const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const columnWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); @@ -1569,7 +1569,7 @@ export function layoutTableBlock({ // Only create a fragment if we made progress (rendered some lines) // Don't create empty fragments with just padding if (fragmentHeight > 0 && madeProgress) { - const baseX = columnX(state.columnIndex); + const baseX = columnX(state); const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const scaledWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); @@ -1686,7 +1686,7 @@ export function layoutTableBlock({ forcedPartialRow, ); - const baseX = columnX(state.columnIndex); + const baseX = columnX(state); const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const scaledWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); @@ -1738,7 +1738,7 @@ export function layoutTableBlock({ partialRow, ); - const baseX = columnX(state.columnIndex); + const baseX = columnX(state); const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const scaledWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); From 6e278deb679b8a202ee79b2dbdd0374d96062063 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 23:42:56 -0300 Subject: [PATCH 18/26] fix(columns): state-aware image width read; drawing test mocks take columnX(state) (SD-2629) Two review cleanups before the renderer-separator migration. (1) The image relative-from-column width still read getColumnWidthAt of getCurrentColumns(); now columnWidthForState(state), behavior-preserving for the latest state and closing the same stale-state gap. (2) The two layout-drawing test mocks used columnIndex, but the helper now calls columnX(state), so they computed NaN at runtime; updated to take state. The constant table/paragraph mocks (() => 0) ignore args and stay correct. Note: the package build does not type-gate .test.ts (verified empirically), so vitest is what covers mock correctness. --- packages/layout-engine/layout-engine/src/index.ts | 2 +- .../layout-engine/layout-engine/src/layout-drawing.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index ed1d282867..234a750990 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -2733,7 +2733,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } else if (relativeFrom === 'margin') { maxWidth = activePageSize.w - (activeLeftMargin + activeRightMargin); } else { - maxWidth = getColumnWidthAt(cols, state.columnIndex); + maxWidth = columnWidthForState(state); } const aspectRatio = imgMeasure.width > 0 && imgMeasure.height > 0 ? imgMeasure.width / imgMeasure.height : 1.0; diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index d70415fad2..ae0b2558c9 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -98,7 +98,7 @@ describe('layoutDrawingBlock', () => { columnIndex: currentState.columnIndex + 1, cursorY: currentState.topMargin, }) as unknown as PageState, - columnX: (columnIndex: number) => columnIndex * (mockColumns.width + mockColumns.gap), + columnX: (state: PageState) => state.columnIndex * (mockColumns.width + mockColumns.gap), }; }; @@ -370,7 +370,7 @@ describe('layoutDrawingBlock', () => { }; return stateRef as unknown as PageState; }, - columnX: (columnIndex: number) => columnIndex * (mockColumns.width + mockColumns.gap), + columnX: (state: PageState) => state.columnIndex * (mockColumns.width + mockColumns.gap), }; layoutDrawingBlock(context); From 28c259d9af1cc7d1f4cf8b8d543a48e7b52a80bf Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 23:49:01 -0300 Subject: [PATCH 19/26] feat(columns): renderer separators read resolved geometry, not re-derived x math (SD-2629) getColumnSeparatorPositions now builds geometry via getColumnGeometry(normalizeColumnLayout(columns, contentWidth)) and reads the positions from the contracts getColumnSeparatorPositions(geometry, leftMargin), dropping the duplicate equal/explicit x math. Verified behavior-preserving: both old branches produce positions identical to the geometry, and the <= 1 width guard is kept (geometry.some(width <= 1)); the content-past-separator gate and the caller's withSeparator/count>1 gates are unchanged. Reads page.columns/columnRegions (resolved metadata), not raw activeColumns, so the count is already safe. Painter boundary preserved (contracts only). Isolated painter --noEmit typecheck hits pre-existing TS6305 (unbuilt dep dists), unrelated to this change; CI tsc -b is the gate. --- .../painters/dom/src/renderer.ts | 41 +++++-------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 910e320008..59484fddda 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -44,6 +44,8 @@ import { buildLayoutSourceIdentityForFragment, expandRunsForInlineNewlines, getCellSpacingPx, + getColumnGeometry, + getColumnSeparatorPositions as getColumnSeparatorPositionsFromGeometry, normalizeColumnLayout, } from '@superdoc/contracts'; import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; @@ -1879,37 +1881,14 @@ export class DomPainter { } private getColumnSeparatorPositions(columns: ColumnLayout, leftMargin: number, contentWidth: number): number[] { - const hasExplicitWidths = Array.isArray(columns.widths) && columns.widths.length > 0; - - if (!hasExplicitWidths) { - const equalWidth = (contentWidth - columns.gap * (columns.count - 1)) / columns.count; - if (equalWidth <= 1) return []; - - const separatorPositions: number[] = []; - for (let index = 0; index < columns.count - 1; index += 1) { - separatorPositions.push(leftMargin + (index + 1) * equalWidth + index * columns.gap + columns.gap / 2); - } - return separatorPositions; - } - - const normalizedColumns = normalizeColumnLayout(columns, contentWidth); - if (normalizedColumns.count <= 1) return []; - - const columnWidths = - normalizedColumns.widths ?? Array.from({ length: normalizedColumns.count }, () => normalizedColumns.width); - // A 1px separator only makes sense when every participating column is wider than the separator itself. - if (columnWidths.some((columnWidth) => columnWidth <= 1)) return []; - - const separatorPositions: number[] = []; - let cursorX = leftMargin; - - for (let index = 0; index < normalizedColumns.count - 1; index += 1) { - const currentColumnWidth = columnWidths[index] ?? normalizedColumns.width; - separatorPositions.push(cursorX + currentColumnWidth + normalizedColumns.gap / 2); - cursorX += currentColumnWidth + normalizedColumns.gap; - } - - return separatorPositions; + // SD-2629: separator positions come from the one resolved column geometry (the same source as + // fill count and column widths), not a re-derivation here. The caller has already gated on + // withSeparator and count > 1; we only skip when a participating column is too narrow for a 1px + // line, preserving the prior <= 1 width guards (equal and explicit modes alike). + const geometry = getColumnGeometry(normalizeColumnLayout(columns, contentWidth)); + if (geometry.length <= 1) return []; + if (geometry.some((column) => column.width <= 1)) return []; + return getColumnSeparatorPositionsFromGeometry(geometry, leftMargin); } private renderDecorationsForPage(pageEl: HTMLElement, page: ResolvedPage, pageIndex: number): void { if (this.isSemanticFlow) return; From 389408784c73804c486aaf681abebb456db69a54 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 23:54:39 -0300 Subject: [PATCH 20/26] fix(columns): restore equal-mode narrow-column separator guard after geometry migration (SD-2629) The geometry migration lost the legacy equal-mode skip: when the gap overflows the content area, availableWidth goes negative and normalize falls back to full-content-width columns (floored at 1), so the geometry width never trips the <=1 guard and phantom separators would draw outside the content area. Restore the pre-geometry equalWidth<=1 check for equal mode (explicit mode already matched via the geometry width guard). Added a renderer test (count:3, huge gap, fragments past the phantom positions so only the guard can suppress) to pin it. --- .../dom/src/renderer-column-separators.test.ts | 16 ++++++++++++++++ .../layout-engine/painters/dom/src/renderer.ts | 14 +++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts index 699fd35afe..4d0c3bc7f7 100644 --- a/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts @@ -157,6 +157,22 @@ describe('DomPainter renderColumnSeparators', () => { // contentWidth=90, columnWidth=(90-100)/2=-5 → guard fires. expect(querySeparators(mount)).toHaveLength(0); }); + + it('renders nothing for equal columns whose gap overflows the content area (SD-2629 legacy guard)', () => { + // count:3 with a gap so large the evenly-divided column width goes negative. normalize floors + // fabricated widths at the full content width, so the geometry width alone would not reveal the + // overflow; the pre-geometry equalWidth<=1 guard must still suppress the separators. The far + // fragment sits past where the phantom separators would land, so only the guard (not the + // content-past-separator gate) can suppress them. + const page = buildPage({ + columns: { count: 3, gap: 400, withSeparator: true }, + fragments: [fragAt(96), fragAt(2000)], + }); + paintOnce(buildLayout(page), mount); + + // contentWidth=624, equalWidth=(624-400*2)/3 < 0, so the guard fires. + expect(querySeparators(mount)).toHaveLength(0); + }); }); describe('region-aware path (page.columnRegions)', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 59484fddda..995b7dec2b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1883,9 +1883,17 @@ export class DomPainter { private getColumnSeparatorPositions(columns: ColumnLayout, leftMargin: number, contentWidth: number): number[] { // SD-2629: separator positions come from the one resolved column geometry (the same source as // fill count and column widths), not a re-derivation here. The caller has already gated on - // withSeparator and count > 1; we only skip when a participating column is too narrow for a 1px - // line, preserving the prior <= 1 width guards (equal and explicit modes alike). - const geometry = getColumnGeometry(normalizeColumnLayout(columns, contentWidth)); + // withSeparator and count > 1. + const normalized = normalizeColumnLayout(columns, contentWidth); + // Equal mode: skip when the evenly-divided column is too narrow for a 1px line. This must be + // checked PRE-geometry because normalize floors fabricated widths at 1 (and falls back to the + // full content width when the gap overflows the content area), so the geometry width alone would + // not reveal the overflow. Preserves the legacy guard. + if (!Array.isArray(columns.widths) || columns.widths.length === 0) { + const equalWidth = (contentWidth - columns.gap * (normalized.count - 1)) / normalized.count; + if (equalWidth <= 1) return []; + } + const geometry = getColumnGeometry(normalized); if (geometry.length <= 1) return []; if (geometry.some((column) => column.width <= 1)) return []; return getColumnSeparatorPositionsFromGeometry(geometry, leftMargin); From d1dd751348fa30b16d51b7ad9ee5e3804209e4b6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 4 Jun 2026 00:05:29 -0300 Subject: [PATCH 21/26] fix(columns): key renderer equal-mode guard on resolveColumnMode, not widths presence (SD-2629) The equal-mode narrow-column separator guard keyed on the presence of a widths array, which misclassifies a raw equalWidth:true config carrying stray widths as explicit and skips the guard. Key it on resolveColumnMode(columns) === 'equal' so such a config still takes the equalWidth<=1 skip. Behavior-identical for layout-produced metadata (equal-mode widths are already dropped) and all current tests; this hardens the raw-input path a consumer might pass. --- packages/layout-engine/painters/dom/src/renderer.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 995b7dec2b..ec6d505145 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -47,6 +47,7 @@ import { getColumnGeometry, getColumnSeparatorPositions as getColumnSeparatorPositionsFromGeometry, normalizeColumnLayout, + resolveColumnMode, } from '@superdoc/contracts'; import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; @@ -1888,8 +1889,9 @@ export class DomPainter { // Equal mode: skip when the evenly-divided column is too narrow for a 1px line. This must be // checked PRE-geometry because normalize floors fabricated widths at 1 (and falls back to the // full content width when the gap overflows the content area), so the geometry width alone would - // not reveal the overflow. Preserves the legacy guard. - if (!Array.isArray(columns.widths) || columns.widths.length === 0) { + // not reveal the overflow. Keyed on resolveColumnMode (not the presence of a widths array) so a + // raw equalWidth:true config carrying stray widths still takes the equal-mode guard. Legacy guard. + if (resolveColumnMode(columns) === 'equal') { const equalWidth = (contentWidth - columns.gap * (normalized.count - 1)) / normalized.count; if (equalWidth <= 1) return []; } From fc7941f8e9fc8a444a3fe50b8995ba35e614cff6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 4 Jun 2026 00:05:41 -0300 Subject: [PATCH 22/26] chore(columns): remove dead paginator.columnX and its getCurrentColumns option (SD-2629) Placement now reads columnXForState, the renderer reads geometry, and footnotes/balancing keep their own local columnX, so paginator.columnX and the getCurrentColumns paginator option that only it consumed are dead. Remove both. index.ts's local getCurrentColumns stays (still used for page/document metadata and image width reads). Final behavior- preserving 3b-ii cleanup; layout-engine tsc clean. --- packages/layout-engine/layout-engine/src/index.ts | 1 - .../layout-engine/layout-engine/src/paginator.ts | 15 --------------- 2 files changed, 16 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 234a750990..3ff066096e 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1520,7 +1520,6 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options getActivePageSize: () => activePageSize, getDefaultPageSize: () => pageSize, getActiveColumns: () => activeColumns, - getCurrentColumns: () => getCurrentColumns(), createPage, onNewPage: (state?: PageState) => { // apply pending->active and invalidate columns cache (first callback) diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts index c7b2269c92..f4407c42fa 100644 --- a/packages/layout-engine/layout-engine/src/paginator.ts +++ b/packages/layout-engine/layout-engine/src/paginator.ts @@ -65,7 +65,6 @@ export type PaginatorOptions = { getActivePageSize(): { w: number; h: number }; getDefaultPageSize(): { w: number; h: number }; getActiveColumns(): ColumnLayout; - getCurrentColumns(): NormalizedColumns; createPage(number: number, pageMargins: PageMargins, pageSizeOverride?: { w: number; h: number }): Page; onNewPage?: (state: PageState) => void; /** @@ -94,19 +93,6 @@ export function createPaginator(opts: PaginatorOptions) { return opts.getActiveColumns(); }; - const columnX = (columnIndex: number): number => { - const cols = opts.getCurrentColumns(); - const widths = Array.isArray(cols.widths) && cols.widths.length > 0 ? cols.widths : null; - if (!widths) { - return opts.margins.left + columnIndex * (cols.width + cols.gap); - } - let x = opts.margins.left; - for (let index = 0; index < columnIndex; index += 1) { - x += (widths[index] ?? cols.width) + cols.gap; - } - return x; - }; - const startNewPage = (): PageState => { // Allow caller to update state (e.g., apply pending→active) before we snapshot margins/size // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -203,7 +189,6 @@ export function createPaginator(opts: PaginatorOptions) { startNewPage, ensurePage, advanceColumn, - columnX, getActiveColumnsForState, getPageByNumber, pruneTrailingEmptyPages, From 273f2c698b836f469808cf9e50ac01d821dc9762 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 4 Jun 2026 00:14:15 -0300 Subject: [PATCH 23/26] chore(columns): remove dead NormalizedColumns type from paginator (SD-2629) Unused after dropping the getCurrentColumns option (its only consumer); nothing imports it from paginator (layout-drawing imports its own from layout-image). Pure dead-code removal. --- packages/layout-engine/layout-engine/src/paginator.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts index f4407c42fa..de8c53604c 100644 --- a/packages/layout-engine/layout-engine/src/paginator.ts +++ b/packages/layout-engine/layout-engine/src/paginator.ts @@ -1,8 +1,6 @@ import { resolveColumnCount } from '@superdoc/contracts'; import type { ColumnLayout, Page, PageMargins } from '@superdoc/contracts'; -export type NormalizedColumns = ColumnLayout & { width: number }; - export type ConstraintBoundary = { y: number; columns: ColumnLayout; From 017d4812c380217abd9092898126f334d57b6372 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 4 Jun 2026 00:21:52 -0300 Subject: [PATCH 24/26] feat(columns): region/cache change-detection uses render equality, not raw fields (SD-2629) Add columnRenderLayoutsEqual: a canonical render form (resolved mode + count, scalar gap, withSeparator, and explicit-mode sliced widths/gaps; ignores raw equalWidth and the surplus count/widths resolution discards). The engine region/cache sites (isColumnsChanging, isColumnConfigChanging, the normalized-columns cache key) now use it + resolveColumnCount for the reset-to-single check, so configs that render identically (num:4/widths [a,b] vs num:2/widths[a,b]; equalWidth:true vs omitted) no longer split into separate regions. ~Zero production impact (extraction caps at source). breaks.ts/signaturesEqual (adapter, re-exported, no internal caller) is left for a separate structural-vs-render classification. Tests added. --- .../contracts/src/column-layout.test.ts | 43 +++++++++++++++++++ .../contracts/src/column-layout.ts | 25 +++++++++++ packages/layout-engine/contracts/src/index.ts | 1 + .../layout-engine/layout-engine/src/index.ts | 28 ++++-------- .../layout-engine/src/section-breaks.ts | 26 ++++------- 5 files changed, 87 insertions(+), 36 deletions(-) diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts index d6fa9f240b..d9e46bc4ca 100644 --- a/packages/layout-engine/contracts/src/column-layout.test.ts +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -3,6 +3,7 @@ import type { ColumnLayout } from './index.js'; import { cloneColumnLayout, columnLayoutsEqual, + columnRenderLayoutsEqual, getColumnAtX, getColumnGapAfter, getColumnGeometry, @@ -300,3 +301,45 @@ describe('resolveColumnLayout (SD-2629)', () => { expect(resolveColumnLayout({ count: 2, gap: 20, widths: [100, 200] })).toEqual({ count: 2, gap: 20 }); }); }); + +describe('columnRenderLayoutsEqual (SD-2629)', () => { + it('treats equalWidth:true and omitted equalWidth as render-equal (both equal mode)', () => { + expect(columnRenderLayoutsEqual({ count: 2, gap: 24, equalWidth: true }, { count: 2, gap: 24 })).toBe(true); + }); + + it('treats num>widths and num===widths as render-equal when the resolved columns match', () => { + expect( + columnRenderLayoutsEqual( + { count: 4, gap: 24, widths: [192, 384], equalWidth: false }, + { count: 2, gap: 24, widths: [192, 384], equalWidth: false }, + ), + ).toBe(true); + }); + + it('distinguishes a separator toggle', () => { + expect( + columnRenderLayoutsEqual({ count: 2, gap: 24, withSeparator: true }, { count: 2, gap: 24, withSeparator: false }), + ).toBe(false); + }); + + it('distinguishes a different gap', () => { + expect(columnRenderLayoutsEqual({ count: 2, gap: 24 }, { count: 2, gap: 48 })).toBe(false); + }); + + it('distinguishes explicit vs equal mode and different resolved widths', () => { + expect( + columnRenderLayoutsEqual({ count: 2, gap: 24, widths: [192, 384], equalWidth: false }, { count: 2, gap: 24 }), + ).toBe(false); + expect( + columnRenderLayoutsEqual( + { count: 2, gap: 24, widths: [192, 384], equalWidth: false }, + { count: 2, gap: 24, widths: [100, 400], equalWidth: false }, + ), + ).toBe(false); + }); + + it('handles missing inputs', () => { + expect(columnRenderLayoutsEqual(undefined, undefined)).toBe(true); + expect(columnRenderLayoutsEqual({ count: 2, gap: 24 }, undefined)).toBe(false); + }); +}); diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 27e7868b07..1af7fae31f 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -237,3 +237,28 @@ export function columnLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): boolean widthsEqual(a.gaps, b.gaps) ); } + +/** + * Render equality: true when two column configs produce the SAME rendered layout even if their raw + * fields differ. Compares the canonical render form (resolved mode + count, scalar gap, + * withSeparator, and in explicit mode the sliced widths/gaps) and deliberately ignores raw + * `equalWidth` and the surplus count/widths that resolution discards. Use for region/cache change + * detection so e.g. `{num:4, widths:[a,b]}` vs `{num:2, widths:[a,b]}`, or `equalWidth:true` vs an + * omitted equalWidth, do not split into separate regions. (SD-2629) + */ +export function columnRenderLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + const mode = resolveColumnMode(a); + if (mode !== resolveColumnMode(b)) return false; + if (resolveColumnCount(a) !== resolveColumnCount(b)) return false; + if ((a.gap ?? 0) !== (b.gap ?? 0)) return false; + if (Boolean(a.withSeparator) !== Boolean(b.withSeparator)) return false; + if (mode === 'explicit') { + const ra = resolveColumnLayout(a); + const rb = resolveColumnLayout(b); + if (!widthsEqual(ra.widths, rb.widths)) return false; + if (!widthsEqual(ra.gaps, rb.gaps)) return false; + } + return true; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 2c6f891d84..1fa2cc181a 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -102,6 +102,7 @@ import type { LayoutSourceIdentity } from './layout-identity.js'; export { cloneColumnLayout, columnLayoutsEqual, + columnRenderLayoutsEqual, getColumnAtX, getColumnGapAfter, getColumnGeometry, diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 3ff066096e..ce02c4fab6 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -37,6 +37,7 @@ import { getColumnGeometry, getColumnWidth, getColumnX, + columnRenderLayoutsEqual, resolveColumnCount, resolveColumnLayout, } from '@superdoc/contracts'; @@ -63,7 +64,7 @@ import { createPaginator, type PageState, type ConstraintBoundary } from './pagi import { formatPageNumber } from './pageNumbering.js'; import { shouldSuppressSpacingForEmpty, shouldSuppressOwnSpacing } from './layout-utils.js'; import { balanceSectionOnPage, type BalancingFragment, type MeasureData } from './column-balancing.js'; -import { cloneColumnLayout, widthsEqual } from './column-utils.js'; +import { cloneColumnLayout } from './column-utils.js'; type PageSize = { w: number; h: number }; type Margins = { @@ -1122,20 +1123,13 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (block.pageSize) next.pendingPageSize = { w: block.pageSize.w, h: block.pageSize.h }; if (block.orientation) next.pendingOrientation = block.orientation; const sectionType = block.type ?? 'continuous'; - // Check if columns are changing: either explicitly to a different config, - // or implicitly resetting to single column (undefined = single column in OOXML). - // withSeparator must be compared because a sep-only toggle still needs a new - // column region so the renderer can draw (or stop drawing) the separator from - // the toggle point onward. + // Columns change when the block's resolved RENDER layout differs from the active one (render + // equality ignores raw equalWidth / surplus count that resolution discards), or when columns + // reset to single (undefined). withSeparator is part of render equality: a sep-only toggle still + // needs a new region so the renderer can start or stop the separator from the toggle point. const isColumnsChanging = - (block.columns && - (block.columns.count !== next.activeColumns.count || - block.columns.gap !== next.activeColumns.gap || - Boolean(block.columns.withSeparator) !== Boolean(next.activeColumns.withSeparator) || - block.columns.equalWidth !== next.activeColumns.equalWidth || - !widthsEqual(block.columns.widths, next.activeColumns.widths) || - !widthsEqual(block.columns.gaps, next.activeColumns.gaps))) || - (!block.columns && (next.activeColumns.count > 1 || Boolean(next.activeColumns.withSeparator))); + (block.columns && !columnRenderLayoutsEqual(block.columns, next.activeColumns)) || + (!block.columns && (resolveColumnCount(next.activeColumns) > 1 || Boolean(next.activeColumns.withSeparator))); // Schedule section index change for next page (enables section-aware page numbering) const sectionIndexRaw = block.attrs?.sectionIndex; const metadataIndex = typeof sectionIndexRaw === 'number' ? sectionIndexRaw : Number(sectionIndexRaw ?? NaN); @@ -1784,11 +1778,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options cachedColumnsState.state === state && cachedColumnsState.constraintIndex === constraintIndex && cachedColumnsState.contentWidth === currentContentWidth && - cachedColumnsState.colsConfig?.count === colsConfig.count && - cachedColumnsState.colsConfig?.gap === colsConfig.gap && - cachedColumnsState.colsConfig?.equalWidth === colsConfig.equalWidth && - widthsEqual(cachedColumnsState.colsConfig?.widths, colsConfig.widths) && - widthsEqual(cachedColumnsState.colsConfig?.gaps, colsConfig.gaps) && + columnRenderLayoutsEqual(cachedColumnsState.colsConfig ?? undefined, colsConfig) && cachedColumnsState.normalized ) { return cachedColumnsState.normalized; diff --git a/packages/layout-engine/layout-engine/src/section-breaks.ts b/packages/layout-engine/layout-engine/src/section-breaks.ts index 2cf82de337..c3ee970ec0 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.ts @@ -1,5 +1,6 @@ import type { ColumnLayout, SectionBreakBlock } from '@superdoc/contracts'; -import { cloneColumnLayout, widthsEqual } from './column-utils.js'; +import { columnRenderLayoutsEqual, resolveColumnCount } from '@superdoc/contracts'; +import { cloneColumnLayout } from './column-utils.js'; export type SectionState = { activeTopMargin: number; @@ -56,23 +57,14 @@ function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout { */ function isColumnConfigChanging(blockColumns: ColumnLayout | undefined, activeColumns: ColumnLayout): boolean { if (blockColumns) { - // Explicit column change: any of count, gap, separator presence, equalWidth, - // widths, or per-column gaps differs. withSeparator must be included because a - // sep-only toggle still needs a new column region so the renderer can draw (or - // stop drawing) the separator from the toggle point onward. - return ( - blockColumns.count !== activeColumns.count || - blockColumns.gap !== activeColumns.gap || - Boolean(blockColumns.withSeparator) !== Boolean(activeColumns.withSeparator) || - blockColumns.equalWidth !== activeColumns.equalWidth || - !widthsEqual(blockColumns.widths, activeColumns.widths) || - !widthsEqual(blockColumns.gaps, activeColumns.gaps) - ); + // Columns change when the block's resolved RENDER layout differs from the active one. Render + // equality includes withSeparator (a sep-only toggle needs a new region) and ignores raw + // equalWidth / surplus count that resolution discards. + return !columnRenderLayoutsEqual(blockColumns, activeColumns); } - // No columns specified = reset to single column (OOXML default). - // This is a change if currently in multi-column layout, or if the separator was on - // (the reset implicitly turns it off). - return activeColumns.count > 1 || Boolean(activeColumns.withSeparator); + // No columns specified = reset to single column (OOXML default). A change if the active layout + // renders as multi-column, or the separator was on (the reset implicitly turns it off). + return resolveColumnCount(activeColumns) > 1 || Boolean(activeColumns.withSeparator); } /** From bdb3baba3a978cabc731bf9e7fc19bbcdd68b3c2 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 5 Jun 2026 10:24:51 -0700 Subject: [PATCH 25/26] fix(columns): ignore gaps-only deltas until geometry uses them --- .../contracts/src/column-layout.test.ts | 9 +++ .../contracts/src/column-layout.ts | 14 ++-- .../layout-engine/src/index.test.ts | 64 +++++++++++++++++++ .../layout-engine/src/layout-drawing.test.ts | 2 +- .../layout-engine/src/section-breaks.test.ts | 21 ++++++ 5 files changed, 103 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts index d9e46bc4ca..56dacc4040 100644 --- a/packages/layout-engine/contracts/src/column-layout.test.ts +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -326,6 +326,15 @@ describe('columnRenderLayoutsEqual (SD-2629)', () => { expect(columnRenderLayoutsEqual({ count: 2, gap: 24 }, { count: 2, gap: 48 })).toBe(false); }); + it('treats explicit layouts differing only by per-column gaps as render-equal until geometry flips', () => { + expect( + columnRenderLayoutsEqual( + { count: 3, gap: 24, widths: [100, 100, 300], gaps: [24, 24], equalWidth: false }, + { count: 3, gap: 24, widths: [100, 100, 300], gaps: [24, 96], equalWidth: false }, + ), + ).toBe(true); + }); + it('distinguishes explicit vs equal mode and different resolved widths', () => { expect( columnRenderLayoutsEqual({ count: 2, gap: 24, widths: [192, 384], equalWidth: false }, { count: 2, gap: 24 }), diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 1af7fae31f..0902b43121 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -240,11 +240,14 @@ export function columnLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): boolean /** * Render equality: true when two column configs produce the SAME rendered layout even if their raw - * fields differ. Compares the canonical render form (resolved mode + count, scalar gap, - * withSeparator, and in explicit mode the sliced widths/gaps) and deliberately ignores raw - * `equalWidth` and the surplus count/widths that resolution discards. Use for region/cache change - * detection so e.g. `{num:4, widths:[a,b]}` vs `{num:2, widths:[a,b]}`, or `equalWidth:true` vs an - * omitted equalWidth, do not split into separate regions. (SD-2629) + * fields differ. Compares the canonical render form for today's renderer (resolved mode + count, + * scalar gap, withSeparator, and in explicit mode the sliced widths) and deliberately ignores raw + * `equalWidth` and the surplus count/widths that resolution discards. Per-column `gaps` are + * intentionally ignored until geometry/separators consume them (step 4), so a gaps-only authored + * delta does not split regions or invalidate the normalized-columns cache before it becomes + * paint-significant. Use for region/cache change detection so e.g. `{num:4, widths:[a,b]}` vs + * `{num:2, widths:[a,b]}`, or `equalWidth:true` vs an omitted equalWidth, do not split into + * separate regions. (SD-2629) */ export function columnRenderLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): boolean { if (!a && !b) return true; @@ -258,7 +261,6 @@ export function columnRenderLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): bo const ra = resolveColumnLayout(a); const rb = resolveColumnLayout(b); if (!widthsEqual(ra.widths, rb.widths)) return false; - if (!widthsEqual(ra.gaps, rb.gaps)) return false; } return true; } diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 26be955b4f..2ce6471234 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -4535,6 +4535,70 @@ describe('requirePageBoundary edge cases', () => { expect(p3.width).toBeCloseTo(550); }); + it('keeps the current explicit column after a manual column break when only later per-column gaps differ', () => { + const toExplicitColumns: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-explicit', + type: 'continuous', + columns: { count: 3, gap: 48, widths: [100, 100, 300], gaps: [48, 48], equalWidth: false }, + margins: {}, + }; + const laterGapsOnlyDelta: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-gaps-only', + type: 'continuous', + columns: { count: 3, gap: 48, widths: [100, 100, 300], gaps: [48, 96], equalWidth: false }, + margins: {}, + }; + + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [] }, + toExplicitColumns, + { kind: 'paragraph', id: 'p2', runs: [] }, + { kind: 'columnBreak', id: 'br-1' } as ColumnBreakBlock, + { kind: 'paragraph', id: 'p3', runs: [] }, + laterGapsOnlyDelta, + { kind: 'paragraph', id: 'p4', runs: [] }, + ]; + + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'sectionBreak' }, + makeMeasure([40]), + { kind: 'columnBreak' }, + makeMeasure([40]), + { kind: 'sectionBreak' }, + makeMeasure([40]), + ]; + + const options: LayoutOptions = { + pageSize: { w: 700, h: 792 }, + margins: { top: 72, right: 50, bottom: 72, left: 50 }, + }; + + const layout = layoutDocument(blocks, measures, options); + const page = layout.pages[0]; + const contentWidth = options.pageSize!.w - options.margins!.left - options.margins!.right; + const totalGap = 48 * 2; + const expectedSecondColumnX = 50 + (100 * (contentWidth - totalGap)) / (100 + 100 + 300) + 48; + + const p2 = page.fragments.find((f) => f.blockId === 'p2') as ParaFragment; + const p3 = page.fragments.find((f) => f.blockId === 'p3') as ParaFragment; + const p4 = page.fragments.find((f) => f.blockId === 'p4') as ParaFragment; + + expect(p2.x).toBeCloseTo(50); + expect(p3.x).toBeCloseTo(expectedSecondColumnX); + expect(p4.x).toBeCloseTo(expectedSecondColumnX); + expect(page.columnRegions).toHaveLength(2); + expect(page.columnRegions?.[1]?.columns).toEqual({ + count: 3, + gap: 48, + widths: [100, 100, 300], + gaps: [48, 48], + equalWidth: false, + }); + }); + it('does not balance the final page for explicit custom-width columns', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index ae0b2558c9..0ba5888187 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -685,7 +685,7 @@ describe('layoutDrawingBlock', () => { it('should use correct columnX for multi-column layout', () => { const context = createMockContext({}, {}, { columnIndex: 2 }); - context.columnX = (index: number) => index * 620; // width(600) + gap(20) + context.columnX = (state: PageState) => state.columnIndex * 620; // width(600) + gap(20) const state = context.ensurePage(); layoutDrawingBlock(context); diff --git a/packages/layout-engine/layout-engine/src/section-breaks.test.ts b/packages/layout-engine/layout-engine/src/section-breaks.test.ts index 49f3143b1f..fe7b6084c6 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.test.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.test.ts @@ -141,6 +141,27 @@ describe('scheduleSectionBreak', () => { expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48 }); }); + it('does not trigger mid-page region change for explicit gaps-only changes before geometry uses gaps', () => { + const state = createSectionState({ + activeColumns: { count: 3, gap: 48, widths: [100, 100, 300], gaps: [48, 48], equalWidth: false }, + }); + const block = createSectionBreak({ + type: 'continuous', + columns: { count: 3, gap: 48, widths: [100, 100, 300], gaps: [48, 96], equalWidth: false }, + }); + + const result = scheduleSectionBreak(block, state, BASE_MARGINS); + + expect(result.decision.forceMidPageRegion).toBe(false); + expect(result.state.pendingColumns).toEqual({ + count: 3, + gap: 48, + widths: [100, 100, 300], + gaps: [48, 96], + equalWidth: false, + }); + }); + it('detects column change when only withSeparator toggles on', () => { const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: false } }); const block = createSectionBreak({ From d51920baeadf114301a47084d8cda4a66620dc1e Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 5 Jun 2026 10:52:38 -0700 Subject: [PATCH 26/26] chore(tests): fix stale test expectation --- .../src/editors/v1/core/layout-adapter/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts index 9bdcc3e5b6..17b3097d1e 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts @@ -930,6 +930,7 @@ describe('toFlowBlocks', () => { gap: 101.53333333333333, withSeparator: false, widths: [72, 497.26666666666665], + gaps: [101.53333333333333], equalWidth: false, }); });