From 49d64e3556535873d5fffca12a913d041946d906 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Wed, 11 Mar 2026 18:34:47 +0200 Subject: [PATCH 1/6] fix: improve multi-column rendering --- packages/layout-engine/contracts/src/index.ts | 3 + .../layout-bridge/src/incrementalLayout.ts | 108 ++++++++++++----- .../layout-bridge/src/remeasure.ts | 11 +- ...entalLayout.previous-measure-reuse.test.ts | 53 ++++++++ .../layout-bridge/test/remeasure.test.ts | 55 +++++++++ .../resolveMeasurementConstraints.test.ts | 41 +++++++ .../src/column-balancing.test.ts | 22 ++-- .../layout-engine/src/column-balancing.ts | 21 ++++ .../layout-engine/src/column-utils.ts | 21 ++++ .../layout-engine/src/index.test.ts | 113 +++++++++++++----- .../layout-engine/layout-engine/src/index.ts | 93 +++++++++++--- .../layout-engine/src/paginator.ts | 16 ++- .../layout-engine/src/section-breaks.ts | 25 ++-- .../measuring/dom/src/index.test.ts | 75 +++++++++++- .../layout-engine/measuring/dom/src/index.ts | 4 +- .../pm-adapter/src/index.test.ts | 43 +++++++ .../pm-adapter/src/sections/breaks.ts | 19 ++- .../src/sections/extraction.test.ts | 33 +++++ .../pm-adapter/src/sections/extraction.ts | 31 ++++- .../pm-adapter/src/sections/types.ts | 4 +- .../presentation-editor/PresentationEditor.ts | 5 +- 21 files changed, 675 insertions(+), 121 deletions(-) create mode 100644 packages/layout-engine/layout-engine/src/column-utils.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index d8a5b9052..1475ed103 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -932,6 +932,7 @@ export type SectionBreakBlock = { columns?: { count: number; gap: number; + widths?: number[]; equalWidth?: boolean; }; /** @@ -1419,6 +1420,8 @@ export type FlowBlock = export type ColumnLayout = { count: number; gap: number; + widths?: number[]; + equalWidth?: boolean; }; /** A measured line within a block, output by the measurer. */ diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index ab5953ab5..022434353 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -128,12 +128,50 @@ const COLUMN_EPSILON = 0.01; type NormalizedColumns = ColumnLayout & { width: number }; type PageColumns = NormalizedColumns & { left: number; contentWidth: number }; +const cloneColumnLayout = (columns?: ColumnLayout): ColumnLayout => + columns + ? { + count: columns.count, + gap: columns.gap, + ...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}), + ...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}), + } + : { count: 1, gap: 0 }; + +const resolveMaxColumnWidth = (contentWidth: number, columns?: ColumnLayout): number => { + if (!columns || columns.count <= 1) return contentWidth; + const normalized = normalizeColumnsForFootnotes(columns, contentWidth); + return normalized.width; +}; + const normalizeColumnsForFootnotes = (input: ColumnLayout | undefined, contentWidth: number): NormalizedColumns => { const rawCount = Number.isFinite(input?.count) ? Math.floor(input!.count) : 1; const count = Math.max(1, rawCount || 1); const gap = Math.max(0, input?.gap ?? 0); const totalGap = gap * (count - 1); - const width = (contentWidth - totalGap) / count; + const availableWidth = contentWidth - totalGap; + const explicitWidths = + Array.isArray(input?.widths) && input.widths.length > 0 + ? input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0) + : []; + let widths = + explicitWidths.length > 0 + ? explicitWidths.slice(0, count) + : Array.from({ length: count }, () => (availableWidth > 0 ? availableWidth / count : contentWidth)); + + if (widths.length < count) { + const remaining = Math.max(0, availableWidth - widths.reduce((sum, width) => sum + width, 0)); + const fallbackWidth = count - widths.length > 0 ? remaining / (count - widths.length) : 0; + widths.push(...Array.from({ length: count - widths.length }, () => fallbackWidth)); + } + + const totalExplicitWidth = widths.reduce((sum, width) => sum + width, 0); + if (availableWidth > 0 && totalExplicitWidth > 0) { + const scale = availableWidth / totalExplicitWidth; + widths = widths.map((width) => Math.max(1, width * scale)); + } + + const width = widths.reduce((max, value) => Math.max(max, value), 0); if (!Number.isFinite(width) || width <= COLUMN_EPSILON) { return { @@ -143,12 +181,20 @@ const normalizeColumnsForFootnotes = (input: ColumnLayout | undefined, contentWi }; } - return { count, gap, width }; + return { + count, + gap, + ...(widths.length > 0 ? { widths } : {}), + ...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}), + width, + }; }; +const ooXmlSectionColumns = (columns?: ColumnLayout): ColumnLayout => cloneColumnLayout(columns); + const resolveSectionColumnsByIndex = (options: LayoutOptions, blocks?: FlowBlock[]): Map => { const result = new Map(); - let activeColumns: ColumnLayout = options.columns ?? { count: 1, gap: 0 }; + let activeColumns: ColumnLayout = cloneColumnLayout(options.columns); if (blocks && blocks.length > 0) { for (const block of blocks) { @@ -156,15 +202,13 @@ const resolveSectionColumnsByIndex = (options: LayoutOptions, blocks?: FlowBlock const sectionIndexRaw = (block.attrs as { sectionIndex?: number } | undefined)?.sectionIndex; const sectionIndex = typeof sectionIndexRaw === 'number' && Number.isFinite(sectionIndexRaw) ? sectionIndexRaw : result.size; - if (block.columns) { - activeColumns = { count: block.columns.count, gap: block.columns.gap }; - } - result.set(sectionIndex, { ...activeColumns }); + activeColumns = ooXmlSectionColumns(block.columns); + result.set(sectionIndex, cloneColumnLayout(activeColumns)); } } if (result.size === 0) { - result.set(0, { ...activeColumns }); + result.set(0, cloneColumnLayout(activeColumns)); } return result; @@ -228,9 +272,23 @@ const assignFootnotesToColumns = ( if (columns && columns.count > 1 && page) { const fragment = findFragmentForPos(page, ref.pos); if (fragment && typeof fragment.x === 'number') { - const columnStride = columns.width + columns.gap; - const rawIndex = columnStride > 0 ? Math.floor((fragment.x - columns.left) / columnStride) : 0; - columnIndex = Math.max(0, Math.min(columns.count - 1, rawIndex)); + const widths = Array.isArray(columns.widths) && columns.widths.length > 0 ? columns.widths : undefined; + if (widths) { + let cursorX = columns.left; + for (let index = 0; index < columns.count; index += 1) { + const columnWidth = widths[index] ?? columns.width; + if (fragment.x < cursorX + columnWidth + columns.gap / 2) { + columnIndex = index; + break; + } + cursorX += columnWidth + columns.gap; + columnIndex = Math.min(columns.count - 1, index + 1); + } + } else { + const columnStride = columns.width + columns.gap; + const rawIndex = columnStride > 0 ? Math.floor((fragment.x - columns.left) / columnStride) : 0; + columnIndex = Math.max(0, Math.min(columns.count - 1, rawIndex)); + } } } @@ -260,7 +318,7 @@ const resolveFootnoteMeasurementWidth = (options: LayoutOptions, blocks?: FlowBl left: normalizeMargin(options.margins?.left, DEFAULT_MARGINS.left), }; let width = pageSize.w - (margins.left + margins.right); - let activeColumns: ColumnLayout = options.columns ?? { count: 1, gap: 0 }; + let activeColumns: ColumnLayout = cloneColumnLayout(options.columns); let activePageSize = pageSize; let activeMargins = { ...margins }; @@ -280,9 +338,7 @@ const resolveFootnoteMeasurementWidth = (options: LayoutOptions, blocks?: FlowBl right: normalizeMargin(block.margins?.right, activeMargins.right), left: normalizeMargin(block.margins?.left, activeMargins.left), }; - if (block.columns) { - activeColumns = { count: block.columns.count, gap: block.columns.gap }; - } + activeColumns = ooXmlSectionColumns(block.columns); const w = resolveColumnWidth(); if (w > 0 && w < width) width = w; } @@ -2001,17 +2057,10 @@ function computePerSectionConstraints( bottom: normalizeMargin(options.margins?.bottom, DEFAULT_MARGINS.bottom), left: normalizeMargin(options.margins?.left, DEFAULT_MARGINS.left), }; - const computeColumnWidth = (contentWidth: number, columns?: { count: number; gap?: number }): number => { - if (!columns || columns.count <= 1) return contentWidth; - const gap = Math.max(0, columns.gap ?? 0); - const totalGap = gap * (columns.count - 1); - return (contentWidth - totalGap) / columns.count; - }; - const defaultContentWidth = pageSize.w - (defaultMargins.left + defaultMargins.right); const defaultContentHeight = pageSize.h - (defaultMargins.top + defaultMargins.bottom); const defaultConstraints = { - maxWidth: computeColumnWidth(defaultContentWidth, options.columns), + maxWidth: resolveMaxColumnWidth(defaultContentWidth, options.columns), maxHeight: defaultContentHeight, }; @@ -2032,7 +2081,7 @@ function computePerSectionConstraints( const contentHeight = sectionPageSize.h - (sectionMargins.top + sectionMargins.bottom); if (contentWidth > 0 && contentHeight > 0) { current = { - maxWidth: computeColumnWidth(contentWidth, sb.columns ?? options.columns), + maxWidth: resolveMaxColumnWidth(contentWidth, ooXmlSectionColumns(sb.columns)), maxHeight: contentHeight, }; } @@ -2132,14 +2181,7 @@ export function resolveMeasurementConstraints( const baseContentWidth = pageSize.w - (margins.left + margins.right); const baseContentHeight = pageSize.h - (margins.top + margins.bottom); - const computeColumnWidth = (contentWidth: number, columns?: { count: number; gap?: number }): number => { - if (!columns || columns.count <= 1) return contentWidth; - const gap = Math.max(0, columns.gap ?? 0); - const totalGap = gap * (columns.count - 1); - return (contentWidth - totalGap) / columns.count; - }; - - let measurementWidth = computeColumnWidth(baseContentWidth, options.columns); + let measurementWidth = resolveMaxColumnWidth(baseContentWidth, options.columns); let measurementHeight = baseContentHeight; if (blocks && blocks.length > 0) { @@ -2155,7 +2197,7 @@ export function resolveMeasurementConstraints( const contentWidth = sectionPageSize.w - (sectionMargins.left + sectionMargins.right); const contentHeight = sectionPageSize.h - (sectionMargins.top + sectionMargins.bottom); if (contentWidth <= 0 || contentHeight <= 0) continue; - const columnWidth = computeColumnWidth(contentWidth, block.columns ?? options.columns); + const columnWidth = resolveMaxColumnWidth(contentWidth, ooXmlSectionColumns(block.columns)); if (columnWidth > measurementWidth) { measurementWidth = columnWidth; } diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index 5b178c2e3..de649c133 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -291,8 +291,8 @@ const TAB_EPSILON = 0.1; * - Conservative value that prevents premature line breaks without allowing significant overflow * * Usage: - * - When checking if word fits: `width + wordWidth > effectiveMaxWidth - WIDTH_FUDGE_PX` - * - Gives layout a 0.5px safety margin before triggering a line break + * - When checking if another glyph still fits: `width + glyphWidth > effectiveMaxWidth - WIDTH_FUDGE_PX` + * - Gives layout a 0.5px safety margin before triggering a normal line break * - Prevents edge cases where measured text at 199.7px breaks on a 200px line */ const WIDTH_FUDGE_PX = 0.5; @@ -1242,6 +1242,13 @@ export function remeasureParagraph( } const w = measureRunSliceWidth(run, c, c + 1); if (width + w > effectiveMaxWidth - WIDTH_FUDGE_PX && width > 0) { + const canKeepBorderlineUnbreakableText = lastBreakRun < 0 && width + w <= effectiveMaxWidth + WIDTH_FUDGE_PX; + if (canKeepBorderlineUnbreakableText) { + width += w; + endRun = r; + endChar = c + 1; + continue; + } // Break line if (lastBreakRun >= 0) { endRun = lastBreakRun; diff --git a/packages/layout-engine/layout-bridge/test/incrementalLayout.previous-measure-reuse.test.ts b/packages/layout-engine/layout-bridge/test/incrementalLayout.previous-measure-reuse.test.ts index 4256656d9..319a08f7d 100644 --- a/packages/layout-engine/layout-bridge/test/incrementalLayout.previous-measure-reuse.test.ts +++ b/packages/layout-engine/layout-bridge/test/incrementalLayout.previous-measure-reuse.test.ts @@ -73,4 +73,57 @@ describe('incrementalLayout previous-measure reuse', () => { expect(secondPassBodyMeasure.lines?.[0]?.width).toBe(80); expect(measureBlock).toHaveBeenCalledTimes(1); }); + + it('measures pre-section content using single-column width when a following section break omits columns', async () => { + const options = { + pageSize: { w: 300, h: 400 }, + margins: { top: 20, right: 20, bottom: 20, left: 20 }, + columns: { count: 2, gap: 20 }, + }; + + const intro = makeParagraph('intro', 'Intro paragraph'); + const firstSection: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'section-0', + attrs: { isFirstSection: true, sectionIndex: 0, source: 'sectPr' }, + margins: { top: 20, right: 20, bottom: 20, left: 20 }, + }; + const nextSection: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'section-1', + attrs: { sectionIndex: 1, source: 'sectPr' }, + margins: { top: 20, right: 20, bottom: 20, left: 20 }, + columns: { count: 2, gap: 20 }, + }; + const body = makeParagraph('body', 'Body paragraph'); + + const blocks: FlowBlock[] = [firstSection, intro, nextSection, body]; + + const measureBlock = vi.fn(async (_block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => { + return { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: constraints.maxWidth, + ascent: 8, + descent: 2, + lineHeight: 10, + }, + ], + totalHeight: 10, + } satisfies ParagraphMeasure; + }); + + const result = await incrementalLayout([], null, blocks, options, measureBlock); + + const introMeasure = result.measures[1] as ParagraphMeasure; + const bodyMeasure = result.measures[3] as ParagraphMeasure; + + expect(introMeasure.lines?.[0]?.width).toBe(260); + expect(bodyMeasure.lines?.[0]?.width).toBe(120); + }); }); diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index 9bd92aa8b..0109926d7 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -815,6 +815,61 @@ describe('remeasureParagraph', () => { expect(measure.lines.length).toBeGreaterThanOrEqual(1); expect(measure.totalHeight).toBeGreaterThan(0); }); + + it('does not split a borderline narrow list word during remeasure', () => { + const ctx = document.createElement('canvas').getContext('2d'); + expect(ctx).not.toBeNull(); + const originalMeasureText = ctx!.measureText.bind(ctx); + const widthMap = new Map([ + ['Terms', 48.9], + ['Term', 39.125], + ['Ter', 30], + ['Te', 20], + ['T', 10], + ['e', 8.5], + ['r', 5.8], + ['m', 14.825], + ['s', 8.8984375], + ['1.', 13.34375], + ]); + + ctx!.measureText = ((text: string) => { + const mappedWidth = widthMap.get(text); + if (mappedWidth != null) { + return { width: mappedWidth } as TextMetrics; + } + return originalMeasureText(text); + }) as typeof ctx.measureText; + + const block = createBlock([textRun('Terms', { bold: true, fontFamily: 'Arial, sans-serif', fontSize: 16 })], { + indent: { left: 24, hanging: 23.933333333333334 }, + wordLayout: { + indentLeftPx: 24, + hangingPx: 23.933333333333334, + textStartPx: 24, + marker: { + markerText: '1.', + markerBoxWidthPx: 23.933333333333334, + textStartX: 24, + gutterWidthPx: 8, + suffix: 'tab', + run: { + fontFamily: 'Arial, sans-serif', + fontSize: 16, + bold: true, + }, + }, + }, + } as ParagraphBlock['attrs']); + + try { + const measure = remeasureParagraph(block, 72.26666666666667); + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0]?.toChar).toBe(5); + } finally { + ctx!.measureText = originalMeasureText as typeof ctx.measureText; + } + }); }); describe('Complex Scenarios', () => { diff --git a/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts b/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts index 1f112e72d..4939318f1 100644 --- a/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts @@ -124,6 +124,27 @@ describe('resolveMeasurementConstraints', () => { expect(result.measurementHeight).toBe(648); }); + it('treats a section break without columns as a reset to single-column layout', () => { + const options: LayoutOptions = { + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + columns: { count: 2, gap: 48 }, + }; + + const blocks: FlowBlock[] = [ + { + kind: 'sectionBreak', + id: 'section-1', + // No columns on the section break means OOXML default: single column. + } as SectionBreakBlock, + ]; + + const result = resolveMeasurementConstraints(options, blocks); + + expect(result.measurementWidth).toBe(468); + expect(result.measurementHeight).toBe(648); + }); + it('handles multiple section breaks and returns max dimensions', () => { const options: LayoutOptions = { pageSize: { w: 612, h: 792 }, @@ -155,6 +176,26 @@ describe('resolveMeasurementConstraints', () => { expect(result.measurementHeight).toBe(648); }); + it('keeps the widest section width for measurement constraints when a custom column section is narrower', () => { + const options: LayoutOptions = { + pageSize: { w: 800, h: 792 }, + margins: { top: 72, right: 50, bottom: 72, left: 50 }, + }; + + const blocks: FlowBlock[] = [ + { + kind: 'sectionBreak', + id: 'section-custom', + columns: { count: 2, gap: 50, widths: [100, 550], equalWidth: false }, + } as SectionBreakBlock, + ]; + + const result = resolveMeasurementConstraints(options, blocks); + + expect(result.measurementWidth).toBe(700); + expect(result.measurementHeight).toBe(648); + }); + it('skips sections with invalid dimensions (negative content width)', () => { const options: LayoutOptions = { pageSize: { w: 612, h: 792 }, 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 d8809e3b3..c6df4b38a 100644 --- a/packages/layout-engine/layout-engine/src/column-balancing.test.ts +++ b/packages/layout-engine/layout-engine/src/column-balancing.test.ts @@ -290,9 +290,9 @@ describe('shouldSkipBalancing', () => { }); it('should NOT skip when content can be meaningfully distributed', () => { - // 100px total / 2 columns = 50px per column, above minColumnHeight (20px) + // 100px total exceeds the available single-column height (80px), so balancing is needed. const ctx = createContext(2, [createBlock('block-1', 50), createBlock('block-2', 50)], { - availableHeight: 1000, + availableHeight: 80, }); expect(shouldSkipBalancing(ctx, DEFAULT_BALANCING_CONFIG)).toBe(false); @@ -357,7 +357,7 @@ describe('balancePageColumns', () => { ['block-4', createMeasure('paragraph', [20])], ]); - balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, measureMap); + balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, 40, measureMap); // Block 1 stays in column 0 expect(fragments[0].x).toBe(96); @@ -374,7 +374,7 @@ describe('balancePageColumns', () => { ['block-2', createMeasure('paragraph', [20])], ]); - balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, measureMap); + balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, 30, measureMap); // Both fragments should have width set to column width expect(fragments[0].width).toBe(288); @@ -403,7 +403,7 @@ describe('balancePageColumns', () => { ['block-6', createMeasure('paragraph', [20])], ]); - balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, measureMap); + balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, 50, measureMap); // Column 0: blocks 1, 2 - Y positions stack from top expect(fragments[0].y).toBe(96); @@ -437,7 +437,7 @@ describe('balancePageColumns', () => { ['block-6', createMeasure('paragraph', [20])], ]); - balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, measureMap); + balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, 50, measureMap); // With >= condition: target = 120px / 2 = 60px per column // Block 1 (20px): column 0, height=20 @@ -480,7 +480,7 @@ describe('balancePageColumns', () => { ['block-6', createMeasure('paragraph', [21])], ]); - balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, measureMap); + balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, 70, measureMap); // Blocks 1, 2 should be in column 0 expect(fragments[0].x).toBe(96); @@ -505,7 +505,7 @@ describe('balancePageColumns', () => { const origX1 = fragments[0].x; const origX2 = fragments[1].x; - balancePageColumns(fragments, { count: 1, gap: 0, width: 624 }, { left: 96 }, 96, measureMap); + balancePageColumns(fragments, { count: 1, gap: 0, width: 624 }, { left: 96 }, 96, 1000, measureMap); // Should not modify positions for single column expect(fragments[0].x).toBe(origX1); @@ -518,7 +518,7 @@ describe('balancePageColumns', () => { // Should not throw expect(() => - balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, measureMap), + balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, 1000, measureMap), ).not.toThrow(); }); @@ -529,7 +529,7 @@ describe('balancePageColumns', () => { // Should not throw - block-2 will have height 0 expect(() => - balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, measureMap), + balancePageColumns(fragments, { count: 2, gap: 48, width: 288 }, { left: 96 }, 96, 10, measureMap), ).not.toThrow(); }); @@ -552,7 +552,7 @@ describe('balancePageColumns', () => { ['block-6', createMeasure('paragraph', [20])], ]); - balancePageColumns(fragments, { count: 3, gap: 24, width: 192 }, { left: 96 }, 96, measureMap); + balancePageColumns(fragments, { count: 3, gap: 24, width: 192 }, { left: 96 }, 96, 30, measureMap); // Verify 3 columns are used const colXValues = new Set(fragments.map((f) => f.x)); diff --git a/packages/layout-engine/layout-engine/src/column-balancing.ts b/packages/layout-engine/layout-engine/src/column-balancing.ts index 942b3506b..cb14ae8a2 100644 --- a/packages/layout-engine/layout-engine/src/column-balancing.ts +++ b/packages/layout-engine/layout-engine/src/column-balancing.ts @@ -683,6 +683,7 @@ export function balancePageColumns( columns: { count: number; gap: number; width: number }, margins: { left: number }, topMargin: number, + availableHeight: number, measureMap: Map, ): void { // Skip balancing for single-column layouts or empty pages @@ -720,9 +721,29 @@ export function balancePageColumns( // Calculate total content height by summing max height of each row let totalHeight = 0; + const contentBlocks: BalancingBlock[] = []; for (const [, rowFragments] of sortedRows) { const maxHeight = Math.max(...rowFragments.map((f) => f.height)); totalHeight += maxHeight; + contentBlocks.push({ + blockId: rowFragments[0]?.fragment.blockId ?? `row-${contentBlocks.length}`, + measuredHeight: maxHeight, + canBreak: false, + keepWithNext: false, + keepTogether: true, + }); + } + + if ( + shouldSkipBalancing({ + columnCount: columns.count, + columnWidth: columns.width, + columnGap: columns.gap, + availableHeight, + contentBlocks, + }) + ) { + return; } // Calculate target height per column for balanced distribution diff --git a/packages/layout-engine/layout-engine/src/column-utils.ts b/packages/layout-engine/layout-engine/src/column-utils.ts new file mode 100644 index 000000000..5c0b4cf82 --- /dev/null +++ b/packages/layout-engine/layout-engine/src/column-utils.ts @@ -0,0 +1,21 @@ +import type { ColumnLayout } from '@superdoc/contracts'; + +export const widthsEqual = (a?: number[], b?: number[]): boolean => { + if (!a && !b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +}; + +export const cloneColumnLayout = (columns: ColumnLayout | undefined): ColumnLayout => + columns + ? { + count: columns.count, + gap: columns.gap, + ...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}), + ...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}), + } + : { count: 1, gap: 0 }; diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 4809423f8..235a726ae 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -2500,38 +2500,6 @@ describe('layoutDocument', () => { // Balancing should NOT have made them the same width expect(singleColFragment?.width).not.toBeCloseTo(twoColFragment!.width, 0); }); - - it('applies balancing when all fragments have same column configuration', () => { - // When all fragments have the same width (same column config), - // balancing should redistribute them across columns. - const options: LayoutOptions = { - pageSize: { w: 612, h: 792 }, - margins: { top: 72, right: 72, bottom: 72, left: 72 }, - columns: { count: 2, gap: 48 }, - }; - - const blocks: FlowBlock[] = [ - { kind: 'paragraph', id: 'p1', runs: [] }, - { kind: 'paragraph', id: 'p2', runs: [] }, - { kind: 'paragraph', id: 'p3', runs: [] }, - { kind: 'paragraph', id: 'p4', runs: [] }, - ]; - - const measures: Measure[] = [makeMeasure([40]), makeMeasure([40]), makeMeasure([40]), makeMeasure([40])]; - - const layout = layoutDocument(blocks, measures, options); - const page = layout.pages[0]; - - // All fragments should have the same column width - const columnWidth = (468 - 48) / 2; // 210 - for (const f of page.fragments) { - expect(f.width).toBeCloseTo(columnWidth, 0); - } - - // Fragments should be distributed across columns (different X positions) - const uniqueXPositions = new Set(page.fragments.map((f) => Math.round(f.x))); - expect(uniqueXPositions.size).toBe(2); - }); }); }); @@ -3230,6 +3198,87 @@ describe('requirePageBoundary edge cases', () => { expect(p3.x).toBeCloseTo(options.margins!.left + columnWidth + 48); // second column expect(p3.y).toBeCloseTo(regionTop); // reset to region top }); + + it('uses explicit custom column widths after a manual column break', () => { + const toCustomColumns: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-custom', + type: 'continuous', + columns: { count: 2, gap: 50, widths: [100, 550], equalWidth: false }, + margins: {}, + }; + + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [] }, + toCustomColumns, + { kind: 'paragraph', id: 'p2', runs: [] }, + { kind: 'columnBreak', id: 'br-1' } as ColumnBreakBlock, + { kind: 'paragraph', id: 'p3', runs: [] }, + ]; + + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'sectionBreak' }, + makeMeasure([40]), + { kind: 'columnBreak' }, + makeMeasure([40]), + ]; + + const options: LayoutOptions = { + pageSize: { w: 800, h: 792 }, + margins: { top: 72, right: 50, bottom: 72, left: 50 }, + }; + + const layout = layoutDocument(blocks, measures, options); + const page = layout.pages[0]; + const p1 = page.fragments.find((f) => f.blockId === 'p1') as ParaFragment; + const regionTop = p1.y + 40; + + const p2 = page.fragments.find((f) => f.blockId === 'p2') as ParaFragment; + expect(p2.x).toBeCloseTo(50); + expect(p2.y).toBeCloseTo(regionTop); + expect(p2.width).toBeCloseTo(100); + + const p3 = page.fragments.find((f) => f.blockId === 'p3') as ParaFragment; + expect(p3.x).toBeCloseTo(200); + expect(p3.y).toBeCloseTo(regionTop); + expect(p3.width).toBeCloseTo(550); + }); + + it('does not balance the final page for explicit custom-width columns', () => { + const blocks: FlowBlock[] = [ + { + kind: 'sectionBreak', + id: 'sb-custom-final-page', + type: 'nextPage', + columns: { count: 2, gap: 48, widths: [210, 214], equalWidth: false }, + margins: {}, + } as SectionBreakBlock, + { kind: 'paragraph', id: 'p1', runs: [] }, + { kind: 'paragraph', id: 'p2', runs: [] }, + { kind: 'paragraph', id: 'p3', runs: [] }, + ]; + + const measures: Measure[] = [{ kind: 'sectionBreak' }, makeMeasure([40]), makeMeasure([40]), makeMeasure([40])]; + + const options: LayoutOptions = { + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + }; + + const layout = layoutDocument(blocks, measures, options); + const page = layout.pages[0]; + + const p1 = page.fragments.find((f) => f.blockId === 'p1') as ParaFragment; + const p2 = page.fragments.find((f) => f.blockId === 'p2') as ParaFragment; + const p3 = page.fragments.find((f) => f.blockId === 'p3') as ParaFragment; + + expect(p1.x).toBeCloseTo(72); + expect(p2.x).toBeCloseTo(72); + expect(p3.x).toBeCloseTo(72); + expect(p2.y).toBeGreaterThan(p1.y); + expect(p3.y).toBeGreaterThan(p2.y); + }); }); describe('drawing blocks', () => { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index a87451ef2..e314a0564 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -44,6 +44,7 @@ import { formatPageNumber } from './pageNumbering.js'; import { shouldSuppressSpacingForEmpty } from './layout-utils.js'; import { balancePageColumns } from './column-balancing.js'; import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js'; +import { cloneColumnLayout, widthsEqual } from './column-utils.js'; type PageSize = { w: number; h: number }; type Margins = { @@ -57,6 +58,13 @@ type Margins = { type NormalizedColumns = ColumnLayout & { width: number }; +const getColumnWidthAt = (columns: NormalizedColumns, columnIndex: number): number => { + if (Array.isArray(columns.widths) && columns.widths.length > 0) { + return columns.widths[Math.max(0, Math.min(columnIndex, columns.widths.length - 1))] ?? columns.width; + } + return columns.width; +}; + /** * Default paragraph line height in pixels used for vertical alignment calculations * when actual height is not available in the measure data. @@ -764,8 +772,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options let pendingPageSize: { w: number; h: number } | null = null; // Track active and pending columns - let activeColumns = options.columns ?? { count: 1, gap: 0 }; - let pendingColumns: { count: number; gap: number } | null = null; + let activeColumns = cloneColumnLayout(options.columns); + let pendingColumns: ColumnLayout | null = null; // Track active and pending orientation let activeOrientation: 'portrait' | 'landscape' | null = null; @@ -862,11 +870,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Update columns - if section has columns, use them; if undefined, reset to single column. // In OOXML, absence of means single column (default). if (block.columns) { - next.activeColumns = { count: block.columns.count, gap: block.columns.gap }; + next.activeColumns = cloneColumnLayout(block.columns); next.pendingColumns = null; } else { // No columns specified = reset to single column (OOXML default) - next.activeColumns = { count: 1, gap: 0 }; + next.activeColumns = cloneColumnLayout(undefined); next.pendingColumns = null; } // Schedule section refs for first section (will be applied on first page creation) @@ -944,7 +952,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // or implicitly resetting to single column (undefined = single column in OOXML) const isColumnsChanging = (block.columns && - (block.columns.count !== next.activeColumns.count || block.columns.gap !== next.activeColumns.gap)) || + (block.columns.count !== next.activeColumns.count || + block.columns.gap !== next.activeColumns.gap || + block.columns.equalWidth !== next.activeColumns.equalWidth || + !widthsEqual(block.columns.widths, next.activeColumns.widths))) || (!block.columns && next.activeColumns.count > 1); // Schedule section index change for next page (enables section-aware page numbering) const sectionIndexRaw = block.attrs?.sectionIndex; @@ -971,8 +982,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options layoutLog(`[Layout] Compat fallback: Scheduled pendingSectionRefs:`, pendingSectionRefs); } // Helper to get column config: use block.columns if defined, otherwise reset to single column (OOXML default) - const getColumnConfig = () => - block.columns ? { count: block.columns.count, gap: block.columns.gap } : { count: 1, gap: 0 }; + const getColumnConfig = () => cloneColumnLayout(block.columns); if (block.attrs?.requirePageBoundary) { next.pendingColumns = getColumnConfig(); @@ -1309,7 +1319,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options state: PageState | null; constraintIndex: number; contentWidth: number; - colsConfig: { count: number; gap: number } | null; + colsConfig: ColumnLayout | null; normalized: NormalizedColumns | null; } = { state: null, constraintIndex: -2, contentWidth: -1, colsConfig: null, normalized: null }; @@ -1325,6 +1335,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options cachedColumnsState.contentWidth === currentContentWidth && cachedColumnsState.colsConfig?.count === colsConfig.count && cachedColumnsState.colsConfig?.gap === colsConfig.gap && + cachedColumnsState.colsConfig?.equalWidth === colsConfig.equalWidth && + widthsEqual(cachedColumnsState.colsConfig?.widths, colsConfig.widths) && cachedColumnsState.normalized ) { return cachedColumnsState.normalized; @@ -1335,19 +1347,26 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options state, constraintIndex, contentWidth: currentContentWidth, - colsConfig: { count: colsConfig.count, gap: colsConfig.gap }, + colsConfig: cloneColumnLayout(colsConfig), normalized, }; return normalized; }; + const getCurrentColumnWidth = (): number => { + const cols = getCurrentColumns(); + const state = states[states.length - 1] ?? null; + const columnIndex = state?.columnIndex ?? 0; + return getColumnWidthAt(cols, columnIndex); + }; + // Helper to get column X position const columnX = paginator.columnX; const advanceColumn = paginator.advanceColumn; // Start a new mid-page region with different column configuration - const startMidPageRegion = (state: PageState, newColumns: { count: number; gap: number }): void => { + const startMidPageRegion = (state: PageState, newColumns: ColumnLayout): void => { // Record the boundary at current Y position const boundary: ConstraintBoundary = { y: state.cursorY, @@ -1365,7 +1384,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options layoutLog(` Current page: ${state.page.number}, cursorY: ${state.cursorY}`); // Update activeColumns so subsequent pages use this column configuration - activeColumns = newColumns; + activeColumns = cloneColumnLayout(newColumns); // Invalidate columns cache to ensure recalculation with new region cachedColumnsState.state = null; @@ -1904,7 +1923,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options { block, measure, - columnWidth: getCurrentColumns().width, + columnWidth: getCurrentColumnWidth(), ensurePage: paginator.ensurePage, advanceColumn: paginator.advanceColumn, columnX, @@ -1934,7 +1953,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Only vRelativeFrom=paragraph is supported. if (tablesForPara) { const state = paginator.ensurePage(); - const columnWidthForTable = getCurrentColumns().width; + const columnWidthForTable = getCurrentColumnWidth(); let tableBottomY = state.cursorY; for (const { block: tableBlock, measure: tableMeasure } of tablesForPara) { if (placedAnchoredTableIds.has(tableBlock.id)) continue; @@ -1988,7 +2007,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } else if (relativeFrom === 'margin') { maxWidth = activePageSize.w - (activeLeftMargin + activeRightMargin); } else { - maxWidth = cols.width; + maxWidth = getColumnWidthAt(cols, state.columnIndex); } const aspectRatio = imgMeasure.width > 0 && imgMeasure.height > 0 ? imgMeasure.width / imgMeasure.height : 1.0; @@ -2092,7 +2111,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options layoutTableBlock({ block: block as TableBlock, measure: measure as TableMeasure, - columnWidth: getCurrentColumns().width, + columnWidth: getCurrentColumnWidth(), ensurePage: paginator.ensurePage, advanceColumn: paginator.advanceColumn, columnX, @@ -2239,6 +2258,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const measureMap = new Map; height?: number }>(); // Build blockId -> sectionIndex map to filter fragments by section const blockSectionMap = new Map(); + const sectionColumnsMap = new Map(); blocks.forEach((block, idx) => { const measure = measures[idx]; if (measure) { @@ -2250,6 +2270,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const sectionIdx = blockWithAttrs.attrs?.sectionIndex; if (typeof sectionIdx === 'number') { blockSectionMap.set(block.id, sectionIdx); + if (block.kind === 'sectionBreak' && block.columns) { + sectionColumnsMap.set(sectionIdx, cloneColumnLayout(block.columns)); + } } }); @@ -2257,6 +2280,18 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Balance the last page (section ends at document end). // TODO: Track section boundaries and balance at each continuous section break. if (page === pages[pages.length - 1] && page.fragments.length > 0) { + const finalSectionColumns = sectionColumnsMap.get(activeSectionIndex) ?? activeColumns; + // Word does not rebalance the final page for sections that use explicit + // per-column widths. Preserve the natural left-to-right fill order there. + const hasExplicitColumnWidths = + finalSectionColumns?.equalWidth === false && + Array.isArray(finalSectionColumns.widths) && + finalSectionColumns.widths.length > 0; + + if (hasExplicitColumnWidths) { + continue; + } + // Skip balancing if fragments are already in multiple columns (e.g., explicit column breaks). // Balancing should only apply when all content flows naturally in column 0. const uniqueXPositions = new Set(page.fragments.map((f) => Math.round(f.x))); @@ -2295,6 +2330,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options : page.fragments; if (fragmentsToBalance.length > 0) { + const availableHeight = pageSize.h - activeBottomMargin - activeTopMargin; balancePageColumns( fragmentsToBalance as { x: number; @@ -2309,6 +2345,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options normalizedCols, { left: activeLeftMargin }, activeTopMargin, + availableHeight, measureMap, ); } @@ -2532,7 +2569,29 @@ function normalizeColumns(input: ColumnLayout | undefined, contentWidth: number) const count = Math.max(1, rawCount || 1); const gap = Math.max(0, input?.gap ?? 0); const totalGap = gap * (count - 1); - const width = (contentWidth - totalGap) / count; + const availableWidth = contentWidth - totalGap; + const explicitWidths = + Array.isArray(input?.widths) && input.widths.length > 0 + ? input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0) + : []; + let widths = + explicitWidths.length > 0 + ? explicitWidths.slice(0, count) + : Array.from({ length: count }, () => (availableWidth > 0 ? availableWidth / count : contentWidth)); + + if (widths.length < count) { + const remaining = Math.max(0, availableWidth - widths.reduce((sum, width) => sum + width, 0)); + const fallbackWidth = count - widths.length > 0 ? remaining / (count - widths.length) : 0; + widths.push(...Array.from({ length: count - widths.length }, () => fallbackWidth)); + } + + const totalExplicitWidth = widths.reduce((sum, width) => sum + width, 0); + if (availableWidth > 0 && totalExplicitWidth > 0) { + const scale = availableWidth / totalExplicitWidth; + widths = widths.map((width) => Math.max(1, width * scale)); + } + + const width = widths.reduce((max, value) => Math.max(max, value), 0); if (width <= COLUMN_EPSILON) { return { @@ -2545,6 +2604,8 @@ function normalizeColumns(input: ColumnLayout | undefined, contentWidth: number) return { count, gap, + ...(widths.length > 0 ? { widths } : {}), + ...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}), width, }; } diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts index 6c15b4bdb..ba591945a 100644 --- a/packages/layout-engine/layout-engine/src/paginator.ts +++ b/packages/layout-engine/layout-engine/src/paginator.ts @@ -4,7 +4,7 @@ export type NormalizedColumns = ColumnLayout & { width: number }; export type ConstraintBoundary = { y: number; - columns: { count: number; gap: number }; + columns: ColumnLayout; }; export type PageState = { @@ -27,7 +27,7 @@ export type PaginatorOptions = { getActiveFooterDistance(): number; getActivePageSize(): { w: number; h: number }; getDefaultPageSize(): { w: number; h: number }; - getActiveColumns(): { count: number; gap: number }; + getActiveColumns(): ColumnLayout; getCurrentColumns(): NormalizedColumns; createPage(number: number, pageMargins: PageMargins, pageSizeOverride?: { w: number; h: number }): Page; onNewPage?: (state: PageState) => void; @@ -37,7 +37,7 @@ export function createPaginator(opts: PaginatorOptions) { const states: PageState[] = []; const pages: Page[] = []; - const getActiveColumnsForState = (state: PageState): { count: number; gap: number } => { + const getActiveColumnsForState = (state: PageState): ColumnLayout => { if (state.activeConstraintIndex >= 0 && state.constraintBoundaries[state.activeConstraintIndex]) { return state.constraintBoundaries[state.activeConstraintIndex].columns; } @@ -46,7 +46,15 @@ export function createPaginator(opts: PaginatorOptions) { const columnX = (columnIndex: number): number => { const cols = opts.getCurrentColumns(); - return opts.margins.left + columnIndex * (cols.width + cols.gap); + 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 => { diff --git a/packages/layout-engine/layout-engine/src/section-breaks.ts b/packages/layout-engine/layout-engine/src/section-breaks.ts index ae462b833..6eb911657 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.ts @@ -1,4 +1,5 @@ -import type { SectionBreakBlock } from '@superdoc/contracts'; +import type { ColumnLayout, SectionBreakBlock } from '@superdoc/contracts'; +import { cloneColumnLayout, widthsEqual } from './column-utils.js'; export type SectionState = { activeTopMargin: number; @@ -15,8 +16,8 @@ export type SectionState = { pendingFooterDistance: number | null; activePageSize: { w: number; h: number }; pendingPageSize: { w: number; h: number } | null; - activeColumns: { count: number; gap: number }; - pendingColumns: { count: number; gap: number } | null; + activeColumns: ColumnLayout; + pendingColumns: ColumnLayout | null; activeOrientation: 'portrait' | 'landscape' | null; pendingOrientation: 'portrait' | 'landscape' | null; hasAnyPages: boolean; @@ -29,7 +30,7 @@ export type BreakDecision = { }; /** Default single-column configuration per OOXML spec (absence of w:cols element) */ -const SINGLE_COLUMN_DEFAULT: Readonly<{ count: number; gap: number }> = { count: 1, gap: 0 }; +const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 }; /** * Get the column configuration for a section break. @@ -39,8 +40,8 @@ const SINGLE_COLUMN_DEFAULT: Readonly<{ count: number; gap: number }> = { count: * @param blockColumns - The columns property from the section break block (may be undefined) * @returns Column configuration with count and gap */ -function getColumnConfig(blockColumns: { count: number; gap: number } | undefined): { count: number; gap: number } { - return blockColumns ? { count: blockColumns.count, gap: blockColumns.gap } : { ...SINGLE_COLUMN_DEFAULT }; +function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout { + return blockColumns ? cloneColumnLayout(blockColumns) : { ...SINGLE_COLUMN_DEFAULT }; } /** @@ -53,13 +54,15 @@ function getColumnConfig(blockColumns: { count: number; gap: number } | undefine * @param activeColumns - The current active column configuration * @returns True if column layout is changing */ -function isColumnConfigChanging( - blockColumns: { count: number; gap: number } | undefined, - activeColumns: { count: number; gap: number }, -): boolean { +function isColumnConfigChanging(blockColumns: ColumnLayout | undefined, activeColumns: ColumnLayout): boolean { if (blockColumns) { // Explicit column change - return blockColumns.count !== activeColumns.count || blockColumns.gap !== activeColumns.gap; + return ( + blockColumns.count !== activeColumns.count || + blockColumns.gap !== activeColumns.gap || + blockColumns.equalWidth !== activeColumns.equalWidth || + !widthsEqual(blockColumns.widths, activeColumns.widths) + ); } // No columns specified = reset to single column (OOXML default) // This is a change only if currently in multi-column layout diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index ef6750abf..600568a3c 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, vi } from 'vitest'; import { measureBlock } from './index.js'; import type { FlowBlock, @@ -2205,6 +2205,79 @@ describe('measureBlock', () => { }); describe('overflow protection', () => { + it('does not character-break a borderline single word because of tiny measurement overflow', async () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + expect(ctx).not.toBeNull(); + const contextPrototype = Object.getPrototypeOf(ctx) as CanvasRenderingContext2D; + const originalMeasureText = contextPrototype.measureText; + const widthMap = new Map([ + ['Terms', 48.9], + ['Term', 39.125], + ['Ter', 30], + ['Te', 20], + ['T', 10], + ['s', 8.8984375], + ['1.', 13.34375], + ]); + const measureTextSpy = vi.spyOn(contextPrototype, 'measureText').mockImplementation(function (text: string) { + const mappedWidth = widthMap.get(text); + if (mappedWidth != null) { + return { + width: mappedWidth, + actualBoundingBoxLeft: 0, + actualBoundingBoxRight: mappedWidth, + actualBoundingBoxAscent: 12, + actualBoundingBoxDescent: 4, + } as TextMetrics; + } + return originalMeasureText.call(this, text); + }); + + const block: FlowBlock = { + kind: 'paragraph', + id: 'borderline-overflow-list-item', + runs: [ + { + text: 'Terms', + fontFamily: 'Arial, sans-serif', + fontSize: 16, + bold: true, + letterSpacing: -0.13333333333333333, + }, + ], + attrs: { + styleId: 'ListParagraph', + indent: { left: 24, hanging: 23.933333333333334 }, + wordLayout: { + indentLeftPx: 24, + hangingPx: 23.933333333333334, + textStartPx: 24, + marker: { + markerText: '1.', + markerBoxWidthPx: 23.933333333333334, + textStartX: 24, + gutterWidthPx: 8, + suffix: 'tab', + run: { + fontFamily: 'Arial, sans-serif', + fontSize: 16, + bold: true, + }, + }, + }, + }, + }; + + try { + const measure = expectParagraphMeasure(await measureBlock(block, 72.26666666666667)); + expect(measure.lines).toHaveLength(1); + expect(extractLineText(block, measure.lines[0])).toBe('Terms'); + } finally { + measureTextSpy.mockRestore(); + } + }); + it('keeps justified line packed by allowing small space flex (Word parity case)', async () => { const block: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 922be9c46..ab850a007 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -1988,7 +1988,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // - WIDTH_FUDGE_PX is meant to give leeway for fitting text that's very close // - We only want to break mid-word when the word truly exceeds available width // - Breaking words that exactly fit would cause unnecessary fragmentation - if (wordOnlyWidth > effectiveMaxWidth && word.length > 1) { + if (wordOnlyWidth > effectiveMaxWidth + WIDTH_FUDGE_PX && word.length > 1) { // First, finish any existing currentLine before processing the long word // Only push the line if it has actual text content (segments), not just tab positioning. // If the line only has width from tab advances but no text, we should keep it so the @@ -2019,7 +2019,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // Use remaining width for chunking if we have a tab-only line, otherwise use full line width const chunkWidth = hasTabOnlyLine ? Math.max(remainingWidthAfterTab, lineMaxWidth * 0.25) : lineMaxWidth; - const chunks = breakWordIntoChunks(word, chunkWidth - WIDTH_FUDGE_PX, font, ctx, run, wordStartChar); + const chunks = breakWordIntoChunks(word, chunkWidth, font, ctx, run, wordStartChar); // Process all chunks except the last one as complete lines let chunkCharOffset = wordStartChar; diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index ff9f5a3db..f1fc83c4e 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -826,6 +826,49 @@ describe('toFlowBlocks', () => { expect((tailBreaks[0] as never).attrs?.requirePageBoundary).toBeUndefined(); }); + it('preserves explicit custom column widths for continuous section breaks', () => { + const pmDoc: PMNode = { + type: 'doc', + attrs: { bodySectPr: createTestBodySectPr() }, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Single column' }] }, + { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + elements: [ + { name: 'w:type', attributes: { 'w:val': 'continuous' } }, + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:equalWidth': '0' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '1080', 'w:space': '1523' } }, + { name: 'w:col', attributes: { 'w:w': '7459' } }, + ], + }, + ], + }, + }, + }, + content: [{ type: 'text', text: 'Custom columns' }], + }, + ], + } as never; + + const { blocks } = toFlowBlocks(pmDoc, { emitSectionBreaks: true }); + const allBreaks = getSectionBreaks(blocks, { includeFirst: true }); + const contentBreak = allBreaks.find((b) => b.attrs?.sectionIndex === 0); + + expect(contentBreak).toBeDefined(); + expect((contentBreak as FlowBlock).columns).toEqual({ + count: 2, + gap: 101.53333333333333, + widths: [72, 497.26666666666665], + equalWidth: false, + }); + }); + it('does not mark requirePageBoundary when header/footer margins change', () => { const pmDoc: PMNode = { type: 'doc', diff --git a/packages/layout-engine/pm-adapter/src/sections/breaks.ts b/packages/layout-engine/pm-adapter/src/sections/breaks.ts index 14c726b1a..badaa3278 100644 --- a/packages/layout-engine/pm-adapter/src/sections/breaks.ts +++ b/packages/layout-engine/pm-adapter/src/sections/breaks.ts @@ -69,6 +69,16 @@ export function shallowObjectEquals(x?: Record, y?: Record x[k] === y[k]); } +const widthsEqual = (a?: number[], b?: number[]): boolean => { + if (!a && !b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +}; + /** * Deep equality check for SectionSignature objects to determine if * two section configurations are identical. @@ -83,7 +93,14 @@ export function signaturesEqual(a: SectionSignature, b: SectionSignature): boole const columnsEq = (!a.columnsPx && !b.columnsPx) || - !!(a.columnsPx && b.columnsPx && a.columnsPx.count === b.columnsPx.count && a.columnsPx.gap === b.columnsPx.gap); + !!( + a.columnsPx && + b.columnsPx && + 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) + ); const numberingEq = (!a?.numbering && !b?.numbering) || diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts index 9dee115ce..7f3fa6bee 100644 --- a/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts +++ b/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts @@ -253,6 +253,39 @@ describe('extraction', () => { }); }); + it('should extract explicit custom column widths when w:cols contains w:col children', () => { + 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': '1080', 'w:space': '1523' } }, + { name: 'w:col', attributes: { 'w:w': '7459' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 101.53333333333333, + widths: [72, 497.26666666666665], + equalWidth: false, + }); + }); + it('should handle section with only normalized margins and no sectPr elements', () => { const para: PMNode = { type: 'paragraph', diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.ts index a956c6583..6ef26fdf1 100644 --- a/packages/layout-engine/pm-adapter/src/sections/extraction.ts +++ b/packages/layout-engine/pm-adapter/src/sections/extraction.ts @@ -208,17 +208,38 @@ function extractPageNumbering(elements: SectionElement[]): /** * Extract columns from element. */ -function extractColumns(elements: SectionElement[]): { count: number; gap: number } | undefined { +function extractColumns( + elements: SectionElement[], +): { count: number; gap: number; widths?: number[]; equalWidth?: boolean } | 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); - const gapInches = parseColumnGap(cols.attributes['w:space'] as string | number | undefined); - - return { + const equalWidthRaw = cols.attributes['w:equalWidth']; + const equalWidth = + equalWidthRaw === '0' || equalWidthRaw === 0 || equalWidthRaw === false + ? false + : equalWidthRaw === '1' || equalWidthRaw === 1 || equalWidthRaw === true + ? 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']; + 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); + + const result = { count, gap: gapInches * PX_PER_INCH, + ...(widths.length > 0 ? { widths } : {}), + ...(equalWidth !== undefined ? { equalWidth } : {}), }; + + return result; } /** @@ -286,7 +307,7 @@ export function extractSectionData(para: PMNode): { type?: SectionType; pageSizePx?: { w: number; h: number }; orientation?: Orientation; - columnsPx?: { count: number; gap: number }; + columnsPx?: { count: number; gap: number; widths?: number[]; equalWidth?: boolean }; titlePg?: boolean; headerRefs?: HeaderRefType; footerRefs?: HeaderRefType; diff --git a/packages/layout-engine/pm-adapter/src/sections/types.ts b/packages/layout-engine/pm-adapter/src/sections/types.ts index 40c8d9fe1..bc8498ffe 100644 --- a/packages/layout-engine/pm-adapter/src/sections/types.ts +++ b/packages/layout-engine/pm-adapter/src/sections/types.ts @@ -72,7 +72,7 @@ export type SectionSignature = { orientation?: 'portrait' | 'landscape'; headerRefs?: Partial>; footerRefs?: Partial>; - columnsPx?: { count: number; gap: number }; + columnsPx?: { count: number; gap: number; widths?: number[]; equalWidth?: boolean }; numbering?: { format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash'; start?: number; @@ -105,7 +105,7 @@ export interface SectionRange { } | null; pageSize: { w: number; h: number } | null; orientation: 'portrait' | 'landscape' | null; - columns: { count: number; gap: number } | null; + columns: { count: number; gap: number; widths?: number[]; equalWidth?: boolean } | null; type: SectionType; titlePg: boolean; headerRefs?: Partial>; diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 5fb28224c..5f0383bb6 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -4470,7 +4470,10 @@ export class PresentationEditor extends EventEmitter { ...(firstSection?.margins?.header != null ? { header: firstSection.margins.header } : {}), ...(firstSection?.margins?.footer != null ? { footer: firstSection.margins.footer } : {}), }; - const columns = firstSection?.columns ?? defaults.columns; + // For the first emitted section break, absence of w:cols means OOXML single-column default. + // Falling back to document defaults here is wrong because bodySectPr often reflects the + // final section, which can leak a later multi-column configuration into the document start. + const columns = firstSection ? (firstSection.columns ?? { count: 1, gap: 0 }) : defaults.columns; this.#layoutOptions.pageSize = pageSize; this.#layoutOptions.margins = margins; From 24774b3f6181ec78259461c848bcca0089b578c7 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Wed, 11 Mar 2026 18:42:38 +0200 Subject: [PATCH 2/6] fix: move helpers to contracts --- .../contracts/src/column-layout.ts | 22 +++++++++++++++++++ packages/layout-engine/contracts/src/index.ts | 1 + .../layout-bridge/src/incrementalLayout.ts | 11 +--------- .../layout-engine/src/column-utils.ts | 22 +------------------ .../pm-adapter/src/sections/breaks.ts | 11 +--------- 5 files changed, 26 insertions(+), 41 deletions(-) create mode 100644 packages/layout-engine/contracts/src/column-layout.ts diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts new file mode 100644 index 000000000..26a421cbe --- /dev/null +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -0,0 +1,22 @@ +import type { ColumnLayout } from './index.js'; + +export function widthsEqual(a?: number[], b?: number[]): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { + return columns + ? { + count: columns.count, + gap: columns.gap, + ...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}), + ...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}), + } + : { count: 1, gap: 0 }; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 1475ed103..662a76b4b 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -25,6 +25,7 @@ export { } from './clip-path-inset.js'; export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js'; +export { cloneColumnLayout, widthsEqual } from './column-layout.js'; /** Inline field annotation metadata extracted from w:sdt nodes. */ export type FieldAnnotationMetadata = { type: 'fieldAnnotation'; diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 022434353..00d19b421 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -8,6 +8,7 @@ import type { ColumnLayout, SectionBreakBlock, } from '@superdoc/contracts'; +import { cloneColumnLayout } from '@superdoc/contracts'; import { layoutDocument, layoutHeaderFooter, @@ -128,16 +129,6 @@ const COLUMN_EPSILON = 0.01; type NormalizedColumns = ColumnLayout & { width: number }; type PageColumns = NormalizedColumns & { left: number; contentWidth: number }; -const cloneColumnLayout = (columns?: ColumnLayout): ColumnLayout => - columns - ? { - count: columns.count, - gap: columns.gap, - ...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}), - ...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}), - } - : { count: 1, gap: 0 }; - const resolveMaxColumnWidth = (contentWidth: number, columns?: ColumnLayout): number => { if (!columns || columns.count <= 1) return contentWidth; const normalized = normalizeColumnsForFootnotes(columns, contentWidth); diff --git a/packages/layout-engine/layout-engine/src/column-utils.ts b/packages/layout-engine/layout-engine/src/column-utils.ts index 5c0b4cf82..7f6b38e45 100644 --- a/packages/layout-engine/layout-engine/src/column-utils.ts +++ b/packages/layout-engine/layout-engine/src/column-utils.ts @@ -1,21 +1 @@ -import type { ColumnLayout } from '@superdoc/contracts'; - -export const widthsEqual = (a?: number[], b?: number[]): boolean => { - if (!a && !b) return true; - if (!a || !b) return false; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) return false; - } - return true; -}; - -export const cloneColumnLayout = (columns: ColumnLayout | undefined): ColumnLayout => - columns - ? { - count: columns.count, - gap: columns.gap, - ...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}), - ...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}), - } - : { count: 1, gap: 0 }; +export { cloneColumnLayout, widthsEqual } from '@superdoc/contracts'; diff --git a/packages/layout-engine/pm-adapter/src/sections/breaks.ts b/packages/layout-engine/pm-adapter/src/sections/breaks.ts index badaa3278..8501b2230 100644 --- a/packages/layout-engine/pm-adapter/src/sections/breaks.ts +++ b/packages/layout-engine/pm-adapter/src/sections/breaks.ts @@ -5,6 +5,7 @@ */ import type { SectionBreakBlock, FlowBlock } from '@superdoc/contracts'; +import { widthsEqual } from '@superdoc/contracts'; import type { PMNode } from '../types.js'; import type { SectionRange, SectionSignature, SectPrElement } from './types.js'; @@ -69,16 +70,6 @@ export function shallowObjectEquals(x?: Record, y?: Record x[k] === y[k]); } -const widthsEqual = (a?: number[], b?: number[]): boolean => { - if (!a && !b) return true; - if (!a || !b) return false; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) return false; - } - return true; -}; - /** * Deep equality check for SectionSignature objects to determine if * two section configurations are identical. From 1c3cd4134462cde5514375ae582cebc8469ada12 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Wed, 11 Mar 2026 19:38:41 +0200 Subject: [PATCH 3/6] fix: ts issues on build --- packages/layout-engine/pm-adapter/src/sections/extraction.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.ts index 6ef26fdf1..ca2e9979e 100644 --- a/packages/layout-engine/pm-adapter/src/sections/extraction.ts +++ b/packages/layout-engine/pm-adapter/src/sections/extraction.ts @@ -50,6 +50,7 @@ type NumberingFormat = 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' interface SectionElement { name: string; attributes?: Record; + elements?: SectionElement[]; } /** From 0767a2dfc04ea799c670f1e40ff029e7f2aa26a6 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 12 Mar 2026 17:36:19 +0200 Subject: [PATCH 4/6] fix: address comments --- .../contracts/src/column-layout.test.ts | 105 ++++++++++++++++++ .../contracts/src/column-layout.ts | 53 +++++++++ packages/layout-engine/contracts/src/index.ts | 3 +- .../layout-bridge/src/incrementalLayout.ts | 49 +------- .../src/column-balancing.test.ts | 8 +- .../layout-engine/layout-engine/src/index.ts | 48 +------- 6 files changed, 171 insertions(+), 95 deletions(-) create mode 100644 packages/layout-engine/contracts/src/column-layout.test.ts diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts new file mode 100644 index 000000000..f2107b988 --- /dev/null +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import type { ColumnLayout } from './index.js'; +import { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; + +describe('widthsEqual', () => { + it('treats two missing width arrays as equal', () => { + expect(widthsEqual()).toBe(true); + }); + + it('returns false when only one width array is present', () => { + expect(widthsEqual([72], undefined)).toBe(false); + expect(widthsEqual(undefined, [72])).toBe(false); + }); + + it('returns true for identical width arrays', () => { + expect(widthsEqual([72, 144], [72, 144])).toBe(true); + }); + + it('returns false for arrays with different lengths', () => { + expect(widthsEqual([72], [72, 144])).toBe(false); + }); + + it('returns false for arrays with different values', () => { + expect(widthsEqual([72, 144], [72, 145])).toBe(false); + }); +}); + +describe('cloneColumnLayout', () => { + it('returns a default single-column layout when input is missing', () => { + expect(cloneColumnLayout()).toEqual({ count: 1, gap: 0 }); + }); + + it('clones count, gap, widths, and equalWidth', () => { + const original: ColumnLayout = { + count: 2, + gap: 18, + widths: [72, 144], + equalWidth: false, + }; + + expect(cloneColumnLayout(original)).toEqual(original); + }); + + it('creates a defensive copy of widths', () => { + const original: ColumnLayout = { + count: 2, + gap: 18, + widths: [72, 144], + equalWidth: false, + }; + + const cloned = cloneColumnLayout(original); + + expect(cloned).not.toBe(original); + expect(cloned.widths).not.toBe(original.widths); + + cloned.widths?.push(216); + expect(original.widths).toEqual([72, 144]); + }); + + it('omits optional fields that were not provided', () => { + expect(cloneColumnLayout({ count: 2, gap: 18 })).toEqual({ + count: 2, + gap: 18, + }); + }); +}); + +describe('normalizeColumnLayout', () => { + it('returns a default single column when input is missing', () => { + expect(normalizeColumnLayout(undefined, 480)).toEqual({ + count: 1, + gap: 0, + widths: [480], + width: 480, + }); + }); + + it('computes equal-width columns from count and gap', () => { + expect(normalizeColumnLayout({ count: 2, gap: 24 }, 624)).toEqual({ + count: 2, + gap: 24, + widths: [300, 300], + width: 300, + }); + }); + + it('scales explicit widths to the available width', () => { + expect(normalizeColumnLayout({ count: 2, gap: 24, widths: [100, 200], equalWidth: false }, 624)).toEqual({ + count: 2, + gap: 24, + widths: [200, 400], + equalWidth: false, + width: 400, + }); + }); + + 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, + gap: 0, + width: 0, + }); + }); +}); diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 26a421cbe..b91d7628f 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -1,5 +1,7 @@ import type { ColumnLayout } from './index.js'; +export type NormalizedColumnLayout = ColumnLayout & { width: number }; + export function widthsEqual(a?: number[], b?: number[]): boolean { if (!a && !b) return true; if (!a || !b) return false; @@ -20,3 +22,54 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { } : { count: 1, gap: 0 }; } + +export function normalizeColumnLayout( + input: ColumnLayout | undefined, + contentWidth: number, + epsilon = 0.0001, +): NormalizedColumnLayout { + const rawCount = Number.isFinite(input?.count) ? Math.floor(input.count) : 1; + const count = Math.max(1, rawCount || 1); + const gap = Math.max(0, input?.gap ?? 0); + const totalGap = gap * (count - 1); + const availableWidth = contentWidth - totalGap; + const explicitWidths = + Array.isArray(input?.widths) && input.widths.length > 0 + ? input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0) + : []; + + let widths = + explicitWidths.length > 0 + ? explicitWidths.slice(0, count) + : Array.from({ length: count }, () => (availableWidth > 0 ? availableWidth / count : contentWidth)); + + if (widths.length < count) { + const remaining = Math.max(0, availableWidth - widths.reduce((sum, width) => sum + width, 0)); + const fallbackWidth = count - widths.length > 0 ? remaining / (count - widths.length) : 0; + widths.push(...Array.from({ length: count - widths.length }, () => fallbackWidth)); + } + + const totalExplicitWidth = widths.reduce((sum, width) => sum + width, 0); + if (availableWidth > 0 && totalExplicitWidth > 0) { + const scale = availableWidth / totalExplicitWidth; + widths = widths.map((width) => Math.max(1, width * scale)); + } + + const width = widths.reduce((max, value) => Math.max(max, value), 0); + + if (!Number.isFinite(width) || width <= epsilon) { + return { + count: 1, + gap: 0, + width: Math.max(0, contentWidth), + }; + } + + return { + count, + gap, + ...(widths.length > 0 ? { widths } : {}), + ...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}), + width, + }; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 662a76b4b..e20bda67e 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -25,7 +25,8 @@ export { } from './clip-path-inset.js'; export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js'; -export { cloneColumnLayout, widthsEqual } from './column-layout.js'; +export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; +export type { NormalizedColumnLayout } from './column-layout.js'; /** Inline field annotation metadata extracted from w:sdt nodes. */ export type FieldAnnotationMetadata = { type: 'fieldAnnotation'; diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 00d19b421..fd3766775 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -7,8 +7,9 @@ import type { ParagraphBlock, ColumnLayout, SectionBreakBlock, + NormalizedColumnLayout, } from '@superdoc/contracts'; -import { cloneColumnLayout } from '@superdoc/contracts'; +import { cloneColumnLayout, normalizeColumnLayout } from '@superdoc/contracts'; import { layoutDocument, layoutHeaderFooter, @@ -126,7 +127,7 @@ const footnoteColumnKey = (pageIndex: number, columnIndex: number): string => `$ const COLUMN_EPSILON = 0.01; -type NormalizedColumns = ColumnLayout & { width: number }; +type NormalizedColumns = NormalizedColumnLayout; type PageColumns = NormalizedColumns & { left: number; contentWidth: number }; const resolveMaxColumnWidth = (contentWidth: number, columns?: ColumnLayout): number => { @@ -136,49 +137,7 @@ const resolveMaxColumnWidth = (contentWidth: number, columns?: ColumnLayout): nu }; const normalizeColumnsForFootnotes = (input: ColumnLayout | undefined, contentWidth: number): NormalizedColumns => { - const rawCount = Number.isFinite(input?.count) ? Math.floor(input!.count) : 1; - const count = Math.max(1, rawCount || 1); - const gap = Math.max(0, input?.gap ?? 0); - const totalGap = gap * (count - 1); - const availableWidth = contentWidth - totalGap; - const explicitWidths = - Array.isArray(input?.widths) && input.widths.length > 0 - ? input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0) - : []; - let widths = - explicitWidths.length > 0 - ? explicitWidths.slice(0, count) - : Array.from({ length: count }, () => (availableWidth > 0 ? availableWidth / count : contentWidth)); - - if (widths.length < count) { - const remaining = Math.max(0, availableWidth - widths.reduce((sum, width) => sum + width, 0)); - const fallbackWidth = count - widths.length > 0 ? remaining / (count - widths.length) : 0; - widths.push(...Array.from({ length: count - widths.length }, () => fallbackWidth)); - } - - const totalExplicitWidth = widths.reduce((sum, width) => sum + width, 0); - if (availableWidth > 0 && totalExplicitWidth > 0) { - const scale = availableWidth / totalExplicitWidth; - widths = widths.map((width) => Math.max(1, width * scale)); - } - - const width = widths.reduce((max, value) => Math.max(max, value), 0); - - if (!Number.isFinite(width) || width <= COLUMN_EPSILON) { - return { - count: 1, - gap: 0, - width: Math.max(0, contentWidth), - }; - } - - return { - count, - gap, - ...(widths.length > 0 ? { widths } : {}), - ...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}), - width, - }; + return normalizeColumnLayout(input, contentWidth, COLUMN_EPSILON); }; const ooXmlSectionColumns = (columns?: ColumnLayout): ColumnLayout => cloneColumnLayout(columns); 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 c6df4b38a..46d835aa3 100644 --- a/packages/layout-engine/layout-engine/src/column-balancing.test.ts +++ b/packages/layout-engine/layout-engine/src/column-balancing.test.ts @@ -289,11 +289,9 @@ describe('shouldSkipBalancing', () => { expect(shouldSkipBalancing(ctx, DEFAULT_BALANCING_CONFIG)).toBe(true); }); - it('should NOT skip when content can be meaningfully distributed', () => { - // 100px total exceeds the available single-column height (80px), so balancing is needed. - const ctx = createContext(2, [createBlock('block-1', 50), createBlock('block-2', 50)], { - availableHeight: 80, - }); + it('should NOT skip when content height clears the minimum thresholds', () => { + // 100px total / 2 columns = 50px per column, which is above minColumnHeight (20px). + const ctx = createContext(2, [createBlock('block-1', 50), createBlock('block-2', 50)]); expect(shouldSkipBalancing(ctx, DEFAULT_BALANCING_CONFIG)).toBe(false); }); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index e314a0564..9b33fae0f 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -26,7 +26,9 @@ import type { DrawingFragment, SectionNumbering, FlowMode, + NormalizedColumnLayout, } from '@superdoc/contracts'; +import { normalizeColumnLayout } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { @@ -56,7 +58,7 @@ type Margins = { footer?: number; }; -type NormalizedColumns = ColumnLayout & { width: number }; +type NormalizedColumns = NormalizedColumnLayout; const getColumnWidthAt = (columns: NormalizedColumns, columnIndex: number): number => { if (Array.isArray(columns.widths) && columns.widths.length > 0) { @@ -2565,49 +2567,7 @@ export function layoutHeaderFooter( * // Returns { count: 1, gap: 0, width: 600 } */ function normalizeColumns(input: ColumnLayout | undefined, contentWidth: number): NormalizedColumns { - const rawCount = Number.isFinite(input?.count) ? Math.floor(input!.count) : 1; - const count = Math.max(1, rawCount || 1); - const gap = Math.max(0, input?.gap ?? 0); - const totalGap = gap * (count - 1); - const availableWidth = contentWidth - totalGap; - const explicitWidths = - Array.isArray(input?.widths) && input.widths.length > 0 - ? input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0) - : []; - let widths = - explicitWidths.length > 0 - ? explicitWidths.slice(0, count) - : Array.from({ length: count }, () => (availableWidth > 0 ? availableWidth / count : contentWidth)); - - if (widths.length < count) { - const remaining = Math.max(0, availableWidth - widths.reduce((sum, width) => sum + width, 0)); - const fallbackWidth = count - widths.length > 0 ? remaining / (count - widths.length) : 0; - widths.push(...Array.from({ length: count - widths.length }, () => fallbackWidth)); - } - - const totalExplicitWidth = widths.reduce((sum, width) => sum + width, 0); - if (availableWidth > 0 && totalExplicitWidth > 0) { - const scale = availableWidth / totalExplicitWidth; - widths = widths.map((width) => Math.max(1, width * scale)); - } - - const width = widths.reduce((max, value) => Math.max(max, value), 0); - - if (width <= COLUMN_EPSILON) { - return { - count: 1, - gap: 0, - width: contentWidth, - }; - } - - return { - count, - gap, - ...(widths.length > 0 ? { widths } : {}), - ...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}), - width, - }; + return normalizeColumnLayout(input, contentWidth, COLUMN_EPSILON); } const _buildMeasureMap = (blocks: FlowBlock[], measures: Measure[]): Map => { From ce0271a325646bda3d31c6d565ffaf29e7a2f0ba Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 12 Mar 2026 17:40:23 +0200 Subject: [PATCH 5/6] chore: added comment --- packages/layout-engine/layout-bridge/src/incrementalLayout.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index fd3766775..777296b7a 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -130,6 +130,8 @@ const COLUMN_EPSILON = 0.01; type NormalizedColumns = NormalizedColumnLayout; type PageColumns = NormalizedColumns & { left: number; contentWidth: number }; +// TODO: Footnotes are measured against the widest column width for the section. +// If a footnote ultimately lands in a narrower column, its wrapping can be slightly off. const resolveMaxColumnWidth = (contentWidth: number, columns?: ColumnLayout): number => { if (!columns || columns.count <= 1) return contentWidth; const normalized = normalizeColumnsForFootnotes(columns, contentWidth); From 9bc8a64cda379812582e5019c928a1dc7147eb5d Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 12 Mar 2026 18:30:54 +0200 Subject: [PATCH 6/6] fix: build issue --- packages/layout-engine/contracts/src/column-layout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index b91d7628f..e63e4e95d 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -28,7 +28,7 @@ export function normalizeColumnLayout( contentWidth: number, epsilon = 0.0001, ): NormalizedColumnLayout { - const rawCount = Number.isFinite(input?.count) ? Math.floor(input.count) : 1; + const rawCount = input && Number.isFinite(input.count) ? Math.floor(input.count) : 1; const count = Math.max(1, rawCount || 1); const gap = Math.max(0, input?.gap ?? 0); const totalGap = gap * (count - 1);