Skip to content

Commit 6e62198

Browse files
authored
fix(header-footer): normalize page-relative anchor layout (#2484)
* fix(header-footer): normalize page-relative anchor layout * fix(layout): normalize header/footer anchors and preserve paragraph bidi
1 parent ed4839b commit 6e62198

19 files changed

Lines changed: 1049 additions & 187 deletions

File tree

devtools/visual-testing/pnpm-lock.yaml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,9 +1810,14 @@ export type HeaderFooterPage = {
18101810
};
18111811

18121812
export type HeaderFooterLayout = {
1813+
/** Measurement height for pagination — excludes out-of-band fragments. */
18131814
height: number;
1815+
/** Minimum y of all rendered fragments (including out-of-band). */
18141816
minY?: number;
1817+
/** Maximum y + fragmentHeight of all rendered fragments. */
18151818
maxY?: number;
1819+
/** Full visual extent of all rendered fragments (renderMaxY - renderMinY). */
1820+
renderHeight?: number;
18161821
pages: HeaderFooterPage[];
18171822
};
18181823

packages/layout-engine/layout-bridge/src/incrementalLayout.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,7 @@ export async function incrementalLayout(
934934
headerMeasureCache,
935935
HEADER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT,
936936
undefined, // No page resolver needed for height calculation
937+
'header',
937938
);
938939

939940
// Extract actual content heights from each variant
@@ -960,11 +961,8 @@ export async function incrementalLayout(
960961
maxHeight: headerFooter.constraints.height,
961962
};
962963
const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints)));
963-
// Layout to get actual height
964-
const layout = layoutHeaderFooter(blocks, measures, {
965-
width: headerFooter.constraints.width,
966-
height: headerFooter.constraints.height,
967-
});
964+
// Layout to get actual height — pass full constraints for page-relative normalization
965+
const layout = layoutHeaderFooter(blocks, measures, headerFooter.constraints, 'header');
968966
if (layout.height > 0) {
969967
// Store height by rId for per-page margin calculation
970968
headerContentHeightsByRId.set(rId, layout.height);
@@ -1047,6 +1045,7 @@ export async function incrementalLayout(
10471045
headerMeasureCache,
10481046
FOOTER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT,
10491047
undefined, // No page resolver needed for height calculation
1048+
'footer',
10501049
);
10511050

10521051
// Extract actual content heights from each variant
@@ -1073,11 +1072,8 @@ export async function incrementalLayout(
10731072
maxHeight: headerFooter.constraints.height,
10741073
};
10751074
const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints)));
1076-
// Layout to get actual height
1077-
const layout = layoutHeaderFooter(blocks, measures, {
1078-
width: headerFooter.constraints.width,
1079-
height: headerFooter.constraints.height,
1080-
});
1075+
// Layout to get actual height — pass full constraints for page-relative normalization
1076+
const layout = layoutHeaderFooter(blocks, measures, headerFooter.constraints, 'footer');
10811077
if (layout.height > 0) {
10821078
// Store height by rId for per-page margin calculation
10831079
footerContentHeightsByRId.set(rId, layout.height);
@@ -1898,6 +1894,7 @@ export async function incrementalLayout(
18981894
headerMeasureCache,
18991895
FeatureFlags.HEADER_FOOTER_PAGE_TOKENS ? undefined : numberingCtx.totalPages, // Fallback for backward compat
19001896
pageResolver, // Use page resolver for section-aware numbering
1897+
'header',
19011898
);
19021899
headers = serializeHeaderFooterResults('header', headerLayouts);
19031900
}
@@ -1909,6 +1906,7 @@ export async function incrementalLayout(
19091906
headerMeasureCache,
19101907
FeatureFlags.HEADER_FOOTER_PAGE_TOKENS ? undefined : numberingCtx.totalPages, // Fallback for backward compat
19111908
pageResolver, // Use page resolver for section-aware numbering
1909+
'footer',
19121910
);
19131911
footers = serializeHeaderFooterResults('footer', footerLayouts);
19141912
}

packages/layout-engine/layout-bridge/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ export type { BoundaryRange } from './text-boundaries';
5353
export { incrementalLayout, measureCache, normalizeMargin } from './incrementalLayout';
5454
export type { HeaderFooterLayoutResult, IncrementalLayoutResult } from './incrementalLayout';
5555
// Re-export computeDisplayPageNumber from layout-engine for section-aware page numbering
56-
export { computeDisplayPageNumber, type DisplayPageInfo } from '@superdoc/layout-engine';
56+
export { computeDisplayPageNumber } from '@superdoc/layout-engine';
57+
export type { DisplayPageInfo, HeaderFooterConstraints } from '@superdoc/layout-engine';
5758
export { remeasureParagraph } from './remeasure';
5859
export { measureCharacterX } from './text-measurement';
5960
export { clickToPositionDom, findPageElement } from './dom-mapping';

packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ export async function layoutHeaderFooterWithCache(
194194
cache: HeaderFooterLayoutCache = sharedHeaderFooterCache,
195195
totalPages?: number,
196196
pageResolver?: PageResolver,
197+
kind?: 'header' | 'footer',
197198
): Promise<HeaderFooterBatchResult> {
198199
const result: HeaderFooterBatchResult = {};
199200

@@ -211,7 +212,7 @@ export async function layoutHeaderFooterWithCache(
211212
resolveHeaderFooterTokens(clonedBlocks, 1, numPages);
212213

213214
const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock);
214-
const layout = layoutHeaderFooter(clonedBlocks, measures, constraints);
215+
const layout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind);
215216

216217
result[type] = { blocks: clonedBlocks, measures, layout };
217218
}
@@ -231,7 +232,7 @@ export async function layoutHeaderFooterWithCache(
231232
const hasTokens = hasPageTokens(blocks);
232233
if (!hasTokens) {
233234
const measures = await cache.measureBlocks(blocks, constraints, measureBlock);
234-
const layout = layoutHeaderFooter(blocks, measures, constraints);
235+
const layout = layoutHeaderFooter(blocks, measures, constraints, kind);
235236
result[type] = { blocks, measures, layout };
236237
continue;
237238
}
@@ -275,7 +276,7 @@ export async function layoutHeaderFooterWithCache(
275276

276277
// Measure and layout
277278
const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock);
278-
const pageLayout = layoutHeaderFooter(clonedBlocks, measures, constraints);
279+
const pageLayout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind);
279280
const measuresById = new Map<string, Measure>();
280281
for (let i = 0; i < clonedBlocks.length; i += 1) {
281282
measuresById.set(clonedBlocks[i].id, measures[i]);
@@ -307,13 +308,14 @@ export async function layoutHeaderFooterWithCache(
307308
// Construct final HeaderFooterLayout with all pages
308309
// Use the first page's measurements for overall dimensions
309310
const firstPageLayout = pages[0]
310-
? layoutHeaderFooter(pages[0].blocks, pages[0].measures, constraints)
311+
? layoutHeaderFooter(pages[0].blocks, pages[0].measures, constraints, kind)
311312
: { height: 0, pages: [] };
312313

313314
const finalLayout: HeaderFooterLayout = {
314315
height: firstPageLayout.height,
315316
minY: firstPageLayout.minY,
316317
maxY: firstPageLayout.maxY,
318+
renderHeight: firstPageLayout.renderHeight,
317319
pages: pages.map((p) => ({
318320
number: p.number,
319321
fragments: p.fragments,

packages/layout-engine/layout-engine/src/index.d.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,25 @@ export type LayoutOptions = {
3737
export declare const SEMANTIC_PAGE_HEIGHT_PX = 1000000;
3838
export type HeaderFooterConstraints = {
3939
width: number;
40+
/** Body content height used as the measurement canvas (pagination boundary). */
4041
height: number;
41-
/** Actual page width for page-relative anchor positioning */
42+
/** Actual page width for page-relative anchor positioning. */
4243
pageWidth?: number;
43-
/** Page margins for page-relative anchor positioning */
44-
margins?: { left: number; right: number };
44+
/** Physical page height for vertical page-relative anchor conversion. */
45+
pageHeight?: number;
46+
/**
47+
* Page margins for anchor positioning.
48+
* `left`/`right`: horizontal page-relative conversion.
49+
* `top`/`bottom`: vertical margin-relative conversion and footer band origin.
50+
* `header`: header distance from page top edge (header band origin).
51+
*/
52+
margins?: {
53+
left: number;
54+
right: number;
55+
top?: number;
56+
bottom?: number;
57+
header?: number;
58+
};
4559
/**
4660
* Optional base height used to bound behindDoc overflow handling.
4761
* When provided, decorative assets far outside the header/footer band
@@ -61,7 +75,9 @@ export declare function layoutHeaderFooter(
6175
blocks: FlowBlock[],
6276
measures: Measure[],
6377
constraints: HeaderFooterConstraints,
78+
kind?: 'header' | 'footer',
6479
): HeaderFooterLayout;
80+
export { normalizeFragmentsForRegion } from './normalize-header-footer-fragments.js';
6581
export { buildAnchorMap, resolvePageRefTokens, getTocBlocksForRemeasurement } from './resolvePageRefs.js';
6682
export { formatPageNumber, computeDisplayPageNumber } from './pageNumbering.js';
6783
export type { PageNumberFormat, DisplayPageInfo } from './pageNumbering.js';

0 commit comments

Comments
 (0)