diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 9170e1e202..d0eae8f951 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -1,4 +1,14 @@ -import type { DrawingBlock, FlowMode, Fragment, ImageBlock, Line, TableBlock, TableMeasure } from './index.js'; +import type { + DrawingBlock, + FlowMode, + Fragment, + ImageBlock, + Line, + PageMargins, + SectionVerticalAlign, + TableBlock, + TableMeasure, +} from './index.js'; /** A fully resolved layout ready for the next-generation paint pipeline. */ export type ResolvedLayout = { @@ -10,6 +20,8 @@ export type ResolvedLayout = { pageGap: number; /** Resolved pages with normalized dimensions. */ pages: ResolvedPage[]; + /** Document epoch identifier from the source layout. Used for change tracking in the painter. */ + layoutEpoch?: number; }; /** A single resolved page with stable identity and normalized dimensions. */ @@ -26,6 +38,25 @@ export type ResolvedPage = { height: number; /** Resolved paint items for this page. */ items: ResolvedPaintItem[]; + /** Page margins from the source page. Used for ruler rendering and header/footer positioning. */ + margins?: PageMargins; + /** Extra bottom space reserved for footnotes (px). Used for footer space calculation. */ + footnoteReserved?: number; + /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */ + numberText?: string; + /** Vertical alignment of content within this page. */ + vAlign?: SectionVerticalAlign; + /** Base section margins before header/footer inflation. Used for vAlign centering calculations. */ + baseMargins?: { top: number; bottom: number }; + /** 0-based index of the section this page belongs to. */ + sectionIndex?: number; + /** Header/footer reference IDs for this page's section. */ + sectionRefs?: { + headerRefs?: { default?: string; first?: string; even?: string; odd?: string }; + footerRefs?: { default?: string; first?: string; even?: string; odd?: string }; + }; + /** Page orientation. */ + orientation?: 'portrait' | 'landscape'; }; /** Union of all resolved paint item kinds. */ diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index a9df355da8..04a9fc805b 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -1487,4 +1487,186 @@ describe('resolveLayout', () => { expect(content.lines[0].availableWidth).toBe(360); }); }); + + describe('page metadata fields', () => { + it('carries margins through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [], + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36, gutter: 0 }, + }, + ], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].margins).toEqual({ + top: 72, + right: 72, + bottom: 72, + left: 72, + header: 36, + footer: 36, + gutter: 0, + }); + }); + + it('leaves margins undefined when page has no margins', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].margins).toBeUndefined(); + }); + + it('carries footnoteReserved through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [], footnoteReserved: 48 }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].footnoteReserved).toBe(48); + }); + + it('carries numberText through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [], numberText: 'i' }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].numberText).toBe('i'); + }); + + it('carries vAlign and baseMargins through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [], + vAlign: 'center', + baseMargins: { top: 72, bottom: 72 }, + }, + ], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].vAlign).toBe('center'); + expect(result.pages[0].baseMargins).toEqual({ top: 72, bottom: 72 }); + }); + + it('carries sectionIndex through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [], sectionIndex: 2 }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].sectionIndex).toBe(2); + }); + + it('carries sectionRefs through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [], + sectionRefs: { + headerRefs: { default: 'hdr1', first: 'hdr-first' }, + footerRefs: { default: 'ftr1' }, + }, + }, + ], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].sectionRefs).toEqual({ + headerRefs: { default: 'hdr1', first: 'hdr-first' }, + footerRefs: { default: 'ftr1' }, + }); + }); + + it('carries orientation through to resolved page', () => { + const layout: Layout = { + pageSize: { w: 792, h: 612 }, + pages: [{ number: 1, fragments: [], orientation: 'landscape' }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.pages[0].orientation).toBe('landscape'); + }); + + it('leaves optional metadata undefined when not set on source page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const page = result.pages[0]; + expect(page.margins).toBeUndefined(); + expect(page.footnoteReserved).toBeUndefined(); + expect(page.numberText).toBeUndefined(); + expect(page.vAlign).toBeUndefined(); + expect(page.baseMargins).toBeUndefined(); + expect(page.sectionIndex).toBeUndefined(); + expect(page.sectionRefs).toBeUndefined(); + expect(page.orientation).toBeUndefined(); + }); + + it('carries all metadata fields together on a fully-populated page', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 3, + fragments: [], + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + footnoteReserved: 24, + numberText: 'iii', + vAlign: 'bottom', + baseMargins: { top: 96, bottom: 96 }, + sectionIndex: 1, + sectionRefs: { + headerRefs: { default: 'h1' }, + footerRefs: { default: 'f1', even: 'f-even' }, + }, + orientation: 'portrait', + }, + ], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const page = result.pages[0]; + expect(page.margins).toEqual({ top: 72, right: 72, bottom: 72, left: 72 }); + expect(page.footnoteReserved).toBe(24); + expect(page.numberText).toBe('iii'); + expect(page.vAlign).toBe('bottom'); + expect(page.baseMargins).toEqual({ top: 96, bottom: 96 }); + expect(page.sectionIndex).toBe(1); + expect(page.sectionRefs).toEqual({ + headerRefs: { default: 'h1' }, + footerRefs: { default: 'f1', even: 'f-even' }, + }); + expect(page.orientation).toBe('portrait'); + }); + }); + + describe('layoutEpoch', () => { + it('carries layoutEpoch from source layout to resolved layout', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [], + layoutEpoch: 42, + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.layoutEpoch).toBe(42); + }); + + it('defaults layoutEpoch to undefined when not set', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + expect(result.layoutEpoch).toBeUndefined(); + }); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index fd16d0b15d..1c7e513981 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -168,12 +168,26 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { items: page.fragments.map((fragment, fragmentIndex) => resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap), ), + margins: page.margins, + footnoteReserved: page.footnoteReserved, + numberText: page.numberText, + vAlign: page.vAlign, + baseMargins: page.baseMargins, + sectionIndex: page.sectionIndex, + sectionRefs: page.sectionRefs, + orientation: page.orientation, })); - return { + const resolved: ResolvedLayout = { version: 1, flowMode, pageGap: layout.pageGap ?? 0, pages, }; + + if (layout.layoutEpoch != null) { + resolved.layoutEpoch = layout.layoutEpoch; + } + + return resolved; } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index c0c85996fa..a588896c3e 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1682,7 +1682,7 @@ export class DomPainter { } this.layoutVersion += 1; - this.layoutEpoch = layout.layoutEpoch ?? 0; + this.layoutEpoch = this.resolvedLayout?.layoutEpoch ?? layout.layoutEpoch ?? 0; this.mount = mount; this.beginPaintSnapshot(layout); @@ -2193,6 +2193,7 @@ export class DomPainter { if (!this.doc) { throw new Error('DomPainter: document is not available'); } + const resolvedPage = this.getResolvedPage(pageIndex); const el = this.doc.createElement('div'); el.classList.add(CLASS_NAMES.page); applyStyles(el, pageStyles(width, height, this.getEffectivePageStyles())); @@ -2203,7 +2204,7 @@ export class DomPainter { // Render per-page ruler if enabled (suppressed in semantic flow mode) if (!this.isSemanticFlow && this.options.ruler?.enabled) { - const rulerEl = this.renderPageRuler(width, page); + const rulerEl = this.renderPageRuler(width, page, resolvedPage); if (rulerEl) { el.appendChild(rulerEl); } @@ -2213,7 +2214,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: 'body', - pageNumberText: page.numberText, + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2227,7 +2228,7 @@ export class DomPainter { this.renderFragment(fragment, contextBase, sdtBoundary, betweenBorderFlags.get(index), resolvedItem), ); }); - this.renderDecorationsForPage(el, page, pageIndex); + this.renderDecorationsForPage(el, page, pageIndex, resolvedPage); return el; } @@ -2249,18 +2250,18 @@ export class DomPainter { * - Uses DEFAULT_PAGE_HEIGHT_PX (1056px = 11 inches) if page.size.h is not available * - Defaults margins to 0 if not explicitly provided */ - private renderPageRuler(pageWidthPx: number, page: Page): HTMLElement | null { + private renderPageRuler(pageWidthPx: number, page: Page, resolvedPage?: ResolvedPage | null): HTMLElement | null { if (!this.doc) { console.warn('[renderPageRuler] Cannot render ruler: document is not available.'); return null; } - if (!page.margins) { + const margins = resolvedPage?.margins ?? page.margins; + if (!margins) { console.warn(`[renderPageRuler] Cannot render ruler for page ${page.number}: margins not available.`); return null; } - const margins = page.margins; const leftMargin = margins.left ?? 0; const rightMargin = margins.right ?? 0; @@ -2308,10 +2309,15 @@ export class DomPainter { } } - private renderDecorationsForPage(pageEl: HTMLElement, page: Page, pageIndex: number): void { + private renderDecorationsForPage( + pageEl: HTMLElement, + page: Page, + pageIndex: number, + resolvedPage?: ResolvedPage | null, + ): void { if (this.isSemanticFlow) return; - this.renderDecorationSection(pageEl, page, pageIndex, 'header'); - this.renderDecorationSection(pageEl, page, pageIndex, 'footer'); + this.renderDecorationSection(pageEl, page, pageIndex, 'header', resolvedPage); + this.renderDecorationSection(pageEl, page, pageIndex, 'footer', resolvedPage); } /** @@ -2349,17 +2355,19 @@ export class DomPainter { page: Page, kind: 'header' | 'footer', effectiveOffset: number, + resolvedPage?: ResolvedPage | null, ): number { if (kind === 'header') { return effectiveOffset; } - const bottomMargin = page.margins?.bottom; + const pageMargins = resolvedPage?.margins ?? page.margins; + const bottomMargin = pageMargins?.bottom; if (bottomMargin == null) { return effectiveOffset; } - const footnoteReserve = page.footnoteReserved ?? 0; + const footnoteReserve = resolvedPage?.footnoteReserved ?? page.footnoteReserved ?? 0; const adjustedBottomMargin = Math.max(0, bottomMargin - footnoteReserve); const styledPageHeight = Number.parseFloat(pageEl.style.height || ''); const pageHeight = @@ -2370,11 +2378,18 @@ export class DomPainter { return Math.max(0, pageHeight - adjustedBottomMargin); } - private renderDecorationSection(pageEl: HTMLElement, page: Page, pageIndex: number, kind: 'header' | 'footer'): void { + private renderDecorationSection( + pageEl: HTMLElement, + page: Page, + pageIndex: number, + kind: 'header' | 'footer', + resolvedPage?: ResolvedPage | null, + ): void { if (!this.doc) return; const provider = kind === 'header' ? this.headerProvider : this.footerProvider; const className = kind === 'header' ? CLASS_NAMES.pageHeader : CLASS_NAMES.pageFooter; const existing = pageEl.querySelector(`.${className}`); + // Provider still receives legacy page — its signature is not changed in this PR const data = provider ? provider(page.number, page.margins, page) : null; if (!data || data.fragments.length === 0) { @@ -2387,7 +2402,8 @@ export class DomPainter { container.innerHTML = ''; const baseOffset = data.offset ?? (kind === 'footer' ? pageEl.clientHeight - data.height : 0); const marginLeft = data.marginLeft ?? 0; - const marginRight = page.margins?.right ?? 0; + const pageMargins = resolvedPage?.margins ?? page.margins; + const marginRight = pageMargins?.right ?? 0; // For footers, if content is taller than reserved space, expand container upward // The container bottom stays anchored at footerMargin from page bottom @@ -2427,7 +2443,7 @@ export class DomPainter { // Header page-relative anchors use raw inner-layout Y and are handled with // the simpler effectiveOffset subtraction (unchanged from the baseline). const footerAnchorPageOriginY = - kind === 'footer' ? this.getDecorationAnchorPageOriginY(pageEl, page, kind, effectiveOffset) : 0; + kind === 'footer' ? this.getDecorationAnchorPageOriginY(pageEl, page, kind, effectiveOffset, resolvedPage) : 0; const footerAnchorContainerOffsetY = kind === 'footer' ? footerAnchorPageOriginY - effectiveOffset : 0; // For footers, calculate offset to push content to bottom of container @@ -2452,7 +2468,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: kind, - pageNumberText: page.numberText, + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2622,6 +2638,7 @@ export class DomPainter { } private patchPage(state: PageDomState, page: Page, pageSize: { w: number; h: number }, pageIndex: number): void { + const resolvedPage = this.getResolvedPage(pageIndex); const pageEl = state.element; applyStyles(pageEl, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles())); this.applySemanticPageOverrides(pageEl); @@ -2638,7 +2655,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: 'body', - pageNumberText: page.numberText, + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2716,7 +2733,7 @@ export class DomPainter { }); state.fragments = nextFragments; - this.renderDecorationsForPage(pageEl, page, pageIndex); + this.renderDecorationsForPage(pageEl, page, pageIndex, resolvedPage); } /** @@ -2776,6 +2793,7 @@ export class DomPainter { if (!this.doc) { throw new Error('DomPainter.createPageState requires a document'); } + const resolvedPage = this.getResolvedPage(pageIndex); const el = this.doc.createElement('div'); el.classList.add(CLASS_NAMES.page); applyStyles(el, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles())); @@ -2786,6 +2804,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: 'body', + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2811,7 +2830,7 @@ export class DomPainter { }; }); - this.renderDecorationsForPage(el, page, pageIndex); + this.renderDecorationsForPage(el, page, pageIndex, resolvedPage); return { element: el, fragments: fragmentStates }; }