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
105 changes: 105 additions & 0 deletions packages/layout-engine/contracts/src/column-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
75 changes: 75 additions & 0 deletions packages/layout-engine/contracts/src/column-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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;
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 };
}

export function normalizeColumnLayout(
input: ColumnLayout | undefined,
contentWidth: number,
epsilon = 0.0001,
): NormalizedColumnLayout {
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);
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,
};
}
5 changes: 5 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export {
} from './clip-path-inset.js';

export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.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';
Expand Down Expand Up @@ -932,6 +934,7 @@ export type SectionBreakBlock = {
columns?: {
count: number;
gap: number;
widths?: number[];
equalWidth?: boolean;
};
/**
Expand Down Expand Up @@ -1419,6 +1422,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. */
Expand Down
88 changes: 41 additions & 47 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import type {
ParagraphBlock,
ColumnLayout,
SectionBreakBlock,
NormalizedColumnLayout,
} from '@superdoc/contracts';
import { cloneColumnLayout, normalizeColumnLayout } from '@superdoc/contracts';
import {
layoutDocument,
layoutHeaderFooter,
Expand Down Expand Up @@ -125,46 +127,40 @@ 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 };

Copy link
Contributor

Choose a reason for hiding this comment

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

this function is almost copy-pasted from normalizeColumns in index.ts:2567. could they share one helper?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

created shared helper

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;

if (!Number.isFinite(width) || width <= COLUMN_EPSILON) {
return {
count: 1,
gap: 0,
width: Math.max(0, contentWidth),
};
}
// 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 => {
Copy link
Contributor

Choose a reason for hiding this comment

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

footnotes are always measured using the widest column's width. if a footnote lands in a narrower column, its wrapping might be slightly off. maybe add a // TODO so it doesn't catch someone off guard later?

if (!columns || columns.count <= 1) return contentWidth;
const normalized = normalizeColumnsForFootnotes(columns, contentWidth);
return normalized.width;
};

return { count, gap, width };
const normalizeColumnsForFootnotes = (input: ColumnLayout | undefined, contentWidth: number): NormalizedColumns => {
return normalizeColumnLayout(input, contentWidth, COLUMN_EPSILON);
};

const ooXmlSectionColumns = (columns?: ColumnLayout): ColumnLayout => cloneColumnLayout(columns);

const resolveSectionColumnsByIndex = (options: LayoutOptions, blocks?: FlowBlock[]): Map<number, ColumnLayout> => {
const result = new Map<number, ColumnLayout>();
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) {
if (block.kind !== 'sectionBreak') continue;
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;
Expand Down Expand Up @@ -228,9 +224,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));
}
}
}

Expand Down Expand Up @@ -260,7 +270,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 };

Expand All @@ -280,9 +290,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;
}
Expand Down Expand Up @@ -2001,17 +2009,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,
};

Expand All @@ -2032,7 +2033,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,
};
}
Expand Down Expand Up @@ -2132,14 +2133,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) {
Expand All @@ -2155,7 +2149,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;
}
Expand Down
Loading
Loading