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
2 changes: 2 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,8 @@ export type {
ResolvedTableItem,
ResolvedImageItem,
ResolvedDrawingItem,
ResolvedHeaderFooterPage,
ResolvedHeaderFooterLayout,
} from './resolved-layout.js';
export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from './resolved-layout.js';

Expand Down
16 changes: 16 additions & 0 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,22 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'drawing' && 'block' in item;
}

/** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */
export type ResolvedHeaderFooterPage = {
number: number;
numberText?: string;
items: ResolvedPaintItem[];
};

/** A resolved header/footer layout — mirrors HeaderFooterLayout but with resolved pages. */
export type ResolvedHeaderFooterLayout = {
height: number;
minY?: number;
maxY?: number;
renderHeight?: number;
pages: ResolvedHeaderFooterPage[];
};

/** Resolved list marker rendering data with pre-computed positioning. */
export type ResolvedListMarkerItem = {
/** Marker text content (e.g., "1.", "a)", bullet). */
Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/layout-resolved/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { resolveLayout } from './resolveLayout.js';
export type { ResolveLayoutInput } from './resolveLayout.js';
export { resolveHeaderFooterLayout } from './resolveHeaderFooter.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import { resolveHeaderFooterLayout } from './resolveHeaderFooter.js';
import type { FlowBlock, HeaderFooterLayout, Measure, ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts';

describe('resolveHeaderFooterLayout', () => {
it('resolves a header/footer with one paragraph fragment', () => {
const paraFragment: ParaFragment = {
kind: 'para',
blockId: 'p1',
fromLine: 0,
toLine: 1,
x: 72,
y: 10,
width: 468,
};
const layout: HeaderFooterLayout = {
height: 50,
pages: [{ number: 1, fragments: [paraFragment] }],
};
const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
const measures: Measure[] = [
{
kind: 'paragraph',
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }],
totalHeight: 18,
},
];

const result = resolveHeaderFooterLayout(layout, blocks, measures);
expect(result.pages).toHaveLength(1);
const item = result.pages[0].items[0] as ResolvedFragmentItem;
expect(item.version).toBeDefined();
expect(item.block?.kind).toBe('paragraph');
expect(item.measure?.kind).toBe('paragraph');
});

it('preserves height, minY, maxY, renderHeight from input', () => {
const layout: HeaderFooterLayout = {
height: 100,
minY: 5,
maxY: 120,
renderHeight: 115,
pages: [],
};

const result = resolveHeaderFooterLayout(layout, [], []);
expect(result.height).toBe(100);
expect(result.minY).toBe(5);
expect(result.maxY).toBe(120);
expect(result.renderHeight).toBe(115);
});

it('preserves numberText on pages', () => {
const layout: HeaderFooterLayout = {
height: 50,
pages: [
{ number: 1, fragments: [], numberText: 'i' },
{ number: 2, fragments: [], numberText: 'ii' },
],
};

const result = resolveHeaderFooterLayout(layout, [], []);
expect(result.pages[0].numberText).toBe('i');
expect(result.pages[1].numberText).toBe('ii');
});

it('returns empty items array for empty fragments array', () => {
const layout: HeaderFooterLayout = {
height: 50,
pages: [{ number: 1, fragments: [] }],
};

const result = resolveHeaderFooterLayout(layout, [], []);
expect(result.pages).toHaveLength(1);
expect(result.pages[0].items).toEqual([]);
});

it('leaves block/measure undefined when block entry is missing', () => {
const paraFragment: ParaFragment = {
kind: 'para',
blockId: 'missing-id',
fromLine: 0,
toLine: 1,
x: 0,
y: 0,
width: 100,
};
const layout: HeaderFooterLayout = {
height: 50,
pages: [{ number: 1, fragments: [paraFragment] }],
};

const result = resolveHeaderFooterLayout(layout, [], []);
const item = result.pages[0].items[0] as ResolvedFragmentItem;
expect(item.block).toBeUndefined();
expect(item.measure).toBeUndefined();
});
});
40 changes: 40 additions & 0 deletions packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {
FlowBlock,
HeaderFooterLayout,
Measure,
ResolvedHeaderFooterLayout,
ResolvedHeaderFooterPage,
} from '@superdoc/contracts';
import { buildBlockMap, resolveFragmentItem } from './resolveLayout.js';

/**
* Resolves a header/footer layout into a `ResolvedHeaderFooterLayout`.
*
* Standalone helper invoked per `HeaderFooterLayoutResult` from `incrementalLayout`.
* The caller stores results indexed by the same key (type or rId) as the originals;
* alignment between fragments and resolved items is guaranteed by construction.
*/
export function resolveHeaderFooterLayout(
layout: HeaderFooterLayout,
blocks: FlowBlock[],
measures: Measure[],
): ResolvedHeaderFooterLayout {
const blockMap = buildBlockMap(blocks, measures);
const blockVersionCache = new Map<string, string>();

const pages: ResolvedHeaderFooterPage[] = layout.pages.map((page, pageIndex) => ({
number: page.number,
numberText: page.numberText,
items: page.fragments.map((fragment, fragmentIndex) =>
resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap, blockVersionCache),
),
}));

return {
height: layout.height,
minY: layout.minY,
maxY: layout.maxY,
renderHeight: layout.renderHeight,
pages,
};
}
4 changes: 2 additions & 2 deletions packages/layout-engine/layout-resolved/src/resolveLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type ResolveLayoutInput = {
measures: Measure[];
};

function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map<string, BlockMapEntry> {
export function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map<string, BlockMapEntry> {
const map = new Map<string, BlockMapEntry>();
for (let i = 0; i < blocks.length; i++) {
map.set(blocks[i].id, { block: blocks[i], measure: measures[i] });
Expand Down Expand Up @@ -190,7 +190,7 @@ function computeBlockVersion(
return version;
}

function resolveFragmentItem(
export function resolveFragmentItem(
fragment: Fragment,
fragmentIndex: number,
pageIndex: number,
Expand Down
Loading