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
32 changes: 32 additions & 0 deletions packages/layout-engine/contracts/src/column-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions packages/layout-engine/contracts/src/column-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <w:col> widths. When the section declares more
// columns than it supplies widths (e.g. w:num="4" with two <w:col>), 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<w:cols w:num="N" w:equalWidth="0">`
// with explicit `<w:col w:w>` 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.
Expand Down
22 changes: 20 additions & 2 deletions packages/layout-engine/layout-engine/src/column-balancing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <w:col w:w> 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;
}

Expand Down
Loading
Loading