Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions packages/layout-engine/layout-engine/src/column-balancing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { kind: string; lines: Array<{ lineHeight: number }> }>;
blockSectionMap: Map<string, number>;
} {
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<string, { kind: string; lines: Array<{ lineHeight: number }> }>([
['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<string, number>([
['A', 1],
['B', 1],
['C', 1],
]);
return { fragments, measureMap, blockSectionMap };
}

const balance = (
fragments: SplitFragment[],
measureMap: Map<string, { kind: string; lines: Array<{ lineHeight: number }> }>,
blockSectionMap: Map<string, number>,
extra: Record<string, unknown> = {},
) =>
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<SplitFragment & { lines?: Array<{ lineHeight: number }> }>
).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);
});
});
});
117 changes: 108 additions & 9 deletions packages/layout-engine/layout-engine/src/column-balancing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -549,6 +565,11 @@ interface FragmentInfo {
*/
function getFragmentHeight(fragment: BalancingFragment, measureMap: Map<string, MeasureData>): 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;
Expand Down Expand Up @@ -665,6 +686,12 @@ export interface BalanceSectionOnPageArgs {
* Optional; when omitted no fragment is treated as a marker.
*/
sectPrMarkerBlockIds?: Set<string>;
/**
* 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<string>;
}

/**
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Comment on lines +904 to +907
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Slice remeasured paragraph lines when splitting

When this splits a paragraph fragment that was remeasured for a narrower column or floats, ...f copies the fragment’s full lines override onto the second half, and the first half keeps the same override after only toLine is changed. renderParagraphFragment prefers fragment.lines over measure.lines.slice(fromLine, toLine), so in real multi-column layouts that set fragment.lines both halves can render the entire pre-split paragraph instead of their respective line ranges, duplicating text across columns. The split needs to clear or partition lines alongside fromLine/toLine.

Useful? React with 👍 / 👎.

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];
}
Expand Down
Loading
Loading