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 ce6869b26d..130af9f3cf 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1689,7 +1689,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); @@ -2200,6 +2200,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())); @@ -2210,7 +2211,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); } @@ -2220,7 +2221,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: 'body', - pageNumberText: page.numberText, + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2234,9 +2235,8 @@ export class DomPainter { this.renderFragment(fragment, contextBase, sdtBoundary, betweenBorderFlags.get(index), resolvedItem), ); }); - this.renderDecorationsForPage(el, page, pageIndex); - this.renderColumnSeparators(el, page, width, height); - + this.renderDecorationsForPage(el, page, pageIndex, resolvedPage); + this.renderColumnSeparators(el, page, width, height, resolvedPage); return el; } @@ -2258,18 +2258,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; @@ -2317,14 +2317,23 @@ export class DomPainter { } } - private renderColumnSeparators(pageEl: HTMLElement, page: Page, pageWidth: number, pageHeight: number): void { + private renderColumnSeparators( + pageEl: HTMLElement, + page: Page, + pageWidth: number, + pageHeight: number, + resolvedPage?: ResolvedPage | null, + ): void { if (!this.doc) return; - if (!page.margins) return; + pageEl.querySelectorAll('[data-superdoc-column-separator="true"]').forEach((separator) => separator.remove()); - const leftMargin = page.margins.left ?? 0; - const rightMargin = page.margins.right ?? 0; - const topMargin = page.margins.top ?? 0; - const bottomMargin = page.margins.bottom ?? 0; + const pageMargins = resolvedPage?.margins ?? page.margins; + if (!pageMargins) return; + + const leftMargin = pageMargins.left ?? 0; + const rightMargin = pageMargins.right ?? 0; + const topMargin = pageMargins.top ?? 0; + const bottomMargin = pageMargins.bottom ?? 0; const contentWidth = pageWidth - leftMargin - rightMargin; // Prefer columnRegions (per-region configs for pages with continuous @@ -2356,6 +2365,7 @@ export class DomPainter { for (const separatorX of separatorPositions) { const separatorEl = this.doc.createElement('div'); + separatorEl.dataset.superdocColumnSeparator = 'true'; separatorEl.style.position = 'absolute'; separatorEl.style.left = `${separatorX}px`; @@ -2403,10 +2413,15 @@ export class DomPainter { return separatorPositions; } - 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); } /** @@ -2444,17 +2459,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 = @@ -2465,11 +2482,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) { @@ -2482,7 +2506,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 @@ -2522,7 +2547,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 @@ -2547,7 +2572,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: kind, - pageNumberText: page.numberText, + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2719,6 +2744,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); @@ -2735,7 +2761,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: 'body', - pageNumberText: page.numberText, + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2813,7 +2839,8 @@ export class DomPainter { }); state.fragments = nextFragments; - this.renderDecorationsForPage(pageEl, page, pageIndex); + this.renderDecorationsForPage(pageEl, page, pageIndex, resolvedPage); + this.renderColumnSeparators(pageEl, page, pageSize.w, pageSize.h, resolvedPage); } /** @@ -2873,6 +2900,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())); @@ -2883,6 +2911,7 @@ export class DomPainter { pageNumber: page.number, totalPages: this.totalPages, section: 'body', + pageNumberText: resolvedPage?.numberText ?? page.numberText, pageIndex, }; @@ -2908,9 +2937,8 @@ export class DomPainter { }; }); - this.renderDecorationsForPage(el, page, pageIndex); - this.renderColumnSeparators(el, page, pageSize.w, pageSize.h); - + this.renderDecorationsForPage(el, page, pageIndex, resolvedPage); + this.renderColumnSeparators(el, page, pageSize.w, pageSize.h, resolvedPage); return { element: el, fragments: fragmentStates }; }