Skip to content
Merged
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
4 changes: 2 additions & 2 deletions packages/layout-engine/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
"description": "Shared layout contracts & engine interfaces for the SuperDoc layout engine pipeline.",
"type": "module",
"private": true,
"main": "./dist/index.js",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"source": "./src/index.ts",
"default": "./dist/index.js"
"default": "./src/index.ts"
}
},
"scripts": {
Expand Down
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,
};
}
12 changes: 11 additions & 1 deletion packages/layout-engine/contracts/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { extractHeaderFooterSpace } from './index.js';
import { cloneColumnLayout, extractHeaderFooterSpace, normalizeColumnLayout, widthsEqual } from './index.js';
import type { FlowBlock, Layout, PainterDOM, PainterPDF } from './index.js';

describe('contracts', () => {
Expand Down Expand Up @@ -92,4 +92,14 @@ describe('contracts', () => {
expect(zeroSpacing.headerSpace).toBe(0);
expect(zeroSpacing.footerSpace).toBe(0);
});

it('re-exports column layout helpers from the package entrypoint', () => {
expect(widthsEqual([72, 144], [72, 144])).toBe(true);
expect(cloneColumnLayout({ count: 2, gap: 18, widths: [72, 144] })).toEqual({
count: 2,
gap: 18,
widths: [72, 144],
});
expect(normalizeColumnLayout({ count: 2, gap: 24 }, 624).widths).toEqual([300, 300]);
});
});
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
Loading
Loading