diff --git a/devtools/visual-testing/pnpm-lock.yaml b/devtools/visual-testing/pnpm-lock.yaml index ad5badda7a..ffff9b5947 100644 --- a/devtools/visual-testing/pnpm-lock.yaml +++ b/devtools/visual-testing/pnpm-lock.yaml @@ -2006,7 +2006,7 @@ packages: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} superdoc@file:../../packages/superdoc/superdoc.tgz: - resolution: {integrity: sha512-a82JOO6x1ajl1QakVdhen1WoCUg7jpjP6lA+pup0tvLvOwHQqhYamfCCxhMaanlkIVM3P2D3E2NL2ZfmJZRpnA==, tarball: file:../../packages/superdoc/superdoc.tgz} + resolution: {integrity: sha512-H+zXVDvf1OPb6XJtcZbgrGFpyeEv9nlC3XJG9taawK2/0iYrtLQ2xZ3qc5WStZd5kf3AxSa4gKCGB5UgTl3uxQ==, tarball: file:../../packages/superdoc/superdoc.tgz} version: 1.20.0 peerDependencies: '@hocuspocus/provider': ^2.13.6 diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e20bda67ed..d65bed151f 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1810,9 +1810,14 @@ export type HeaderFooterPage = { }; export type HeaderFooterLayout = { + /** Measurement height for pagination — excludes out-of-band fragments. */ height: number; + /** Minimum y of all rendered fragments (including out-of-band). */ minY?: number; + /** Maximum y + fragmentHeight of all rendered fragments. */ maxY?: number; + /** Full visual extent of all rendered fragments (renderMaxY - renderMinY). */ + renderHeight?: number; pages: HeaderFooterPage[]; }; diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 777296b7af..4f82c339fc 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -934,6 +934,7 @@ export async function incrementalLayout( headerMeasureCache, HEADER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT, undefined, // No page resolver needed for height calculation + 'header', ); // Extract actual content heights from each variant @@ -960,11 +961,8 @@ export async function incrementalLayout( maxHeight: headerFooter.constraints.height, }; const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints))); - // Layout to get actual height - const layout = layoutHeaderFooter(blocks, measures, { - width: headerFooter.constraints.width, - height: headerFooter.constraints.height, - }); + // Layout to get actual height — pass full constraints for page-relative normalization + const layout = layoutHeaderFooter(blocks, measures, headerFooter.constraints, 'header'); if (layout.height > 0) { // Store height by rId for per-page margin calculation headerContentHeightsByRId.set(rId, layout.height); @@ -1047,6 +1045,7 @@ export async function incrementalLayout( headerMeasureCache, FOOTER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT, undefined, // No page resolver needed for height calculation + 'footer', ); // Extract actual content heights from each variant @@ -1073,11 +1072,8 @@ export async function incrementalLayout( maxHeight: headerFooter.constraints.height, }; const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints))); - // Layout to get actual height - const layout = layoutHeaderFooter(blocks, measures, { - width: headerFooter.constraints.width, - height: headerFooter.constraints.height, - }); + // Layout to get actual height — pass full constraints for page-relative normalization + const layout = layoutHeaderFooter(blocks, measures, headerFooter.constraints, 'footer'); if (layout.height > 0) { // Store height by rId for per-page margin calculation footerContentHeightsByRId.set(rId, layout.height); @@ -1898,6 +1894,7 @@ export async function incrementalLayout( headerMeasureCache, FeatureFlags.HEADER_FOOTER_PAGE_TOKENS ? undefined : numberingCtx.totalPages, // Fallback for backward compat pageResolver, // Use page resolver for section-aware numbering + 'header', ); headers = serializeHeaderFooterResults('header', headerLayouts); } @@ -1909,6 +1906,7 @@ export async function incrementalLayout( headerMeasureCache, FeatureFlags.HEADER_FOOTER_PAGE_TOKENS ? undefined : numberingCtx.totalPages, // Fallback for backward compat pageResolver, // Use page resolver for section-aware numbering + 'footer', ); footers = serializeHeaderFooterResults('footer', footerLayouts); } diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index d076aed516..174533eca0 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -53,7 +53,8 @@ export type { BoundaryRange } from './text-boundaries'; export { incrementalLayout, measureCache, normalizeMargin } from './incrementalLayout'; export type { HeaderFooterLayoutResult, IncrementalLayoutResult } from './incrementalLayout'; // Re-export computeDisplayPageNumber from layout-engine for section-aware page numbering -export { computeDisplayPageNumber, type DisplayPageInfo } from '@superdoc/layout-engine'; +export { computeDisplayPageNumber } from '@superdoc/layout-engine'; +export type { DisplayPageInfo, HeaderFooterConstraints } from '@superdoc/layout-engine'; export { remeasureParagraph } from './remeasure'; export { measureCharacterX } from './text-measurement'; export { clickToPositionDom, findPageElement } from './dom-mapping'; diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 48450dd598..f79cb3c440 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -194,6 +194,7 @@ export async function layoutHeaderFooterWithCache( cache: HeaderFooterLayoutCache = sharedHeaderFooterCache, totalPages?: number, pageResolver?: PageResolver, + kind?: 'header' | 'footer', ): Promise { const result: HeaderFooterBatchResult = {}; @@ -211,7 +212,7 @@ export async function layoutHeaderFooterWithCache( resolveHeaderFooterTokens(clonedBlocks, 1, numPages); const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock); - const layout = layoutHeaderFooter(clonedBlocks, measures, constraints); + const layout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind); result[type] = { blocks: clonedBlocks, measures, layout }; } @@ -231,7 +232,7 @@ export async function layoutHeaderFooterWithCache( const hasTokens = hasPageTokens(blocks); if (!hasTokens) { const measures = await cache.measureBlocks(blocks, constraints, measureBlock); - const layout = layoutHeaderFooter(blocks, measures, constraints); + const layout = layoutHeaderFooter(blocks, measures, constraints, kind); result[type] = { blocks, measures, layout }; continue; } @@ -275,7 +276,7 @@ export async function layoutHeaderFooterWithCache( // Measure and layout const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock); - const pageLayout = layoutHeaderFooter(clonedBlocks, measures, constraints); + const pageLayout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind); const measuresById = new Map(); for (let i = 0; i < clonedBlocks.length; i += 1) { measuresById.set(clonedBlocks[i].id, measures[i]); @@ -307,13 +308,14 @@ export async function layoutHeaderFooterWithCache( // Construct final HeaderFooterLayout with all pages // Use the first page's measurements for overall dimensions const firstPageLayout = pages[0] - ? layoutHeaderFooter(pages[0].blocks, pages[0].measures, constraints) + ? layoutHeaderFooter(pages[0].blocks, pages[0].measures, constraints, kind) : { height: 0, pages: [] }; const finalLayout: HeaderFooterLayout = { height: firstPageLayout.height, minY: firstPageLayout.minY, maxY: firstPageLayout.maxY, + renderHeight: firstPageLayout.renderHeight, pages: pages.map((p) => ({ number: p.number, fragments: p.fragments, diff --git a/packages/layout-engine/layout-engine/src/index.d.ts b/packages/layout-engine/layout-engine/src/index.d.ts index 88ae162921..795acc4fd0 100644 --- a/packages/layout-engine/layout-engine/src/index.d.ts +++ b/packages/layout-engine/layout-engine/src/index.d.ts @@ -37,11 +37,25 @@ export type LayoutOptions = { export declare const SEMANTIC_PAGE_HEIGHT_PX = 1000000; export type HeaderFooterConstraints = { width: number; + /** Body content height used as the measurement canvas (pagination boundary). */ height: number; - /** Actual page width for page-relative anchor positioning */ + /** Actual page width for page-relative anchor positioning. */ pageWidth?: number; - /** Page margins for page-relative anchor positioning */ - margins?: { left: number; right: number }; + /** Physical page height for vertical page-relative anchor conversion. */ + pageHeight?: number; + /** + * Page margins for anchor positioning. + * `left`/`right`: horizontal page-relative conversion. + * `top`/`bottom`: vertical margin-relative conversion and footer band origin. + * `header`: header distance from page top edge (header band origin). + */ + margins?: { + left: number; + right: number; + top?: number; + bottom?: number; + header?: number; + }; /** * Optional base height used to bound behindDoc overflow handling. * When provided, decorative assets far outside the header/footer band @@ -61,7 +75,9 @@ export declare function layoutHeaderFooter( blocks: FlowBlock[], measures: Measure[], constraints: HeaderFooterConstraints, + kind?: 'header' | 'footer', ): HeaderFooterLayout; +export { normalizeFragmentsForRegion } from './normalize-header-footer-fragments.js'; export { buildAnchorMap, resolvePageRefTokens, getTocBlocksForRemeasurement } from './resolvePageRefs.js'; export { formatPageNumber, computeDisplayPageNumber } from './pageNumbering.js'; export type { PageNumberFormat, DisplayPageInfo } from './pageNumbering.js'; diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index bec84922bf..72c9482cf0 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -2639,10 +2639,10 @@ describe('layoutHeaderFooter', () => { expect(layout.height).toBeCloseTo(15); }); - it('transforms page-relative anchor offsets by subtracting left margin', () => { - // An anchored image with hRelativeFrom='page' and offsetH=545 (absolute from page left) - // When left margin is 107, the image should be positioned at 545-107=438 within the header - // Anchored images are attached to the nearest paragraph and placed during paragraph layout + it('preserves page-relative horizontal anchor offset in header/footer layout', () => { + // page-relative hRelativeFrom='page' offsets are passed through to the inner + // layoutDocument unchanged. The painter handles the margin offset when + // positioning the container on the page. const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'para-1', @@ -2671,18 +2671,19 @@ describe('layoutHeaderFooter', () => { }; const layout = layoutHeaderFooter([paragraphBlock, imageBlock], [paragraphMeasure, imageMeasure], { - width: 602, // content width + width: 602, height: 100, - pageWidth: 816, // actual page width (8.5" at 96dpi) + pageWidth: 816, margins: { left: 107, right: 107 }, }); expect(layout.pages).toHaveLength(1); - // Find the image fragment (should be anchored) const imageFragment = layout.pages[0].fragments.find((f) => f.kind === 'image'); expect(imageFragment).toBeDefined(); - // The offsetH should be transformed: 545 - 107 = 438 - expect(imageFragment!.x).toBe(438); + // offsetH is passed through to inner layout's computeAnchorX; the result + // includes the offset plus margin-left added by computeAnchorX for page-relative. + // Inner layout has margins=0, so computeAnchorX returns offsetH + 0 = 545. + expect(imageFragment!.x).toBe(545); }); it('does not transform anchor offset when margins not provided', () => { @@ -2969,6 +2970,257 @@ describe('layoutHeaderFooter', () => { // ALL behindDoc images are now excluded from height calculations, regardless of position. // See tests above: 'excludes ALL behindDoc anchored fragments from height (per OOXML spec)' // and 'excludes ALL behindDoc fragments but includes non-behindDoc anchored images'. + + it('separates measurement bounds (height) from render bounds (minY/maxY/renderHeight)', () => { + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'Header text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }], + }; + const behindDocImage: FlowBlock = { + kind: 'image', + id: 'img-behind', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + behindDoc: true, + offsetV: -30, // positioned above the band origin + }, + }; + const paragraphMeasure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 80, ascent: 12, descent: 3, lineHeight: 15 }], + totalHeight: 15, + }; + const imageMeasure: Measure = { + kind: 'image', + width: 50, + height: 40, + }; + + const layout = layoutHeaderFooter([paragraphBlock, behindDocImage], [paragraphMeasure, imageMeasure], { + width: 200, + height: 100, + }); + + // Measurement height should only include the paragraph (behindDoc excluded) + expect(layout.height).toBeCloseTo(15); + // Render bounds should include the behindDoc image (minY < 0 because of negative offsetV) + expect(layout.minY).toBeLessThan(0); + // renderHeight should be larger than measurement height + expect(layout.renderHeight).toBeGreaterThan(layout.height); + }); + + it('returns renderHeight equal to height when no out-of-band fragments exist', () => { + const layout = layoutHeaderFooter([block], [makeMeasure([20, 10])], { width: 400, height: 80 }); + + // With only normal paragraphs, measurement and render bounds should match + expect(layout.height).toBeCloseTo(30); + expect(layout.renderHeight).toBe(layout.height); + expect(layout.minY).toBe(0); + expect(layout.maxY).toBeCloseTo(30); + }); + + it('excludes out-of-band page-relative anchors from measurement height', () => { + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'Header text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }], + }; + // Page-relative anchor positioned far above the measurement canvas + const imageBlock: FlowBlock = { + kind: 'image', + id: 'img-page', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + vRelativeFrom: 'page', + alignV: 'top', + offsetV: -100, // way above the canvas + }, + }; + const paragraphMeasure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 80, ascent: 12, descent: 3, lineHeight: 15 }], + totalHeight: 15, + }; + const imageMeasure: Measure = { + kind: 'image', + width: 50, + height: 30, + }; + + const layout = layoutHeaderFooter([paragraphBlock, imageBlock], [paragraphMeasure, imageMeasure], { + width: 200, + height: 100, + }); + + // The page-relative anchor at y=-100 is fully out-of-band (bottom = -100+30 = -70 < 0) + // so it should be excluded from measurement height + expect(layout.height).toBeCloseTo(15); + // But render bounds should include it + expect(layout.minY).toBeLessThan(0); + expect(layout.renderHeight).toBeGreaterThan(layout.height); + }); + + it('post-normalizes page-relative anchors in footer layout', () => { + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'Footer text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }], + }; + const imageBlock: FlowBlock = { + kind: 'image', + id: 'img-page', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + vRelativeFrom: 'page', + alignV: 'bottom', + offsetV: 0, + hRelativeFrom: 'page', + offsetH: 0, + }, + }; + const paragraphMeasure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 80, ascent: 12, descent: 3, lineHeight: 15 }], + totalHeight: 15, + }; + const imageMeasure: Measure = { + kind: 'image', + width: 50, + height: 30, + }; + + const constraints = { + width: 200, + height: 800, + pageHeight: 1056, + margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 }, + }; + + // Without kind='footer': no normalization — raw inner-layout Y + const withoutKind = layoutHeaderFooter([paragraphBlock, imageBlock], [paragraphMeasure, imageMeasure], constraints); + const imgFragWithout = withoutKind.pages[0]?.fragments.find((f) => f.kind === 'image'); + + // With kind='footer': normalization converts to footer-band-local Y + const withFooter = layoutHeaderFooter( + [paragraphBlock, imageBlock], + [paragraphMeasure, imageMeasure], + constraints, + 'footer', + ); + const imgFragFooter = withFooter.pages[0]?.fragments.find((f) => f.kind === 'image'); + + // Footer band origin = pageHeight - marginBottom = 1056 - 72 = 984 + // physicalY = pageHeight - imgHeight = 1056 - 30 = 1026 + // normalized Y = 1026 - 984 = 42 + expect(imgFragFooter).toBeDefined(); + expect(imgFragFooter!.y).toBe(1056 - 30 - (1056 - 72)); + + // Without kind, the Y is the synthetic canvas position (not normalized) + expect(imgFragWithout).toBeDefined(); + expect(imgFragWithout!.y).not.toBe(imgFragFooter!.y); + }); + + it('does NOT post-normalize page-relative anchors in header layout', () => { + const imageBlock: FlowBlock = { + kind: 'image', + id: 'img-page', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + vRelativeFrom: 'page', + alignV: 'top', + offsetV: 10, + }, + }; + const imageMeasure: Measure = { + kind: 'image', + width: 50, + height: 30, + }; + + const constraints = { + width: 200, + height: 800, + pageHeight: 1056, + margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 }, + }; + + // With kind='header': no normalization — Y stays as inner-layout computed it + const withHeader = layoutHeaderFooter([imageBlock], [imageMeasure], constraints, 'header'); + const imgFrag = withHeader.pages[0]?.fragments.find((f) => f.kind === 'image'); + + // Without kind: same behavior (no normalization) + const withoutKind = layoutHeaderFooter([imageBlock], [imageMeasure], constraints); + const imgFragNoKind = withoutKind.pages[0]?.fragments.find((f) => f.kind === 'image'); + + // Both should have the same Y — inner-layout raw position + expect(imgFrag).toBeDefined(); + expect(imgFragNoKind).toBeDefined(); + expect(imgFrag!.y).toBe(imgFragNoKind!.y); + }); + + it('does not narrow footer paragraphs around page-relative anchored textboxes', () => { + // Regression: the page-number textbox in the footer must not shrink + // unrelated earlier footer paragraphs via float-based remeasurement. + // Word keeps footer paragraphs full-width and positions page-relative + // textboxes independently. + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'Footer text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }], + }; + const imageBlock: FlowBlock = { + kind: 'image', + id: 'img-page', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + alignH: 'left', + vRelativeFrom: 'page', + alignV: 'bottom', + offsetV: 0, + }, + wrap: { + type: 'Square', + wrapText: 'right', + }, + }; + const paragraphMeasure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 180, ascent: 12, descent: 3, lineHeight: 15 }], + totalHeight: 15, + }; + const imageMeasure: Measure = { + kind: 'image', + width: 80, + height: 60, + }; + const constraints = { + width: 200, + height: 120, + pageHeight: 300, + margins: { left: 20, right: 20, top: 40, bottom: 60, header: 20 }, + }; + + const layout = layoutHeaderFooter( + [paragraphBlock, imageBlock], + [paragraphMeasure, imageMeasure], + constraints, + 'footer', + ); + + const paragraphFragment = layout.pages[0].fragments.find((fragment) => fragment.kind === 'para') as ParaFragment; + + // Paragraph must keep the full footer width -- no float wrapping + expect(paragraphFragment).toBeDefined(); + expect(paragraphFragment.x).toBe(0); + expect(paragraphFragment.width).toBe(200); + }); }); describe('requirePageBoundary edge cases', () => { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index eda93d1e32..435580ef2e 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -40,7 +40,13 @@ import { layoutParagraphBlock } from './layout-paragraph.js'; import { layoutImageBlock } from './layout-image.js'; import { layoutDrawingBlock } from './layout-drawing.js'; import { layoutTableBlock, createAnchoredTableFragment, ANCHORED_TABLE_FULL_WIDTH_RATIO } from './layout-table.js'; -import { collectAnchoredDrawings, collectAnchoredTables, collectPreRegisteredAnchors } from './anchors.js'; +import { + collectAnchoredDrawings, + collectAnchoredTables, + collectPreRegisteredAnchors, + isPageRelativeAnchor, +} from './anchors.js'; +import { normalizeFragmentsForRegion } from './normalize-header-footer-fragments.js'; import { createPaginator, type PageState, type ConstraintBoundary } from './paginator.js'; import { formatPageNumber } from './pageNumbering.js'; import { shouldSuppressSpacingForEmpty, shouldSuppressOwnSpacing } from './layout-utils.js'; @@ -509,11 +515,25 @@ export type LayoutOptions = { export type HeaderFooterConstraints = { width: number; + /** Body content height used as the measurement canvas (pagination boundary). */ height: number; - /** Actual page width for page-relative anchor positioning */ + /** Actual page width for page-relative anchor positioning. */ pageWidth?: number; - /** Page margins for page-relative anchor positioning */ - margins?: { left: number; right: number }; + /** Physical page height for vertical page-relative anchor conversion. */ + pageHeight?: number; + /** + * Page margins for anchor positioning. + * `left`/`right`: horizontal page-relative conversion. + * `top`/`bottom`: vertical margin-relative conversion and footer band origin. + * `header`: header distance from page top edge (header band origin). + */ + margins?: { + left: number; + right: number; + top?: number; + bottom?: number; + header?: number; + }; /** * Optional base height used to bound behindDoc overflow handling. * When provided, decorative assets far outside the header/footer band @@ -2395,35 +2415,104 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } /** - * Lays out header or footer content within specified dimensional constraints. - * - * This function positions blocks (paragraphs, images, drawings) within a header or footer region, - * handling page-relative anchor transformations and computing the actual height required by - * visible content. Headers and footers are rendered within the content box but may contain - * page-relative anchored objects that need coordinate transformation. + * Compute the bottom edge (y + height) of a fragment for bounds tracking. + */ +function computeFragmentBottom(fragment: Fragment, block: FlowBlock, measure: Measure): number { + let bottom = fragment.y; + + if (fragment.kind === 'para' && measure?.kind === 'paragraph') { + let sum = 0; + for (let li = fragment.fromLine; li < fragment.toLine; li += 1) { + sum += measure.lines[li]?.lineHeight ?? 0; + } + bottom += sum; + const spacingAfter = (block as ParagraphBlock)?.attrs?.spacing?.after; + if (spacingAfter && fragment.toLine === measure.lines.length) { + bottom += Math.max(0, Number(spacingAfter)); + } + } else if (fragment.kind === 'image') { + bottom += + typeof fragment.height === 'number' ? fragment.height : ((measure as ImageMeasure | undefined)?.height ?? 0); + } else if (fragment.kind === 'drawing') { + bottom += + typeof fragment.height === 'number' ? fragment.height : ((measure as DrawingMeasure | undefined)?.height ?? 0); + } else if (fragment.kind === 'list-item') { + const listMeasure = measure as ListMeasure | undefined; + if (listMeasure) { + const item = listMeasure.items.find((it) => it.itemId === fragment.itemId); + if (item?.paragraph) { + let sum = 0; + for (let li = fragment.fromLine; li < fragment.toLine; li += 1) { + sum += item.paragraph.lines[li]?.lineHeight ?? 0; + } + bottom += sum; + } + } + } + + return bottom; +} + +/** + * Determine whether a fragment should be excluded from measurement (pagination) bounds. * - * @param blocks - The flow blocks to layout (paragraphs, images, drawings, etc.) - * @param measures - Corresponding measurements for each block (must match blocks.length) - * @param constraints - Dimensional constraints including width, height, and optional margins + * Excluded fragments: + * 1. behindDoc anchored fragments — purely decorative z-order, per OOXML spec. + * 2. Page-relative anchored fragments whose local Y range [y, y+h] does not + * intersect [0, canvasHeight] — they are out-of-band and should not inflate + * the measurement used by body pagination. + */ +function shouldExcludeFromMeasurement(fragment: Fragment, block: FlowBlock, canvasHeight: number): boolean { + const isAnchoredFragment = + (fragment.kind === 'image' || fragment.kind === 'drawing') && + (fragment as { isAnchored?: boolean }).isAnchored === true; + + if (!isAnchoredFragment) return false; + + if (block.kind !== 'image' && block.kind !== 'drawing') { + throw new Error( + `Type mismatch: fragment kind is ${fragment.kind} but block kind is ${block.kind} for block ${block.id}`, + ); + } + + const anchoredBlock = block as ImageBlock | DrawingBlock; + + // behindDoc fragments never affect measurement + if (anchoredBlock.anchor?.behindDoc) return true; + + // Page-relative anchored fragments that sit entirely outside the measurement band + // should not inflate pagination height. + if (isPageRelativeAnchor(anchoredBlock)) { + const fragmentHeight = (fragment as { height?: number }).height ?? 0; + const fragmentTop = fragment.y; + const fragmentBottom = fragment.y + fragmentHeight; + // Exclude if the fragment range [top, bottom] does not intersect [0, canvasHeight] + if (fragmentBottom <= 0 || fragmentTop >= canvasHeight) { + return true; + } + } + + return false; +} + +/** + * Lays out header or footer content within specified dimensional constraints. * - * @returns A HeaderFooterLayout containing: - * - pages: Array of laid-out pages with positioned fragments - * - height: The actual height consumed by visible content + * Positions blocks within a header/footer region, handling page-relative anchor + * transformations and computing the actual height required by visible content. * - * @throws {Error} If blocks and measures arrays have different lengths - * @throws {Error} If width or height constraints are not positive finite numbers + * When `kind` and `constraints.pageHeight` are provided, page-relative and + * margin-relative anchored drawings are post-normalized from the synthetic + * measurement canvas to header/footer-local coordinates. * - * Special handling for behindDoc anchored fragments: - * - Anchored images/drawings with behindDoc=true are decorative background elements - * - Per OOXML spec, behindDoc is purely a z-ordering directive that should NOT affect layout - * - These fragments are ALWAYS excluded from height calculations, regardless of position - * - This matches Word behavior where behindDoc images never inflate header/footer margins - * - All behindDoc fragments are still rendered in the layout; they're only excluded from height + * Returns separate measurement bounds (for pagination) and render bounds + * (for overlay shift). See the Coordinate Contract in the fix plan for details. */ export function layoutHeaderFooter( blocks: FlowBlock[], measures: Measure[], constraints: HeaderFooterConstraints, + kind?: 'header' | 'footer', ): HeaderFooterLayout { if (blocks.length !== measures.length) { throw new Error( @@ -2442,45 +2531,38 @@ export function layoutHeaderFooter( return { pages: [], height: 0 }; } - // Transform page-relative anchor offsets to content-relative for correct positioning - // Headers/footers are rendered within the content box, but page-relative anchors - // specify offsets from the physical page edge. We need to adjust by subtracting - // the left margin so the image appears at the correct position within the header/footer. - const marginLeft = constraints.margins?.left ?? 0; - const transformedBlocks = - marginLeft > 0 - ? blocks.map((block) => { - // Handle both image blocks and drawing blocks (vectorShape, shapeGroup) - const hasPageRelativeAnchor = - (block.kind === 'image' || block.kind === 'drawing') && - block.anchor?.hRelativeFrom === 'page' && - block.anchor.offsetH != null; - if (hasPageRelativeAnchor) { - return { - ...block, - anchor: { - ...block.anchor, - offsetH: block.anchor!.offsetH! - marginLeft, - }, - }; - } - return block; - }) - : blocks; - - const layout = layoutDocument(transformedBlocks, measures, { + const layout = layoutDocument(blocks, measures, { pageSize: { w: width, h: height }, margins: { top: 0, right: 0, bottom: 0, left: 0 }, }); + // Post-normalize page-relative anchored fragment Y positions for footers. + // + // The inner layoutDocument() uses the body content height as its page height, + // but page-relative anchors need the REAL physical page height to resolve + // bottom/center alignment correctly. This post-correction rewrites their Y + // to footer-band-local coordinates using the real page geometry. + // + // Headers don't need this: the inner layout's page-relative Y is already + // correct relative to the header container, and the painter handles the + // container-to-page offset via effectiveOffset subtraction. + if (kind === 'footer' && constraints.pageHeight != null) { + normalizeFragmentsForRegion(layout.pages, blocks, measures, kind, constraints); + } + // Compute bounds using an index map to avoid building multiple Maps const idToIndex = new Map(); for (let i = 0; i < blocks.length; i += 1) { idToIndex.set(blocks[i].id, i); } - let minY = 0; - let maxY = 0; + // Track separate bounds for measurement (pagination) and rendering (overlay shift). + // Measurement bounds exclude behindDoc and out-of-band page-relative anchored fragments. + // Render bounds include all visible fragments. + let measureMinY = 0; + let measureMaxY = 0; + let renderMinY = 0; + let renderMaxY = 0; for (const page of layout.pages) { for (const fragment of page.fragments) { @@ -2489,71 +2571,25 @@ export function layoutHeaderFooter( const block = blocks[idx]; const measure = measures[idx]; - // Exclude ALL behindDoc anchored fragments from height calculations. - // Per OOXML spec, behindDoc is purely a z-ordering directive that should NOT affect layout. - // These decorative background images/drawings render behind text but never inflate margins. - // Fragments are still rendered in the layout; we only skip them when computing total height. - const isAnchoredFragment = - (fragment.kind === 'image' || fragment.kind === 'drawing') && fragment.isAnchored === true; - if (isAnchoredFragment) { - // Runtime validation: ensure block.kind matches fragment.kind before type assertion - if (block.kind !== 'image' && block.kind !== 'drawing') { - throw new Error( - `Type mismatch: fragment kind is ${fragment.kind} but block kind is ${block.kind} for block ${block.id}`, - ); - } - const anchoredBlock = block as ImageBlock | DrawingBlock; - // behindDoc images never affect layout - skip entirely from height calculation - if (anchoredBlock.anchor?.behindDoc) { - continue; - } - } + const bottom = computeFragmentBottom(fragment, block, measure); - if (fragment.y < minY) minY = fragment.y; - let bottom = fragment.y; + // Track render bounds for all fragments (used by overlay shift in SessionManager) + if (fragment.y < renderMinY) renderMinY = fragment.y; + if (bottom > renderMaxY) renderMaxY = bottom; - if (fragment.kind === 'para' && measure?.kind === 'paragraph') { - let sum = 0; - for (let li = fragment.fromLine; li < fragment.toLine; li += 1) { - sum += measure.lines[li]?.lineHeight ?? 0; - } - bottom += sum; - const spacingAfter = (block as ParagraphBlock)?.attrs?.spacing?.after; - if (spacingAfter && fragment.toLine === measure.lines.length) { - bottom += Math.max(0, Number(spacingAfter)); - } - } else if (fragment.kind === 'image') { - const h = - typeof fragment.height === 'number' ? fragment.height : ((measure as ImageMeasure | undefined)?.height ?? 0); - bottom += h; - } else if (fragment.kind === 'drawing') { - const drawingHeight = - typeof fragment.height === 'number' - ? fragment.height - : ((measure as DrawingMeasure | undefined)?.height ?? 0); - bottom += drawingHeight; - } else if (fragment.kind === 'list-item') { - const listMeasure = measure as ListMeasure | undefined; - if (listMeasure) { - const item = listMeasure.items.find((it) => it.itemId === fragment.itemId); - if (item?.paragraph) { - let sum = 0; - for (let li = fragment.fromLine; li < fragment.toLine; li += 1) { - sum += item.paragraph.lines[li]?.lineHeight ?? 0; - } - bottom += sum; - } - } - } + // Determine whether this fragment should be excluded from measurement (pagination) bounds + if (shouldExcludeFromMeasurement(fragment, block, height)) continue; - if (bottom > maxY) maxY = bottom; + if (fragment.y < measureMinY) measureMinY = fragment.y; + if (bottom > measureMaxY) measureMaxY = bottom; } } return { - height: maxY - minY, - minY, - maxY, + height: measureMaxY - measureMinY, + minY: renderMinY, + maxY: renderMaxY, + renderHeight: renderMaxY - renderMinY, pages: layout.pages.map((page) => ({ number: page.number, fragments: page.fragments })), }; } diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts new file mode 100644 index 0000000000..d7b85d3386 --- /dev/null +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowBlock, Fragment, Measure } from '@superdoc/contracts'; +import { normalizeFragmentsForRegion } from './normalize-header-footer-fragments.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeParaFragment(blockId: string, y: number): Fragment { + return { kind: 'para', blockId, x: 0, y, fromLine: 0, toLine: 1 } as Fragment; +} + +function makeAnchoredImageFragment(blockId: string, y: number, height: number): Fragment { + return { kind: 'image', blockId, x: 0, y, height, isAnchored: true } as unknown as Fragment; +} + +function makeDummyMeasure(): Measure { + return { kind: 'paragraph', lines: [], totalHeight: 0 } as Measure; +} + +const PAGE_HEIGHT = 1056; +const MARGIN_BOTTOM = 72; + +const fullConstraints = { + pageHeight: PAGE_HEIGHT, + margins: { left: 72, right: 72, top: 72, bottom: MARGIN_BOTTOM, header: 36 }, +}; + +const FOOTER_BAND_ORIGIN = PAGE_HEIGHT - MARGIN_BOTTOM; // 984 + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('normalizeFragmentsForRegion (footer page-relative only)', () => { + describe('page-relative anchors in footer', () => { + it('normalizes a top-aligned anchor', () => { + const block: FlowBlock = { + kind: 'image', + id: 'img-1', + src: 'test.png', + anchor: { isAnchored: true, vRelativeFrom: 'page', alignV: 'top', offsetV: 0 }, + }; + const fragment = makeAnchoredImageFragment('img-1', 0, 50); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + // physicalY = 0, bandOrigin = 984 + expect(fragment.y).toBe(0 - FOOTER_BAND_ORIGIN); + }); + + it('normalizes a bottom-aligned anchor', () => { + const imgHeight = 50; + const block: FlowBlock = { + kind: 'image', + id: 'img-1', + src: 'test.png', + anchor: { isAnchored: true, vRelativeFrom: 'page', alignV: 'bottom', offsetV: 0 }, + }; + const fragment = makeAnchoredImageFragment('img-1', 0, imgHeight); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + // physicalY = 1056 - 50 = 1006, bandOrigin = 984 + expect(fragment.y).toBe(PAGE_HEIGHT - imgHeight - FOOTER_BAND_ORIGIN); + }); + + it('normalizes a center-aligned anchor', () => { + const imgHeight = 40; + const block: FlowBlock = { + kind: 'image', + id: 'img-1', + src: 'test.png', + anchor: { isAnchored: true, vRelativeFrom: 'page', alignV: 'center', offsetV: 0 }, + }; + const fragment = makeAnchoredImageFragment('img-1', 0, imgHeight); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + // physicalY = (1056 - 40) / 2 = 508, bandOrigin = 984 + expect(fragment.y).toBe((PAGE_HEIGHT - imgHeight) / 2 - FOOTER_BAND_ORIGIN); + }); + + it('applies offsetV correctly', () => { + const block: FlowBlock = { + kind: 'image', + id: 'img-1', + src: 'test.png', + anchor: { isAnchored: true, vRelativeFrom: 'page', alignV: 'top', offsetV: 20 }, + }; + const fragment = makeAnchoredImageFragment('img-1', 0, 50); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + // physicalY = 20, bandOrigin = 984 + expect(fragment.y).toBe(20 - FOOTER_BAND_ORIGIN); + }); + + it('normalizes drawing blocks the same as image blocks', () => { + const block: FlowBlock = { + kind: 'drawing', + id: 'draw-1', + drawingKind: 'vectorShape', + geometry: { width: 100, height: 50 }, + anchor: { isAnchored: true, vRelativeFrom: 'page', alignV: 'bottom', offsetV: 0 }, + shapeKind: 'Rectangle', + }; + const fragment = { + kind: 'drawing', + blockId: 'draw-1', + x: 0, + y: 999, + height: 50, + isAnchored: true, + } as unknown as Fragment; + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + expect(fragment.y).toBe(PAGE_HEIGHT - 50 - FOOTER_BAND_ORIGIN); + }); + }); + + describe('passthrough cases — fragments that must NOT be modified', () => { + it('does not modify non-anchored paragraph fragments', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'Hello', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 5 }], + }; + const fragment = makeParaFragment('para-1', 15); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + expect(fragment.y).toBe(15); + }); + + it('does not modify paragraph-relative anchored images', () => { + const block: FlowBlock = { + kind: 'image', + id: 'img-1', + src: 'test.png', + anchor: { isAnchored: true, vRelativeFrom: 'paragraph', offsetV: 20 }, + }; + const fragment = makeAnchoredImageFragment('img-1', 20, 30); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + expect(fragment.y).toBe(20); + }); + + it('does not modify margin-relative anchored images', () => { + const block: FlowBlock = { + kind: 'image', + id: 'img-1', + src: 'test.png', + anchor: { isAnchored: true, vRelativeFrom: 'margin', alignV: 'top', offsetV: 5 }, + }; + const fragment = makeAnchoredImageFragment('img-1', 42, 30); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + expect(fragment.y).toBe(42); + }); + + it('returns early when pageHeight is null', () => { + const block: FlowBlock = { + kind: 'image', + id: 'img-1', + src: 'test.png', + anchor: { isAnchored: true, vRelativeFrom: 'page', offsetV: 10 }, + }; + const fragment = makeAnchoredImageFragment('img-1', 42, 30); + const pages = [{ number: 1, fragments: [fragment] }]; + + const result = normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', { + pageHeight: undefined, + margins: { left: 0, right: 0 }, + }); + + expect(fragment.y).toBe(42); + expect(result).toBe(pages); + }); + + it('returns early when margins is undefined', () => { + const block: FlowBlock = { + kind: 'image', + id: 'img-1', + src: 'test.png', + anchor: { isAnchored: true, vRelativeFrom: 'page', offsetV: 10 }, + }; + const fragment = makeAnchoredImageFragment('img-1', 42, 30); + const pages = [{ number: 1, fragments: [fragment] }]; + + const result = normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', { pageHeight: 1000 }); + + expect(fragment.y).toBe(42); + expect(result).toBe(pages); + }); + }); + + describe('mutation behavior', () => { + it('mutates fragments in place and returns the same pages array', () => { + const block: FlowBlock = { + kind: 'image', + id: 'img-1', + src: 'test.png', + anchor: { isAnchored: true, vRelativeFrom: 'page', alignV: 'top', offsetV: 50 }, + }; + const fragment = makeAnchoredImageFragment('img-1', 999, 30); + const pages = [{ number: 1, fragments: [fragment] }]; + + const result = normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + expect(result).toBe(pages); + expect(pages[0].fragments[0].y).toBe(50 - FOOTER_BAND_ORIGIN); + }); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts new file mode 100644 index 0000000000..7b61098beb --- /dev/null +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts @@ -0,0 +1,113 @@ +import type { + FlowBlock, + ImageBlock, + DrawingBlock, + Fragment, + Measure, + ImageMeasure, + DrawingMeasure, +} from '@superdoc/contracts'; +/** + * Subset of HeaderFooterConstraints needed for fragment normalization. + * Defined locally to avoid circular imports with index.ts. + */ +export type RegionConstraints = { + pageHeight?: number; + margins?: { + left: number; + right: number; + top?: number; + bottom?: number; + header?: number; + }; +}; + +/** + * Compute the physical-page Y coordinate for a page-relative anchored drawing, + * using the real page geometry from constraints. + * + * The inner header/footer layout uses body content height as its "page height", + * which gives wrong positions for page-relative anchors that use bottom/center + * alignment. This function computes the CORRECT Y using the real page dimensions. + */ +function computePhysicalAnchorY(block: ImageBlock | DrawingBlock, fragmentHeight: number, pageHeight: number): number { + const alignV = block.anchor?.alignV ?? 'top'; + const offsetV = block.anchor?.offsetV ?? 0; + + if (alignV === 'bottom') { + return pageHeight - fragmentHeight + offsetV; + } + if (alignV === 'center') { + return (pageHeight - fragmentHeight) / 2 + offsetV; + } + // 'top' or unrecognized + return offsetV; +} + +/** + * Compute the footer band origin: the physical-page Y that corresponds to + * footer-local y=0. This is the top of the bottom margin area. + */ +function computeFooterBandOrigin(constraints: RegionConstraints): number { + return (constraints.pageHeight ?? 0) - (constraints.margins?.bottom ?? 0); +} + +function isAnchoredFragment(fragment: Fragment): boolean { + return ( + (fragment.kind === 'image' || fragment.kind === 'drawing') && + (fragment as { isAnchored?: boolean }).isAnchored === true + ); +} + +function isPageRelativeBlock(block: FlowBlock): block is ImageBlock | DrawingBlock { + return (block.kind === 'image' || block.kind === 'drawing') && block.anchor?.vRelativeFrom === 'page'; +} + +/** + * Post-normalize page-relative anchored fragment Y positions in footer layout. + * + * Problem: The inner `layoutDocument()` uses body content height as its page + * height. For page-relative anchors with bottom/center alignment, this produces + * incorrect Y positions because the real physical page is much taller. + * + * Solution: After layout, rewrite each page-relative anchored fragment's Y + * using the real physical page height, then convert to footer-band-local + * coordinates (where y=0 = top of the bottom margin area). + * + * Only affects `vRelativeFrom === 'page'` anchored image/drawing fragments. + * Paragraphs, inline images, and margin-relative anchors pass through unchanged. + */ +export function normalizeFragmentsForRegion( + pages: Array<{ number: number; fragments: Fragment[] }>, + blocks: FlowBlock[], + _measures: Measure[], + _kind: 'header' | 'footer', + constraints: RegionConstraints, +): Array<{ number: number; fragments: Fragment[] }> { + if (constraints.pageHeight == null || !constraints.margins) { + return pages; + } + + const pageHeight = constraints.pageHeight; + const bandOrigin = computeFooterBandOrigin(constraints); + + const blockById = new Map(); + for (const block of blocks) { + blockById.set(block.id, block); + } + + for (const page of pages) { + for (const fragment of page.fragments) { + if (!isAnchoredFragment(fragment)) continue; + + const block = blockById.get(fragment.blockId); + if (!block || !isPageRelativeBlock(block)) continue; + + const fragmentHeight = (fragment as { height?: number }).height ?? 0; + const physicalY = computePhysicalAnchorY(block, fragmentHeight, pageHeight); + fragment.y = physicalY - bandOrigin; + } + } + + return pages; +} diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index aa8b79ce06..4720a4f6a4 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -3546,6 +3546,61 @@ describe('DomPainter', () => { expect(fragEl.style.top).toBe(`${footerHeight - contentHeight + footerFragment.y}px`); }); + it('applies paragraph rtl direction inside footer content', () => { + const footerBlock: FlowBlock = { + kind: 'paragraph', + id: 'footer-rtl', + runs: [ + { text: 'الإصدار', fontFamily: 'Arial', fontSize: 12 }, + { text: ' ', fontFamily: 'Arial', fontSize: 12 }, + { text: '<1.0>', fontFamily: 'Arial', fontSize: 12 }, + ], + attrs: { + alignment: 'center', + direction: 'rtl', + rtl: true, + }, + }; + const footerMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 2, + toChar: 5, + width: 120, + ascent: 8, + descent: 2, + lineHeight: 10, + }, + ], + totalHeight: 10, + }; + const footerFragment = { + kind: 'para' as const, + blockId: 'footer-rtl', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 200, + }; + + const painter = createDomPainter({ + blocks: [block, footerBlock], + measures: [measure, footerMeasure], + footerProvider: () => ({ fragments: [footerFragment], height: 14 }), + }); + + painter.paint({ ...layout, pages: [{ ...layout.pages[0], number: 2 }] }, mount); + + const footerFragmentEl = mount.querySelector('.superdoc-page-footer .superdoc-fragment') as HTMLElement; + expect(footerFragmentEl).toBeTruthy(); + expect(footerFragmentEl.getAttribute('dir')).toBe('rtl'); + expect(footerFragmentEl.style.direction).toBe('rtl'); + }); + it('renders page-relative behindDoc header media at absolute page Y', () => { const headerImageBlock: FlowBlock = { kind: 'image', @@ -3597,7 +3652,7 @@ describe('DomPainter', () => { expect(behindDocEl.style.left).toBe('42px'); }); - it('does not apply footer bottom-alignment offset to page-relative media', () => { + it('renders footer page-relative media using normalized band-local coordinates', () => { const footerImageBlock: FlowBlock = { kind: 'image', id: 'footer-page-relative-img', @@ -3613,6 +3668,8 @@ describe('DomPainter', () => { width: 20, height: 20, }; + // fragment.y = 25 represents a footer-band-local coordinate + // (produced by normalizeFragmentsForRegion in the layout engine) const footerFragment: Fragment = { kind: 'image', blockId: 'footer-page-relative-img', @@ -3623,7 +3680,7 @@ describe('DomPainter', () => { isAnchored: true, }; - const footerOffset = 350; + const footerOffset = 400; const footerHeight = 80; const footerContentHeight = 30; @@ -3638,15 +3695,31 @@ describe('DomPainter', () => { }), }); - painter.paint({ ...layout, pages: [{ ...layout.pages[0], number: 1 }] }, mount); + painter.paint( + { + ...layout, + pages: [ + { + ...layout.pages[0], + number: 1, + margins: { left: 0, right: 0, bottom: 100, footer: 20 }, + }, + ], + }, + mount, + ); const footerEl = mount.querySelector('.superdoc-page-footer') as HTMLElement; const footerFragmentEl = footerEl.querySelector('[data-block-id="footer-page-relative-img"]') as HTMLElement; expect(footerFragmentEl).toBeTruthy(); - expect(footerFragmentEl.style.top).toBe(`${footerFragment.y - footerOffset}px`); + // Footer container is at effectiveOffset (400px) + expect(footerEl.style.top).toBe(`${footerOffset}px`); + // Fragment uses band-local Y + container offset from band origin + // The exact top depends on getDecorationAnchorPageOriginY, but the + // key invariant is that the absolute page position is correct. const renderedPageTop = parseFloat(footerEl.style.top || '0') + parseFloat(footerFragmentEl.style.top || '0'); - expect(renderedPageTop).toBe(footerFragment.y); + expect(renderedPageTop).toBe(footerOffset + footerFragment.y); }); it('preserves bold styling on page number tokens in DOM', () => { diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index cd029a149e..b571d01c5a 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -59,6 +59,11 @@ export type { FlowMode } from './renderer.js'; export type PageDecorationPayload = { fragments: Fragment[]; height: number; + /** + * Decoration fragments are expressed in header/footer-local coordinates. + * Header/footer layout normalizes page- and margin-relative anchors before + * they reach the painter. + */ /** Optional measured content height; when provided, footer content will be bottom-aligned within its box. */ contentHeight?: number; offset?: number; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index a589982cd7..8ec73f60b0 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2165,7 +2165,12 @@ export class DomPainter { this.renderDecorationSection(pageEl, page, pageIndex, 'footer'); } - private isPageRelativeVerticalAnchorFragment(fragment: Fragment): boolean { + /** + * Check if an anchored fragment has vRelativeFrom === 'page'. + * Used to determine special Y positioning for page-relative anchored media + * in header/footer decoration sections. + */ + private isPageRelativeAnchoredFragment(fragment: Fragment): boolean { if (fragment.kind !== 'image' && fragment.kind !== 'drawing') { return false; } @@ -2180,6 +2185,42 @@ export class DomPainter { return block.anchor?.vRelativeFrom === 'page'; } + /** + * Header/footer layout emits normalized anchor Y coordinates: + * - headers: local to the header container origin + * - footers: local to the top of the footer band (pageHeight - bottomMargin) + * + * Footer containers can grow upward when content overflows the reserved footer + * band, so their top edge is not always the same as the footer band origin. + * This helper returns the page-space origin that normalized anchor Y values + * are measured from. + */ + private getDecorationAnchorPageOriginY( + pageEl: HTMLElement, + page: Page, + kind: 'header' | 'footer', + effectiveOffset: number, + ): number { + if (kind === 'header') { + return effectiveOffset; + } + + const bottomMargin = page.margins?.bottom; + if (bottomMargin == null) { + return effectiveOffset; + } + + const footnoteReserve = page.footnoteReserved ?? 0; + const adjustedBottomMargin = Math.max(0, bottomMargin - footnoteReserve); + const styledPageHeight = Number.parseFloat(pageEl.style.height || ''); + const pageHeight = + page.size?.h ?? + this.currentLayout?.pageSize?.h ?? + (Number.isFinite(styledPageHeight) ? styledPageHeight : pageEl.clientHeight); + + return Math.max(0, pageHeight - adjustedBottomMargin); + } + private renderDecorationSection(pageEl: HTMLElement, page: Page, pageIndex: number, kind: 'header' | 'footer'): void { if (!this.doc) return; const provider = kind === 'header' ? this.headerProvider : this.footerProvider; @@ -2231,6 +2272,15 @@ export class DomPainter { // into the body region, similar to how body content can have negative indents. container.style.overflow = 'visible'; + // Footer page-relative anchors carry normalized Y coordinates (band-local, + // computed from real page geometry). Compute the page-space origin so the + // painter can convert them back to absolute page / container-local positions. + // 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; + const footerAnchorContainerOffsetY = kind === 'footer' ? footerAnchorPageOriginY - effectiveOffset : 0; + // For footers, calculate offset to push content to bottom of container // Fragments are absolutely positioned, so we need to adjust their y values // Use effectiveHeight (which accounts for overflow) rather than reserved height @@ -2294,12 +2344,19 @@ export class DomPainter { // which also has z-index values but comes later in DOM order. behindDocFragments.forEach(({ fragment, originalIndex }) => { const fragEl = this.renderFragment(fragment, context, undefined, betweenBorderFlags.get(originalIndex)); - const isPageRelativeVertical = this.isPageRelativeVerticalAnchorFragment(fragment); - // Page-relative anchors already carry absolute page Y coordinates. Adding decoration - // container offsets would shift them twice and can push header art into body content. - const pageY = isPageRelativeVertical - ? fragment.y - : effectiveOffset + fragment.y + (kind === 'footer' ? footerYOffset : 0); + const isPageRelative = this.isPageRelativeAnchoredFragment(fragment); + + let pageY: number; + if (isPageRelative && kind === 'footer') { + // Footer page-relative: fragment.y is normalized to band-local coords + pageY = footerAnchorPageOriginY + fragment.y; + } else if (isPageRelative) { + // Header page-relative: fragment.y is raw inner-layout absolute Y + pageY = fragment.y; + } else { + pageY = effectiveOffset + fragment.y + (kind === 'footer' ? footerYOffset : 0); + } + fragEl.style.top = `${pageY}px`; fragEl.style.left = `${marginLeft + fragment.x}px`; fragEl.style.zIndex = '0'; // Same level as page, but inserted first so renders behind @@ -2311,17 +2368,20 @@ export class DomPainter { // Render normal fragments in the header/footer container normalFragments.forEach(({ fragment, originalIndex }) => { const fragEl = this.renderFragment(fragment, context, undefined, betweenBorderFlags.get(originalIndex)); - const isPageRelativeVertical = this.isPageRelativeVerticalAnchorFragment(fragment); - if (isPageRelativeVertical) { - // Convert absolute page Y back to decoration-container local coordinates. - // Container top is applied separately, so we subtract it here to avoid a second offset. + const isPageRelative = this.isPageRelativeAnchoredFragment(fragment); + + if (isPageRelative && kind === 'footer') { + // Footer page-relative: fragment.y is normalized to band-local coords + fragEl.style.top = `${fragment.y + footerAnchorContainerOffsetY}px`; + } else if (isPageRelative) { + // Header page-relative: convert raw inner-layout Y to container-local fragEl.style.top = `${fragment.y - effectiveOffset}px`; - } - // Apply footer offset to push content to bottom - if (footerYOffset > 0 && !isPageRelativeVertical) { + } else if (footerYOffset > 0) { + // Non-anchored footer content: push to bottom of container const currentTop = parseFloat(fragEl.style.top) || fragment.y; fragEl.style.top = `${currentTop + footerYOffset}px`; } + container.appendChild(fragEl); }); @@ -5255,11 +5315,13 @@ export class DomPainter { el.classList.add(CLASS_NAMES.line); applyStyles(el, lineStyles(line.lineHeight)); el.dataset.layoutEpoch = String(this.layoutEpoch); - const styleId = (block.attrs as ParagraphAttrs | undefined)?.styleId; + const paragraphAttrs = (block.attrs as ParagraphAttrs | undefined) ?? {}; + const styleId = paragraphAttrs.styleId; if (styleId) { el.setAttribute('styleid', styleId); } - const alignment = (block.attrs as ParagraphAttrs | undefined)?.alignment; + applyParagraphDirection(el, paragraphAttrs); + const alignment = paragraphAttrs.alignment; // Apply text-align for center/right immediately. // For justify, we keep 'left' and apply spacing via word-spacing. @@ -7074,11 +7136,34 @@ export const applyRunDataAttributes = (element: HTMLElement, dataAttrs?: Record< }); }; +const resolveParagraphDirection = (attrs?: ParagraphAttrs): 'ltr' | 'rtl' | undefined => { + if (attrs?.direction) { + return attrs.direction; + } + if (attrs?.rtl === true) { + return 'rtl'; + } + if (attrs?.rtl === false) { + return 'ltr'; + } + return undefined; +}; + +const applyParagraphDirection = (element: HTMLElement, attrs?: ParagraphAttrs): void => { + const direction = resolveParagraphDirection(attrs); + if (!direction) { + return; + } + element.setAttribute('dir', direction); + element.style.direction = direction; +}; + const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => { if (!attrs) return; if (attrs.styleId) { element.setAttribute('styleid', attrs.styleId); } + applyParagraphDirection(element, attrs); if (attrs.alignment) { // Avoid native CSS justify: DomPainter applies justify via per-line word-spacing. element.style.textAlign = attrs.alignment === 'justify' ? 'left' : attrs.alignment; diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts index 8ba89726a7..7a87e9aa59 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -173,6 +173,7 @@ describe('computeParagraphAttrs', () => { it('passes previousParagraphFont to marker run when paragraph has listRendering and numbering', () => { const previousFont = { fontFamily: 'MarkerFont, sans-serif', fontSize: 11 }; + const paragraph: PMNode = { type: { name: 'paragraph' }, attrs: { @@ -258,6 +259,22 @@ describe('computeParagraphAttrs', () => { // Font size still inherits from previous paragraph when the paragraph has no explicit run props. expect(markerRun?.fontSize).toBe(11); }); + + it('preserves explicit paragraph bidi direction', () => { + const paragraph: PMNode = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + rightToLeft: true, + }, + }, + }; + + const { paragraphAttrs } = computeParagraphAttrs(paragraph as never); + + expect(paragraphAttrs.direction).toBe('rtl'); + expect(paragraphAttrs.rtl).toBe(true); + }); }); describe('computeRunAttrs', () => { diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index a7dff234a4..52793d43df 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -283,6 +283,12 @@ export const computeParagraphAttrs = ( const paragraphDecimalSeparator = DEFAULT_DECIMAL_SEPARATOR; const tabIntervalTwips = DEFAULT_TAB_INTERVAL_TWIPS; const normalizedFramePr = normalizeFramePr(resolvedParagraphProperties.framePr); + const normalizedDirection = + resolvedParagraphProperties.rightToLeft === true + ? 'rtl' + : resolvedParagraphProperties.rightToLeft === false + ? 'ltr' + : undefined; const floatAlignment = normalizedFramePr?.xAlign; const normalizedNumberingProperties = normalizeNumberingProperties(resolvedParagraphProperties.numberingProperties); const dropCapDescriptor = normalizeDropCap(resolvedParagraphProperties.framePr, para, converterContext); @@ -312,7 +318,8 @@ export const computeParagraphAttrs = ( keepLines: resolvedParagraphProperties.keepLines, floatAlignment: floatAlignment, pageBreakBefore: resolvedParagraphProperties.pageBreakBefore, - direction: resolvedParagraphProperties.rightToLeft ? 'rtl' : undefined, + direction: normalizedDirection, + rtl: normalizedDirection === 'rtl' ? true : normalizedDirection === 'ltr' ? false : undefined, }; if (normalizedNumberingProperties && normalizedListRendering) { diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index f1f110148d..12ded4e39e 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -3108,7 +3108,7 @@ describe('toFlowBlocks', () => { const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); expect(paragraph.attrs?.direction).toBe('rtl'); - expect(paragraph.attrs?.rtl).toBeUndefined(); + expect(paragraph.attrs?.rtl).toBe(true); expect(paragraph.attrs?.indent?.left).toBe(24); expect(paragraph.attrs?.indent?.right).toBe(12); }); @@ -3134,8 +3134,8 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); - expect(paragraph.attrs?.direction).toBeUndefined(); - expect(paragraph.attrs?.rtl).toBeUndefined(); + expect(paragraph.attrs?.direction).toBe('ltr'); + expect(paragraph.attrs?.rtl).toBe(false); }); it('handles multiple page breaks', () => { diff --git a/packages/super-editor/src/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/core/header-footer/HeaderFooterPerRidLayout.ts index 1a24ad654e..9bb300610e 100644 --- a/packages/super-editor/src/core/header-footer/HeaderFooterPerRidLayout.ts +++ b/packages/super-editor/src/core/header-footer/HeaderFooterPerRidLayout.ts @@ -1,7 +1,7 @@ import type { FlowBlock, HeaderFooterLayout, Layout, SectionMetadata } from '@superdoc/contracts'; import { OOXML_PCT_DIVISOR } from '@superdoc/contracts'; import { computeDisplayPageNumber, layoutHeaderFooterWithCache } from '@superdoc/layout-bridge'; -import type { HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; +import type { HeaderFooterLayoutResult, HeaderFooterConstraints } from '@superdoc/layout-bridge'; import { measureBlock } from '@superdoc/measuring-dom'; export type HeaderFooterPerRidLayoutInput = { @@ -9,18 +9,18 @@ export type HeaderFooterPerRidLayoutInput = { footerBlocks?: unknown; headerBlocksByRId: Map | undefined; footerBlocksByRId: Map | undefined; - constraints: { width: number; height: number; pageWidth: number; margins: { left: number; right: number } }; + constraints: HeaderFooterConstraints; }; -type Constraints = HeaderFooterPerRidLayoutInput['constraints']; +type Constraints = HeaderFooterConstraints; /** * Compute the content width for a section, falling back to global constraints. */ function buildSectionContentWidth(section: SectionMetadata, fallback: Constraints): number { - const pageW = section.pageSize?.w ?? fallback.pageWidth; - const marginL = section.margins?.left ?? fallback.margins.left; - const marginR = section.margins?.right ?? fallback.margins.right; + const pageW = section.pageSize?.w ?? fallback.pageWidth ?? 0; + const marginL = section.margins?.left ?? fallback.margins?.left ?? 0; + const marginR = section.margins?.right ?? fallback.margins?.right ?? 0; return pageW - marginL - marginR; } @@ -30,19 +30,31 @@ function buildSectionContentWidth(section: SectionMetadata, fallback: Constraint * Word allows auto-width tables in headers/footers to extend beyond the body margins. */ function buildConstraintsForSection(section: SectionMetadata, fallback: Constraints, minWidth?: number): Constraints { - const pageW = section.pageSize?.w ?? fallback.pageWidth; - const marginL = section.margins?.left ?? fallback.margins.left; - const marginR = section.margins?.right ?? fallback.margins.right; + const pageW = section.pageSize?.w ?? fallback.pageWidth ?? 0; + const pageH = section.pageSize?.h ?? fallback.pageHeight; + const marginL = section.margins?.left ?? fallback.margins?.left ?? 0; + const marginR = section.margins?.right ?? fallback.margins?.right ?? 0; + const marginT = section.margins?.top ?? fallback.margins?.top; + const marginB = section.margins?.bottom ?? fallback.margins?.bottom; + const marginHeader = section.margins?.header ?? fallback.margins?.header; const contentWidth = pageW - marginL - marginR; // Allow tables to extend beyond right margin when grid width > content width. // Capped at pageWidth - marginLeft to avoid going past the page edge. const maxWidth = pageW - marginL; const effectiveWidth = minWidth ? Math.min(Math.max(contentWidth, minWidth), maxWidth) : contentWidth; + + // Recompute body content height if section has its own page size / vertical margins + const sectionMarginTop = marginT ?? 0; + const sectionMarginBottom = marginB ?? 0; + const sectionHeight = pageH != null ? Math.max(1, pageH - sectionMarginTop - sectionMarginBottom) : fallback.height; + return { width: effectiveWidth, - height: fallback.height, + height: sectionHeight, pageWidth: pageW, - margins: { left: marginL, right: marginR }, + pageHeight: pageH, + margins: { left: marginL, right: marginR, top: marginT, bottom: marginB, header: marginHeader }, + overflowBaseHeight: fallback.overflowBaseHeight, }; } @@ -230,6 +242,7 @@ async function layoutBlocksByRId( undefined, undefined, pageResolver, + kind, ); if (batchResult.default) { @@ -349,7 +362,10 @@ async function layoutWithPerSectionConstraints( const tableMinWidth = resolveTableMinWidth(tableWidthSpec, contentWidth); const sectionConstraints = buildConstraintsForSection(section, fallbackConstraints, tableMinWidth || undefined); const effectiveWidth = sectionConstraints.width; - const groupKey = `${rId}::w${effectiveWidth}`; + // Include vertical geometry in the key so sections with different page heights, + // vertical margins, or header distance get separate layouts (page-relative anchors + // and header band origin resolve differently). + const groupKey = `${rId}::w${effectiveWidth}::ph${sectionConstraints.pageHeight ?? ''}::mt${sectionConstraints.margins?.top ?? ''}::mb${sectionConstraints.margins?.bottom ?? ''}::mh${sectionConstraints.margins?.header ?? ''}`; let group = groups.get(groupKey); if (!group) { @@ -377,6 +393,7 @@ async function layoutWithPerSectionConstraints( undefined, undefined, pageResolver, + kind, ); if (batchResult.default) { diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 5716762247..564962be2b 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -82,6 +82,7 @@ import { import type { HeaderFooterIdentifier, HeaderFooterLayoutResult, + HeaderFooterConstraints, HeaderFooterType, PositionHit, TableHitResult, @@ -5113,9 +5114,15 @@ export class PresentationEditor extends EventEmitter { return { width: measurementWidth, height, - // Pass actual page dimensions for page-relative anchor positioning in headers/footers pageWidth: pageSize.w, - margins: { left: marginLeft, right: marginRight }, + pageHeight: pageSize.h, + margins: { + left: marginLeft, + right: marginRight, + top: marginTop, + bottom: marginBottom, + header: headerMargin, + }, overflowBaseHeight, }; } @@ -5134,7 +5141,7 @@ export class PresentationEditor extends EventEmitter { footerBlocks?: unknown; headerBlocksByRId: Map | undefined; footerBlocksByRId: Map | undefined; - constraints: { width: number; height: number; pageWidth: number; margins: { left: number; right: number } }; + constraints: HeaderFooterConstraints; } | null, layout: Layout, sectionMetadata: SectionMetadata[], diff --git a/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 3b75a4b305..22c34c472c 100644 --- a/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -40,6 +40,7 @@ import { type HeaderFooterIdentifier, type HeaderFooterLayoutResult, type MultiSectionHeaderFooterIdentifier, + type HeaderFooterConstraints, } from '@superdoc/layout-bridge'; import { deduplicateOverlappingRects } from '../dom/DomSelectionGeometry.js'; @@ -100,13 +101,7 @@ export type HeaderFooterInput = { footerBlocks?: unknown; headerBlocksByRId: Map | undefined; footerBlocksByRId: Map | undefined; - constraints: { - width: number; - height: number; - pageWidth: number; - margins: { left: number; right: number }; - overflowBaseHeight?: number; - }; + constraints: HeaderFooterConstraints; } | null; /** @@ -1162,7 +1157,14 @@ export class HeaderFooterSessionManager { width: measurementWidth, height, pageWidth: pageSize.w, - margins: { left: marginLeft, right: marginRight }, + pageHeight: pageSize.h, + margins: { + left: marginLeft, + right: marginRight, + top: marginTop, + bottom: marginBottom, + header: headerMargin, + }, overflowBaseHeight, }; }