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 98ffef97d1..26b02b30e4 100644 --- a/packages/layout-engine/layout-engine/src/column-balancing.test.ts +++ b/packages/layout-engine/layout-engine/src/column-balancing.test.ts @@ -555,4 +555,190 @@ describe('balanceSectionOnPage', () => { expect(result).toBeNull(); }); + + // SD-3359: Word balances a continuous multi-column section by flowing content + // line-by-line — a paragraph that straddles the column boundary SPLITS at a line + // boundary (the IT-1150 complaint). Atomic per-fragment assignment leaves the + // columns lumpy whenever one fragment is large relative to the section. + describe('paragraph line splitting across columns (SD-3359)', () => { + type SplitFragment = TestFragment & { + fromLine?: number; + toLine?: number; + continuesFromPrev?: boolean; + continuesOnNext?: boolean; + }; + const LINE = 20; + const TOP = 96; + const COL1_X = 96 + 288 + 48; + + /** A (5 lines) + B (3 lines) + C (14 lines): atomic best is 160 | 280 (120px lumpy); + * line-balanced is 220 | 220 with C split across the boundary. */ + function straddleFixture(cLines = 14): { + fragments: SplitFragment[]; + measureMap: Map }>; + blockSectionMap: Map; + } { + const mk = (id: string, y: number): SplitFragment => ({ + blockId: id, + x: 96, + y, + width: 624, + kind: 'para', + }); + const fragments = [mk('A', TOP), mk('B', TOP + 100), mk('C', TOP + 160)]; + const measureMap = new Map }>([ + ['A', createMeasure('paragraph', Array(5).fill(LINE))], + ['B', createMeasure('paragraph', Array(3).fill(LINE))], + ['C', createMeasure('paragraph', Array(cLines).fill(LINE))], + ]); + const blockSectionMap = new Map([ + ['A', 1], + ['B', 1], + ['C', 1], + ]); + return { fragments, measureMap, blockSectionMap }; + } + + const balance = ( + fragments: SplitFragment[], + measureMap: Map }>, + blockSectionMap: Map, + extra: Record = {}, + ) => + balanceSectionOnPage({ + fragments, + sectionIndex: 1, + sectionColumns: { count: 2, gap: 48, width: 288 }, + sectionHasExplicitColumnBreak: false, + blockSectionMap, + margins: { left: 96 }, + topMargin: TOP, + columnWidth: 288, + availableHeight: 720, + measureMap, + ...extra, + }); + + it('splits a straddling paragraph at a line boundary so columns balance', () => { + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + + const result = balance(fragments, measureMap, blockSectionMap); + + expect(result).not.toBeNull(); + // C was split into two fragments. + const cFrags = fragments.filter((f) => f.blockId === 'C') as SplitFragment[]; + expect(cFrags.length).toBe(2); + const [c1, c2] = cFrags.sort((a, b) => (a.fromLine ?? 0) - (b.fromLine ?? 0)); + // The halves partition C's lines contiguously. + expect(c1.toLine).toBe(c2.fromLine!); + expect(c2.toLine).toBe(14); + // First half continues in col 0 below A+B; second half tops col 1. + expect(c1.x).toBe(96); + expect(c2.x).toBe(COL1_X); + expect(c2.y).toBe(TOP); + expect(c1.continuesOnNext).toBe(true); + expect(c2.continuesFromPrev).toBe(true); + // Column bottoms balance within one line height (vs 120px atomic lumpiness). + const bottom = (f: SplitFragment): number => { + const from = f.fromLine ?? 0; + const to = f.toLine ?? measureMap.get(f.blockId)!.lines.length; + return f.y + (to - from) * LINE; + }; + const col0Bottom = Math.max(...fragments.filter((f) => f.x === 96).map(bottom)); + const col1Bottom = Math.max(...fragments.filter((f) => f.x === COL1_X).map(bottom)); + expect(Math.abs(col0Bottom - col1Bottom)).toBeLessThanOrEqual(LINE); + // The balanced bottom beats the atomic assignment (TOP + 280). + expect(result!.maxY).toBeLessThan(TOP + 280); + expect(result!.maxY).toBe(Math.max(col0Bottom, col1Bottom)); + }); + + it('does not split a paragraph with keepLines (author intent wins)', () => { + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + + const result = balance(fragments, measureMap, blockSectionMap, { + keepLinesBlockIds: new Set(['C']), + }); + + expect(result).not.toBeNull(); + // C stays whole — no extra fragment, no partial line range. + expect(fragments.filter((f) => f.blockId === 'C').length).toBe(1); + const c = fragments.find((f) => f.blockId === 'C')! as SplitFragment; + expect(c.fromLine ?? 0).toBe(0); + expect(c.toLine ?? 14).toBe(14); + }); + + it('balances a single tall paragraph alone in the section by splitting it', () => { + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + const only = [{ ...fragments[2], y: TOP }]; // C alone (14 lines = 280px) + + const result = balance(only, measureMap, blockSectionMap); + + // Previously skipped (single atomic block can't distribute); a breakable + // paragraph CAN balance — Word splits it across the columns. + expect(result).not.toBeNull(); + expect(only.length).toBe(2); + const [c1, c2] = (only as SplitFragment[]).sort((a, b) => (a.fromLine ?? 0) - (b.fromLine ?? 0)); + expect(c1.toLine).toBe(c2.fromLine!); + expect(c2.toLine).toBe(14); + expect(result!.maxY).toBeLessThan(TOP + 280); + }); + + it('slices remeasured fragment.lines across the split (no duplicated halves)', () => { + // A fragment remeasured for a narrower column carries its own `lines`, and + // resolveParagraph renders that array INSTEAD of measure.lines[fromLine..toLine]. + // The split must slice `lines` for each half, or both columns render the whole + // paragraph. The remeasured heights (22px) also differ from the stale measure + // (20px), so the break point and cursors must come from the remeasured lines. + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + const REMEASURED = 22; + const c = fragments[2] as SplitFragment & { lines?: Array<{ lineHeight: number }> }; + c.lines = Array.from({ length: 14 }, () => ({ lineHeight: REMEASURED })); + + const result = balance(fragments, measureMap, blockSectionMap); + + expect(result).not.toBeNull(); + const cFrags = ( + fragments.filter((f) => f.blockId === 'C') as Array }> + ).sort((a, b) => (a.fromLine ?? 0) - (b.fromLine ?? 0)); + expect(cFrags.length).toBe(2); + const [c1, c2] = cFrags; + // Each half carries ONLY its own remeasured lines, partitioning the original 14. + expect(c1.lines).toBeDefined(); + expect(c2.lines).toBeDefined(); + expect(c1.lines!.length + c2.lines!.length).toBe(14); + expect(c1.lines!.length).toBe((c1.toLine ?? 0) - (c1.fromLine ?? 0)); + expect(c2.lines!.length).toBe(c2.toLine! - c2.fromLine!); + // Cursors advanced by the remeasured heights: the second column's bottom is + // its line count at 22px, not at the stale 20px measure. + const col1Frags = fragments.filter((f) => f.x === COL1_X) as Array< + SplitFragment & { lines?: Array<{ lineHeight: number }> } + >; + const col1Bottom = Math.max( + ...col1Frags.map((f) => f.y + (f.lines ? f.lines.reduce((s, l) => s + l.lineHeight, 0) : 0)), + ); + expect(col1Bottom).toBe(result!.maxY); + }); + + it('offsets the split by the fragment fromLine when pagination already split the paragraph', () => { + const { fragments, measureMap, blockSectionMap } = straddleFixture(); + // C is the tail of a 16-line paragraph: this page renders lines [2, 16). + measureMap.set('C', createMeasure('paragraph', Array(16).fill(LINE))); + const c = fragments[2]; + c.fromLine = 2; + c.toLine = 16; + + const result = balance(fragments, measureMap, blockSectionMap); + + expect(result).not.toBeNull(); + const cFrags = (fragments.filter((f) => f.blockId === 'C') as SplitFragment[]).sort( + (a, b) => (a.fromLine ?? 0) - (b.fromLine ?? 0), + ); + expect(cFrags.length).toBe(2); + const [c1, c2] = cFrags; + expect(c1.fromLine).toBe(2); + expect(c1.toLine).toBe(c2.fromLine!); + expect(c2.toLine).toBe(16); + expect(c2.fromLine!).toBeGreaterThan(2); + }); + }); }); diff --git a/packages/layout-engine/layout-engine/src/column-balancing.ts b/packages/layout-engine/layout-engine/src/column-balancing.ts index e330fb4026..96e89bcb88 100644 --- a/packages/layout-engine/layout-engine/src/column-balancing.ts +++ b/packages/layout-engine/layout-engine/src/column-balancing.ts @@ -158,9 +158,18 @@ export function calculateBalancedColumnHeight( }; } - // Calculate total content height and block-height extremes + // Calculate total content height and block-height extremes. A column can + // never be shorter than its tallest INDIVISIBLE chunk: the full height for + // an unbreakable block, but only the tallest LINE for a breakable paragraph + // (SD-3359 — flooring at a breakable paragraph's full height pinned the + // search above the balanced height and packed the overflow lines into the + // first column instead of splitting evenly). const totalHeight = ctx.contentBlocks.reduce((sum, b) => sum + b.measuredHeight, 0); - const maxBlockHeight = ctx.contentBlocks.reduce((m, b) => Math.max(m, b.measuredHeight), 0); + const maxBlockHeight = ctx.contentBlocks.reduce((m, b) => { + const indivisible = + b.canBreak && b.lineHeights && b.lineHeights.length > 1 ? Math.max(...b.lineHeights) : b.measuredHeight; + return Math.max(m, indivisible); + }, 0); // Early exit: content is very small, no need to balance if (totalHeight < config.minColumnHeight * ctx.columnCount) { @@ -506,6 +515,13 @@ export interface BalancingFragment { fromLine?: number; /** Ending line index (exclusive) for partial paragraph fragments */ toLine?: number; + /** + * Remeasured lines carried by the fragment itself (set when a paragraph measured at one + * width is placed in a narrower column or beside a float). When present, the resolve + * stage renders THIS array and ignores fromLine/toLine into measure.lines - so balancing + * must source heights from it and slice it when splitting a fragment across columns. + */ + lines?: Array<{ lineHeight: number }>; /** Pre-computed height for non-paragraph fragments */ height?: number; } @@ -549,6 +565,11 @@ interface FragmentInfo { */ function getFragmentHeight(fragment: BalancingFragment, measureMap: Map): number { if (fragment.kind === 'para') { + // A fragment remeasured for a narrower column carries its own lines; the resolve + // stage renders (and sizes) from THAT array, so balancing must agree with it. + if (fragment.lines && fragment.lines.length > 0) { + return fragment.lines.reduce((sum, l) => sum + (l.lineHeight ?? 0), 0); + } const measure = measureMap.get(fragment.blockId); if (!measure || measure.kind !== 'paragraph' || !measure.lines) { return 0; @@ -665,6 +686,12 @@ export interface BalanceSectionOnPageArgs { * Optional; when omitted no fragment is treated as a marker. */ sectPrMarkerBlockIds?: Set; + /** + * Block IDs of paragraphs with `w:keepLines` — the author asked Word not to + * split these, so they stay atomic during balancing. Optional; when omitted + * every multi-line paragraph is splittable. (SD-3359) + */ + keepLinesBlockIds?: Set; } /** @@ -784,13 +811,42 @@ export function balanceSectionOnPage(args: BalanceSectionOnPageArgs): { maxY: nu // Use `getBalancingHeight` so empty sectPr-marker paragraphs contribute 0 // to their column's cursor — matching Word's behavior of not rendering a // blank line for such markers. - const contentBlocks: BalancingBlock[] = ordered.map((f, i) => ({ - blockId: `${f.blockId}#${i}`, - measuredHeight: getBalancingHeight(f, args.measureMap, args.sectPrMarkerBlockIds), - canBreak: false, - keepWithNext: false, - keepTogether: true, - })); + // + // SD-3359: multi-line paragraphs additionally expose their per-line heights so + // the balancer can SPLIT a paragraph that straddles the column boundary (Word + // flows content line-by-line when balancing a continuous section, ECMA-376 + // §17.18.77 — atomic assignment leaves the columns lumpy whenever one + // paragraph is large relative to the section). sectPr markers, `w:keepLines` + // paragraphs, non-paragraph fragments, and single-line paragraphs stay atomic. + const lineHeightsFor = (f: BalancingFragment): number[] | undefined => { + if (f.kind !== 'para') return undefined; + if (args.sectPrMarkerBlockIds?.has(f.blockId)) return undefined; + if (args.keepLinesBlockIds?.has(f.blockId)) return undefined; + // A remeasured fragment renders its own `lines` (resolveParagraph ignores + // fromLine/toLine then), so break points must be computed against that array. + if (f.lines && f.lines.length > 0) { + if (f.lines.length <= 1) return undefined; + return f.lines.map((l) => l.lineHeight); + } + const measure = args.measureMap.get(f.blockId); + if (!measure || measure.kind !== 'paragraph' || !Array.isArray(measure.lines)) return undefined; + const fromLine = f.fromLine ?? 0; + const toLine = f.toLine ?? measure.lines.length; + if (toLine - fromLine <= 1) return undefined; + return measure.lines.slice(fromLine, toLine).map((l) => l.lineHeight); + }; + + const contentBlocks: BalancingBlock[] = ordered.map((f, i) => { + const lineHeights = lineHeightsFor(f); + return { + blockId: `${f.blockId}#${i}`, + measuredHeight: getBalancingHeight(f, args.measureMap, args.sectPrMarkerBlockIds), + canBreak: lineHeights !== undefined, + keepWithNext: false, + keepTogether: lineHeights === undefined, + lineHeights, + }; + }); if ( shouldSkipBalancing({ @@ -831,6 +887,49 @@ export function balanceSectionOnPage(args: BalanceSectionOnPageArgs): { maxY: nu f.x = columnX(col); f.y = colCursors[col]; f.width = columnWidth; + // SD-3359: apply a line-boundary split chosen by the balancer. The first + // half keeps the leading lines in this column; a cloned second half carries + // the remaining lines to the top of the next column — the same + // fromLine/toLine + continuation-flag surgery pagination uses when a + // paragraph splits across pages. The simulation assigns the block to the + // column of its FIRST half and flows the remainder into the next column, + // so the cursors advance by the split heights it computed. + const bp = result.blockBreakPoints?.get(block.blockId); + if (bp && bp.heightAfterBreak > 0 && col < columnCount - 1) { + const fromLine = f.fromLine ?? 0; + const splitLine = fromLine + bp.breakAfterLine + 1; + const measureLineCount = args.measureMap.get(f.blockId)?.lines?.length ?? splitLine; + const originalToLine = f.toLine ?? measureLineCount; + const originalContinuesOnNext = (f as { continuesOnNext?: boolean }).continuesOnNext ?? false; + const secondHalf = { + ...f, + fromLine: splitLine, + toLine: originalToLine, + x: columnX(col + 1), + y: colCursors[col + 1], + width: columnWidth, + continuesFromPrev: true, + continuesOnNext: originalContinuesOnNext, + } as BalancingFragment; + // Remeasured fragments render their own `lines` wholesale (fromLine/toLine are + // ignored by the resolve stage then), so the halves must each carry ONLY their + // slice or both columns render the entire paragraph. + if (f.lines && f.lines.length > 0) { + secondHalf.lines = f.lines.slice(bp.breakAfterLine + 1); + f.lines = f.lines.slice(0, bp.breakAfterLine + 1); + } + f.toLine = splitLine; + (f as { continuesOnNext?: boolean }).continuesOnNext = true; + colCursors[col] += bp.heightBeforeBreak; + colCursors[col + 1] += bp.heightAfterBreak; + // Insert right after the first half so document order is preserved for + // any later consumer that walks the page fragments. + const fragIdx = fragments.indexOf(f); + if (fragIdx >= 0) fragments.splice(fragIdx + 1, 0, secondHalf); + if (colCursors[col] > maxY) maxY = colCursors[col]; + if (colCursors[col + 1] > maxY) maxY = colCursors[col + 1]; + continue; + } colCursors[col] += block.measuredHeight; if (colCursors[col] > maxY) maxY = colCursors[col]; } diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index bde926ca2f..3c5c8736b7 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -3038,7 +3038,7 @@ describe('layoutDocument', () => { const TWO_COL_RIGHT_X = LEFT_MARGIN + TWO_COL_WIDTH + COLUMN_GAP; // 330 /** Build a 2-col section ending with a section break, surrounded by single-column context. */ - function buildTwoColumnSection(paragraphCount: number, lineHeight = 20) { + function buildTwoColumnSection(paragraphCount: number, lineHeight = 20, endBreakType = 'continuous') { const blocks: FlowBlock[] = [ { kind: 'sectionBreak', @@ -3059,7 +3059,7 @@ describe('layoutDocument', () => { blocks.push({ kind: 'sectionBreak', id: 'sb-end', - type: 'continuous', + type: endBreakType, columns: { count: 1, gap: 0 }, margins: {}, attrs: { source: 'sectPr', sectionIndex: 1 }, @@ -3112,6 +3112,88 @@ describe('layoutDocument', () => { expect(afterSectionPara!.y).toBe(expectedBalancedBottom); }); + it('splits a paragraph straddling the column boundary so the section balances (SD-3359)', () => { + // IT-1150 / SD-3359 repro shape: two 1-line paragraphs plus one 14-line + // paragraph (280px) in a 2-col continuous section followed by continuous + // single-column content. Atomic assignment can only reach 40 | 280; + // Word flows line-by-line, splitting the long paragraph at the boundary + // so both columns reach the minimum section height (§17.18.77): + // col0 = 20 + 20 + 6 lines (160), col1 = 8 lines (160). + const blocks: FlowBlock[] = [ + { + kind: 'sectionBreak', + id: 'sb-start', + type: 'continuous', + columns: { count: 2, gap: COLUMN_GAP }, + margins: {}, + attrs: { source: 'sectPr', sectionIndex: 0, isFirstSection: true }, + } as FlowBlock, + { kind: 'paragraph', id: 'p0', runs: [], attrs: { sectionIndex: 0 } } as FlowBlock, + { kind: 'paragraph', id: 'p1', runs: [], attrs: { sectionIndex: 0 } } as FlowBlock, + { kind: 'paragraph', id: 'p-long', runs: [], attrs: { sectionIndex: 0 } } as FlowBlock, + { + kind: 'sectionBreak', + id: 'sb-end', + type: 'continuous', + columns: { count: 1, gap: 0 }, + margins: {}, + attrs: { source: 'sectPr', sectionIndex: 1 }, + } as FlowBlock, + { kind: 'paragraph', id: 'p-after', runs: [], attrs: { sectionIndex: 1 } } as FlowBlock, + ]; + const measures: Measure[] = [ + { kind: 'sectionBreak' }, + makeMeasure([20]), + makeMeasure([20]), + makeMeasure(Array(14).fill(20)), + { kind: 'sectionBreak' }, + makeMeasure([20]), + ]; + + const layout = layoutDocument(blocks, measures, PAGE); + + const longFrags = layout.pages[0].fragments + .filter((f): f is ParaFragment => f.kind === 'para' && f.blockId === 'p-long') + .sort((a, b) => a.fromLine - b.fromLine); + // The straddling paragraph split into two fragments partitioning its lines. + expect(longFrags).toHaveLength(2); + expect(longFrags[0].toLine).toBe(longFrags[1].fromLine); + expect(longFrags[1].toLine).toBe(14); + expect(longFrags[0].x).toBe(LEFT_MARGIN); + expect(longFrags[1].x).toBe(TWO_COL_RIGHT_X); + // Both columns reach the same minimum height (320 / 2 = 160). + const pAfter = layout.pages[0].fragments.find( + (f): f is ParaFragment => f.kind === 'para' && f.blockId === 'p-after', + ); + expect(pAfter).toBeDefined(); + expect(pAfter!.y).toBe(72 + 160); + }); + + it('does NOT balance a 2-col section whose END break is nextPage (§17.18.77, SD-3359)', () => { + // Per the §17.18.77 note only a CONTINUOUS break balances the previous + // section. A 2-col section that merely STARTS continuous but is ended by + // a nextPage break fills column-by-column instead (Word behavior), and + // the following section starts on a fresh page. Regression for the + // post-layout gate that previously keyed off the section's own begin + // type instead of the break that ends it. + const { blocks, measures } = buildTwoColumnSection(6, 20, 'nextPage'); + + const layout = layoutDocument(blocks, measures, PAGE); + + const sectionFragments = layout.pages[0].fragments.filter( + (f): f is ParaFragment => f.kind === 'para' && f.blockId.startsWith('p') && f.blockId !== 'p-after', + ); + // Unbalanced column-by-column fill: all 6 short paragraphs stay in col 0. + expect(sectionFragments.filter((f) => f.x === LEFT_MARGIN)).toHaveLength(6); + expect(sectionFragments.filter((f) => f.x === TWO_COL_RIGHT_X)).toHaveLength(0); + // The nextPage break pushes the following section to page 2. + expect(layout.pages.length).toBe(2); + const pAfter = layout.pages[1].fragments.find( + (f): f is ParaFragment => f.kind === 'para' && f.blockId === 'p-after', + ); + expect(pAfter).toBeDefined(); + }); + it('fills BOTH columns on every page of a multi-page 2-col continuous section', () => { // ECMA-376 §17.18.77 (ST_SectionMark): a continuous section break // "balances content of the previous section." Word's observable behavior diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 64fd88708b..6e9bb80d54 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1875,13 +1875,13 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const blockSectionMap = new Map(); const sectionColumnsMap = new Map(); const sectionHasExplicitColumnBreak = new Set(); - // sectionIndex -> type of the section break that ENDS this section (per - // pm-adapter end-tagged semantics, ECMA-376 §17.6.17: a paragraph's sectPr - // describes the section ENDING at that paragraph, so SectionBreakBlock.type - // here is the type of the break that closes the section). Per ECMA-376 - // §17.18.77 only `continuous` breaks trigger column balancing — `nextPage`, - // `evenPage`, `oddPage` do not. Tracked here so the post-layout pass can - // skip the wrong section types. + // sectionIndex -> the section's own sectPr `w:type` (ECMA-376 §17.6.22): + // how the section BEGINS relative to its predecessor — i.e. the type of the + // break that closes the PREVIOUS section. The break that ENDS section N is + // therefore `get(N + 1)`. Per ECMA-376 §17.18.77 only a `continuous` break + // balances the section BEFORE it — `nextPage`, `evenPage`, `oddPage` do + // not. (The earlier comment here claimed end-break semantics, which led the + // post-layout gate to key off the wrong section — SD-3359.) const sectionEndBreakType = new Map(); // sectionIndex -> whether `` was EXPLICIT in the source sectPr. // Body sectPrs default to `continuous` when w:type is omitted; Word does @@ -1906,6 +1906,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // the older `line.width === 0` heuristic, which incorrectly collapsed normal // blank paragraphs and caused overlap on the next paragraph. const sectPrMarkerBlockIds = new Set(); + // Block IDs of paragraphs with `w:keepLines` (ECMA-376 §17.3.1.14): the + // author asked Word not to split these, so column balancing must keep them + // atomic instead of breaking them at a line boundary. (SD-3359) + const keepLinesBlockIds = new Set(); // True if any block in the document is a column break. Used as a guard for // the document-wide balancing fallback (Nick comment 2): when callers use // LayoutOptions.columns without section metadata, we still want Word's @@ -1970,6 +1974,12 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options ) { sectPrMarkerBlockIds.add(block.id); } + if ( + block.kind === 'paragraph' && + (blockWithAttrs as { attrs?: { keepLines?: boolean } }).attrs?.keepLines === true + ) { + keepLinesBlockIds.add(block.id); + } }); // Collect anchored drawings mapped to their anchor paragraphs @@ -2310,6 +2320,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options availableHeight, measureMap: balancingMeasureMap, sectPrMarkerBlockIds, + keepLinesBlockIds, }); if (balanceResult) { // Collapse both cursors to the balanced section bottom so the new @@ -3046,7 +3057,19 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // (two_column_two_page-arial 2 p17 keeps its 3+2 split). if (isMultiPage && !isLast) continue; - const allowedByMidDocContinuous = endBreakType === 'continuous' && !isLast; + // The per-section type is the type of the break that BEGINS the section + // (its own sectPr `w:type`, §17.6.22) — i.e. the break that closes the + // PREVIOUS section. The break that ends section N is therefore section + // N+1's begin type. Keying rule 1 off the section's OWN type balanced a + // 2-col section that merely STARTED continuous even when it ended at a + // nextPage break — Word only balances when the break AFTER the section + // is continuous (§17.18.77 note, SD-3359 V6 repro). The next-is-body + // case is excluded here: a body sectPr defaults to `continuous` when + // `` is omitted and Word does NOT balance then (sd-1655) — + // rule 2 below owns that boundary and demands explicitness. + const nextSectionBeginType = sectionEndBreakType.get(sectionIdx + 1); + const nextIsBody = lastSectionIdx !== null && sectionIdx + 1 === lastSectionIdx; + const allowedByMidDocContinuous = !isLast && !nextIsBody && nextSectionBeginType === 'continuous'; // Body-explicit-continuous balances the section IT ENDS, which is the // section immediately preceding the body. No doc-wide flag. const allowedByBodyExplicitContinuous = @@ -3094,6 +3117,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options availableHeight: sectionAvailableHeight, measureMap: balancingMeasureMap, sectPrMarkerBlockIds, + keepLinesBlockIds, }); }