Skip to content

Commit 84f8ea5

Browse files
committed
fix: header/footer collapsing from image placement (#1575)
1 parent 5a761f6 commit 84f8ea5

2 files changed

Lines changed: 319 additions & 0 deletions

File tree

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

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,6 +2062,76 @@ describe('layoutHeaderFooter', () => {
20622062
expect(layout.pages[0].fragments[0]).toMatchObject({ kind: 'image', height: 40 });
20632063
});
20642064

2065+
it('ignores far-away behindDoc anchored fragments when computing height', () => {
2066+
const paragraphBlock: FlowBlock = {
2067+
kind: 'paragraph',
2068+
id: 'para-1',
2069+
runs: [{ text: 'Header text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }],
2070+
};
2071+
const imageBlock: FlowBlock = {
2072+
kind: 'image',
2073+
id: 'img-1',
2074+
src: 'data:image/png;base64,xxx',
2075+
anchor: {
2076+
isAnchored: true,
2077+
behindDoc: true,
2078+
offsetV: 1000,
2079+
},
2080+
};
2081+
const paragraphMeasure: Measure = {
2082+
kind: 'paragraph',
2083+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 80, ascent: 12, descent: 3, lineHeight: 15 }],
2084+
totalHeight: 15,
2085+
};
2086+
const imageMeasure: Measure = {
2087+
kind: 'image',
2088+
width: 50,
2089+
height: 40,
2090+
};
2091+
2092+
const layout = layoutHeaderFooter([paragraphBlock, imageBlock], [paragraphMeasure, imageMeasure], {
2093+
width: 200,
2094+
height: 60,
2095+
});
2096+
2097+
expect(layout.height).toBeCloseTo(15);
2098+
});
2099+
2100+
it('includes near behindDoc anchored fragments when computing height', () => {
2101+
const paragraphBlock: FlowBlock = {
2102+
kind: 'paragraph',
2103+
id: 'para-1',
2104+
runs: [{ text: 'Header text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }],
2105+
};
2106+
const imageBlock: FlowBlock = {
2107+
kind: 'image',
2108+
id: 'img-1',
2109+
src: 'data:image/png;base64,xxx',
2110+
anchor: {
2111+
isAnchored: true,
2112+
behindDoc: true,
2113+
offsetV: -20,
2114+
},
2115+
};
2116+
const paragraphMeasure: Measure = {
2117+
kind: 'paragraph',
2118+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 80, ascent: 12, descent: 3, lineHeight: 15 }],
2119+
totalHeight: 15,
2120+
};
2121+
const imageMeasure: Measure = {
2122+
kind: 'image',
2123+
width: 50,
2124+
height: 40,
2125+
};
2126+
2127+
const layout = layoutHeaderFooter([paragraphBlock, imageBlock], [paragraphMeasure, imageMeasure], {
2128+
width: 200,
2129+
height: 60,
2130+
});
2131+
2132+
expect(layout.height).toBeGreaterThan(15);
2133+
});
2134+
20652135
it('transforms page-relative anchor offsets by subtracting left margin', () => {
20662136
// An anchored image with hRelativeFrom='page' and offsetH=545 (absolute from page left)
20672137
// When left margin is 107, the image should be positioned at 545-107=438 within the header
@@ -2187,6 +2257,204 @@ describe('layoutHeaderFooter', () => {
21872257
// margin-relative anchors should not be transformed - offsetH stays at 50
21882258
expect(imageFragment!.x).toBe(50);
21892259
});
2260+
2261+
it('ignores behindDoc DrawingBlock with extreme offset when computing height', () => {
2262+
const paragraphBlock: FlowBlock = {
2263+
kind: 'paragraph',
2264+
id: 'para-1',
2265+
runs: [{ text: 'Header text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }],
2266+
};
2267+
const drawingBlock: FlowBlock = {
2268+
kind: 'drawing',
2269+
id: 'drawing-1',
2270+
drawingKind: 'vectorShape',
2271+
anchor: {
2272+
isAnchored: true,
2273+
behindDoc: true,
2274+
offsetV: 2000, // Extreme offset beyond overflow threshold
2275+
},
2276+
shape: {
2277+
type: 'Rectangle',
2278+
},
2279+
};
2280+
const paragraphMeasure: Measure = {
2281+
kind: 'paragraph',
2282+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 80, ascent: 12, descent: 3, lineHeight: 15 }],
2283+
totalHeight: 15,
2284+
};
2285+
const drawingMeasure: Measure = {
2286+
kind: 'drawing',
2287+
drawingKind: 'vectorShape',
2288+
width: 100,
2289+
height: 50,
2290+
scale: 1,
2291+
naturalWidth: 100,
2292+
naturalHeight: 50,
2293+
geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false },
2294+
};
2295+
2296+
const layout = layoutHeaderFooter([paragraphBlock, drawingBlock], [paragraphMeasure, drawingMeasure], {
2297+
width: 200,
2298+
height: 60,
2299+
});
2300+
2301+
// Height should only include paragraph, not the extreme behindDoc drawing
2302+
expect(layout.height).toBeCloseTo(15);
2303+
});
2304+
2305+
it('includes non-behindDoc anchored fragments in height calculation', () => {
2306+
const paragraphBlock: FlowBlock = {
2307+
kind: 'paragraph',
2308+
id: 'para-1',
2309+
runs: [{ text: 'Header text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }],
2310+
};
2311+
const imageBlock: FlowBlock = {
2312+
kind: 'image',
2313+
id: 'img-1',
2314+
src: 'data:image/png;base64,xxx',
2315+
anchor: {
2316+
isAnchored: true,
2317+
behindDoc: false, // NOT behindDoc - should be included in height
2318+
offsetV: 20,
2319+
},
2320+
};
2321+
const paragraphMeasure: Measure = {
2322+
kind: 'paragraph',
2323+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 80, ascent: 12, descent: 3, lineHeight: 15 }],
2324+
totalHeight: 15,
2325+
};
2326+
const imageMeasure: Measure = {
2327+
kind: 'image',
2328+
width: 50,
2329+
height: 40,
2330+
};
2331+
2332+
const layout = layoutHeaderFooter([paragraphBlock, imageBlock], [paragraphMeasure, imageMeasure], {
2333+
width: 200,
2334+
height: 100,
2335+
});
2336+
2337+
// Height should include both paragraph and the anchored image
2338+
// Image is at offsetV=20 with height 40, so bottom is at 60
2339+
expect(layout.height).toBeGreaterThan(15);
2340+
expect(layout.height).toBeCloseTo(60, 0);
2341+
});
2342+
2343+
it('returns minimal height when header contains only behindDoc fragments with extreme offsets', () => {
2344+
const imageBlock1: FlowBlock = {
2345+
kind: 'image',
2346+
id: 'img-1',
2347+
src: 'data:image/png;base64,xxx',
2348+
anchor: {
2349+
isAnchored: true,
2350+
behindDoc: true,
2351+
offsetV: -5000, // Extreme negative offset
2352+
},
2353+
};
2354+
const imageBlock2: FlowBlock = {
2355+
kind: 'image',
2356+
id: 'img-2',
2357+
src: 'data:image/png;base64,yyy',
2358+
anchor: {
2359+
isAnchored: true,
2360+
behindDoc: true,
2361+
offsetV: 3000, // Extreme positive offset
2362+
},
2363+
};
2364+
const imageMeasure1: Measure = {
2365+
kind: 'image',
2366+
width: 100,
2367+
height: 50,
2368+
};
2369+
const imageMeasure2: Measure = {
2370+
kind: 'image',
2371+
width: 100,
2372+
height: 50,
2373+
};
2374+
2375+
const layout = layoutHeaderFooter([imageBlock1, imageBlock2], [imageMeasure1, imageMeasure2], {
2376+
width: 200,
2377+
height: 60,
2378+
});
2379+
2380+
// Both images have extreme offsets and behindDoc=true, so height should be 0
2381+
expect(layout.height).toBe(0);
2382+
});
2383+
2384+
it('includes behindDoc fragments within overflow range alongside regular content', () => {
2385+
const paragraphBlock: FlowBlock = {
2386+
kind: 'paragraph',
2387+
id: 'para-1',
2388+
runs: [{ text: 'Header text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }],
2389+
};
2390+
const behindDocImage1: FlowBlock = {
2391+
kind: 'image',
2392+
id: 'img-behind-1',
2393+
src: 'data:image/png;base64,xxx',
2394+
anchor: {
2395+
isAnchored: true,
2396+
behindDoc: true,
2397+
offsetV: 5, // Within overflow range - should be included
2398+
},
2399+
};
2400+
const behindDocImage2: FlowBlock = {
2401+
kind: 'image',
2402+
id: 'img-behind-2',
2403+
src: 'data:image/png;base64,yyy',
2404+
anchor: {
2405+
isAnchored: true,
2406+
behindDoc: true,
2407+
offsetV: 5000, // Extreme offset - should be excluded
2408+
},
2409+
};
2410+
const regularImage: FlowBlock = {
2411+
kind: 'image',
2412+
id: 'img-regular',
2413+
src: 'data:image/png;base64,zzz',
2414+
anchor: {
2415+
isAnchored: true,
2416+
behindDoc: false,
2417+
offsetV: 25,
2418+
},
2419+
};
2420+
const paragraphMeasure: Measure = {
2421+
kind: 'paragraph',
2422+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 80, ascent: 12, descent: 3, lineHeight: 15 }],
2423+
totalHeight: 15,
2424+
};
2425+
const imageMeasure1: Measure = {
2426+
kind: 'image',
2427+
width: 50,
2428+
height: 30,
2429+
};
2430+
const imageMeasure2: Measure = {
2431+
kind: 'image',
2432+
width: 50,
2433+
height: 30,
2434+
};
2435+
const imageMeasure3: Measure = {
2436+
kind: 'image',
2437+
width: 50,
2438+
height: 35,
2439+
};
2440+
2441+
const layout = layoutHeaderFooter(
2442+
[paragraphBlock, behindDocImage1, behindDocImage2, regularImage],
2443+
[paragraphMeasure, imageMeasure1, imageMeasure2, imageMeasure3],
2444+
{
2445+
width: 200,
2446+
height: 100,
2447+
},
2448+
);
2449+
2450+
// Height should include:
2451+
// - paragraph (15)
2452+
// - behindDocImage1 at y=5, height=30, bottom=35 (within overflow range)
2453+
// - regularImage at y=25, height=35, bottom=60
2454+
// - behindDocImage2 excluded (extreme offset)
2455+
expect(layout.height).toBeGreaterThan(15);
2456+
expect(layout.height).toBeCloseTo(60, 0);
2457+
});
21902458
});
21912459

21922460
describe('requirePageBoundary edge cases', () => {

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1672,6 +1672,33 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
16721672
};
16731673
}
16741674

1675+
/**
1676+
* Lays out header or footer content within specified dimensional constraints.
1677+
*
1678+
* This function positions blocks (paragraphs, images, drawings) within a header or footer region,
1679+
* handling page-relative anchor transformations and computing the actual height required by
1680+
* visible content. Headers and footers are rendered within the content box but may contain
1681+
* page-relative anchored objects that need coordinate transformation.
1682+
*
1683+
* @param blocks - The flow blocks to layout (paragraphs, images, drawings, etc.)
1684+
* @param measures - Corresponding measurements for each block (must match blocks.length)
1685+
* @param constraints - Dimensional constraints including width, height, and optional margins
1686+
*
1687+
* @returns A HeaderFooterLayout containing:
1688+
* - pages: Array of laid-out pages with positioned fragments
1689+
* - height: The actual height consumed by visible content
1690+
*
1691+
* @throws {Error} If blocks and measures arrays have different lengths
1692+
* @throws {Error} If width or height constraints are not positive finite numbers
1693+
*
1694+
* Special handling for behindDoc anchored fragments:
1695+
* - Anchored images/drawings with behindDoc=true are decorative background elements
1696+
* - These fragments are excluded from height calculations if they fall outside a reasonable
1697+
* overflow range (4x the header/footer height or 192pt, whichever is larger)
1698+
* - This prevents decorative elements with extreme offsets from inflating header/footer margins
1699+
* - behindDoc fragments within the overflow range are still included to handle modest positioning
1700+
* - All behindDoc fragments are still rendered in the layout; they're only excluded from height
1701+
*/
16751702
export function layoutHeaderFooter(
16761703
blocks: FlowBlock[],
16771704
measures: Measure[],
@@ -1691,6 +1718,11 @@ export function layoutHeaderFooter(
16911718
throw new Error('layoutHeaderFooter: height must be positive');
16921719
}
16931720

1721+
// Allow modest behindDoc overflow but ignore extreme offsets that shouldn't drive margins.
1722+
const maxBehindDocOverflow = Math.max(192, height * 4);
1723+
const minBehindDocY = -maxBehindDocOverflow;
1724+
const maxBehindDocY = height + maxBehindDocOverflow;
1725+
16941726
// Transform page-relative anchor offsets to content-relative for correct positioning
16951727
// Headers/footers are rendered within the content box, but page-relative anchors
16961728
// specify offsets from the physical page edge. We need to adjust by subtracting
@@ -1738,6 +1770,25 @@ export function layoutHeaderFooter(
17381770
const block = blocks[idx];
17391771
const measure = measures[idx];
17401772

1773+
// Exclude behindDoc anchored fragments with extreme offsets from height calculations.
1774+
// Decorative background images/drawings in headers/footers should not inflate margins.
1775+
// Fragments are still rendered in the layout; we only skip them when computing total height.
1776+
// We allow modest overflow (within maxBehindDocOverflow) to handle reasonable positioning.
1777+
const isAnchoredFragment =
1778+
(fragment.kind === 'image' || fragment.kind === 'drawing') && fragment.isAnchored === true;
1779+
if (isAnchoredFragment) {
1780+
// Runtime validation: ensure block.kind matches fragment.kind before type assertion
1781+
if (block.kind !== 'image' && block.kind !== 'drawing') {
1782+
throw new Error(
1783+
`Type mismatch: fragment kind is ${fragment.kind} but block kind is ${block.kind} for block ${block.id}`,
1784+
);
1785+
}
1786+
const anchoredBlock = block as ImageBlock | DrawingBlock;
1787+
if (anchoredBlock.anchor?.behindDoc && (fragment.y < minBehindDocY || fragment.y > maxBehindDocY)) {
1788+
continue;
1789+
}
1790+
}
1791+
17411792
if (fragment.y < minY) minY = fragment.y;
17421793
let bottom = fragment.y;
17431794

0 commit comments

Comments
 (0)