Skip to content

Commit c3b2cef

Browse files
authored
refactor(layout): lift page metadata into ResolvedPage (#2810)
1 parent 58c3fca commit c3b2cef

4 files changed

Lines changed: 286 additions & 31 deletions

File tree

packages/layout-engine/contracts/src/resolved-layout.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import type { DrawingBlock, FlowMode, Fragment, ImageBlock, Line, TableBlock, TableMeasure } from './index.js';
1+
import type {
2+
DrawingBlock,
3+
FlowMode,
4+
Fragment,
5+
ImageBlock,
6+
Line,
7+
PageMargins,
8+
SectionVerticalAlign,
9+
TableBlock,
10+
TableMeasure,
11+
} from './index.js';
212

313
/** A fully resolved layout ready for the next-generation paint pipeline. */
414
export type ResolvedLayout = {
@@ -10,6 +20,8 @@ export type ResolvedLayout = {
1020
pageGap: number;
1121
/** Resolved pages with normalized dimensions. */
1222
pages: ResolvedPage[];
23+
/** Document epoch identifier from the source layout. Used for change tracking in the painter. */
24+
layoutEpoch?: number;
1325
};
1426

1527
/** A single resolved page with stable identity and normalized dimensions. */
@@ -26,6 +38,25 @@ export type ResolvedPage = {
2638
height: number;
2739
/** Resolved paint items for this page. */
2840
items: ResolvedPaintItem[];
41+
/** Page margins from the source page. Used for ruler rendering and header/footer positioning. */
42+
margins?: PageMargins;
43+
/** Extra bottom space reserved for footnotes (px). Used for footer space calculation. */
44+
footnoteReserved?: number;
45+
/** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */
46+
numberText?: string;
47+
/** Vertical alignment of content within this page. */
48+
vAlign?: SectionVerticalAlign;
49+
/** Base section margins before header/footer inflation. Used for vAlign centering calculations. */
50+
baseMargins?: { top: number; bottom: number };
51+
/** 0-based index of the section this page belongs to. */
52+
sectionIndex?: number;
53+
/** Header/footer reference IDs for this page's section. */
54+
sectionRefs?: {
55+
headerRefs?: { default?: string; first?: string; even?: string; odd?: string };
56+
footerRefs?: { default?: string; first?: string; even?: string; odd?: string };
57+
};
58+
/** Page orientation. */
59+
orientation?: 'portrait' | 'landscape';
2960
};
3061

3162
/** Union of all resolved paint item kinds. */

packages/layout-engine/layout-resolved/src/resolveLayout.test.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,4 +1487,186 @@ describe('resolveLayout', () => {
14871487
expect(content.lines[0].availableWidth).toBe(360);
14881488
});
14891489
});
1490+
1491+
describe('page metadata fields', () => {
1492+
it('carries margins through to resolved page', () => {
1493+
const layout: Layout = {
1494+
pageSize: { w: 612, h: 792 },
1495+
pages: [
1496+
{
1497+
number: 1,
1498+
fragments: [],
1499+
margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36, gutter: 0 },
1500+
},
1501+
],
1502+
};
1503+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1504+
expect(result.pages[0].margins).toEqual({
1505+
top: 72,
1506+
right: 72,
1507+
bottom: 72,
1508+
left: 72,
1509+
header: 36,
1510+
footer: 36,
1511+
gutter: 0,
1512+
});
1513+
});
1514+
1515+
it('leaves margins undefined when page has no margins', () => {
1516+
const layout: Layout = {
1517+
pageSize: { w: 612, h: 792 },
1518+
pages: [{ number: 1, fragments: [] }],
1519+
};
1520+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1521+
expect(result.pages[0].margins).toBeUndefined();
1522+
});
1523+
1524+
it('carries footnoteReserved through to resolved page', () => {
1525+
const layout: Layout = {
1526+
pageSize: { w: 612, h: 792 },
1527+
pages: [{ number: 1, fragments: [], footnoteReserved: 48 }],
1528+
};
1529+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1530+
expect(result.pages[0].footnoteReserved).toBe(48);
1531+
});
1532+
1533+
it('carries numberText through to resolved page', () => {
1534+
const layout: Layout = {
1535+
pageSize: { w: 612, h: 792 },
1536+
pages: [{ number: 1, fragments: [], numberText: 'i' }],
1537+
};
1538+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1539+
expect(result.pages[0].numberText).toBe('i');
1540+
});
1541+
1542+
it('carries vAlign and baseMargins through to resolved page', () => {
1543+
const layout: Layout = {
1544+
pageSize: { w: 612, h: 792 },
1545+
pages: [
1546+
{
1547+
number: 1,
1548+
fragments: [],
1549+
vAlign: 'center',
1550+
baseMargins: { top: 72, bottom: 72 },
1551+
},
1552+
],
1553+
};
1554+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1555+
expect(result.pages[0].vAlign).toBe('center');
1556+
expect(result.pages[0].baseMargins).toEqual({ top: 72, bottom: 72 });
1557+
});
1558+
1559+
it('carries sectionIndex through to resolved page', () => {
1560+
const layout: Layout = {
1561+
pageSize: { w: 612, h: 792 },
1562+
pages: [{ number: 1, fragments: [], sectionIndex: 2 }],
1563+
};
1564+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1565+
expect(result.pages[0].sectionIndex).toBe(2);
1566+
});
1567+
1568+
it('carries sectionRefs through to resolved page', () => {
1569+
const layout: Layout = {
1570+
pageSize: { w: 612, h: 792 },
1571+
pages: [
1572+
{
1573+
number: 1,
1574+
fragments: [],
1575+
sectionRefs: {
1576+
headerRefs: { default: 'hdr1', first: 'hdr-first' },
1577+
footerRefs: { default: 'ftr1' },
1578+
},
1579+
},
1580+
],
1581+
};
1582+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1583+
expect(result.pages[0].sectionRefs).toEqual({
1584+
headerRefs: { default: 'hdr1', first: 'hdr-first' },
1585+
footerRefs: { default: 'ftr1' },
1586+
});
1587+
});
1588+
1589+
it('carries orientation through to resolved page', () => {
1590+
const layout: Layout = {
1591+
pageSize: { w: 792, h: 612 },
1592+
pages: [{ number: 1, fragments: [], orientation: 'landscape' }],
1593+
};
1594+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1595+
expect(result.pages[0].orientation).toBe('landscape');
1596+
});
1597+
1598+
it('leaves optional metadata undefined when not set on source page', () => {
1599+
const layout: Layout = {
1600+
pageSize: { w: 612, h: 792 },
1601+
pages: [{ number: 1, fragments: [] }],
1602+
};
1603+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1604+
const page = result.pages[0];
1605+
expect(page.margins).toBeUndefined();
1606+
expect(page.footnoteReserved).toBeUndefined();
1607+
expect(page.numberText).toBeUndefined();
1608+
expect(page.vAlign).toBeUndefined();
1609+
expect(page.baseMargins).toBeUndefined();
1610+
expect(page.sectionIndex).toBeUndefined();
1611+
expect(page.sectionRefs).toBeUndefined();
1612+
expect(page.orientation).toBeUndefined();
1613+
});
1614+
1615+
it('carries all metadata fields together on a fully-populated page', () => {
1616+
const layout: Layout = {
1617+
pageSize: { w: 612, h: 792 },
1618+
pages: [
1619+
{
1620+
number: 3,
1621+
fragments: [],
1622+
margins: { top: 72, right: 72, bottom: 72, left: 72 },
1623+
footnoteReserved: 24,
1624+
numberText: 'iii',
1625+
vAlign: 'bottom',
1626+
baseMargins: { top: 96, bottom: 96 },
1627+
sectionIndex: 1,
1628+
sectionRefs: {
1629+
headerRefs: { default: 'h1' },
1630+
footerRefs: { default: 'f1', even: 'f-even' },
1631+
},
1632+
orientation: 'portrait',
1633+
},
1634+
],
1635+
};
1636+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1637+
const page = result.pages[0];
1638+
expect(page.margins).toEqual({ top: 72, right: 72, bottom: 72, left: 72 });
1639+
expect(page.footnoteReserved).toBe(24);
1640+
expect(page.numberText).toBe('iii');
1641+
expect(page.vAlign).toBe('bottom');
1642+
expect(page.baseMargins).toEqual({ top: 96, bottom: 96 });
1643+
expect(page.sectionIndex).toBe(1);
1644+
expect(page.sectionRefs).toEqual({
1645+
headerRefs: { default: 'h1' },
1646+
footerRefs: { default: 'f1', even: 'f-even' },
1647+
});
1648+
expect(page.orientation).toBe('portrait');
1649+
});
1650+
});
1651+
1652+
describe('layoutEpoch', () => {
1653+
it('carries layoutEpoch from source layout to resolved layout', () => {
1654+
const layout: Layout = {
1655+
pageSize: { w: 612, h: 792 },
1656+
pages: [],
1657+
layoutEpoch: 42,
1658+
};
1659+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1660+
expect(result.layoutEpoch).toBe(42);
1661+
});
1662+
1663+
it('defaults layoutEpoch to undefined when not set', () => {
1664+
const layout: Layout = {
1665+
pageSize: { w: 612, h: 792 },
1666+
pages: [],
1667+
};
1668+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
1669+
expect(result.layoutEpoch).toBeUndefined();
1670+
});
1671+
});
14901672
});

packages/layout-engine/layout-resolved/src/resolveLayout.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,26 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
168168
items: page.fragments.map((fragment, fragmentIndex) =>
169169
resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap),
170170
),
171+
margins: page.margins,
172+
footnoteReserved: page.footnoteReserved,
173+
numberText: page.numberText,
174+
vAlign: page.vAlign,
175+
baseMargins: page.baseMargins,
176+
sectionIndex: page.sectionIndex,
177+
sectionRefs: page.sectionRefs,
178+
orientation: page.orientation,
171179
}));
172180

173-
return {
181+
const resolved: ResolvedLayout = {
174182
version: 1,
175183
flowMode,
176184
pageGap: layout.pageGap ?? 0,
177185
pages,
178186
};
187+
188+
if (layout.layoutEpoch != null) {
189+
resolved.layoutEpoch = layout.layoutEpoch;
190+
}
191+
192+
return resolved;
179193
}

0 commit comments

Comments
 (0)