From 8c162bede3042af8a0a95ba5ebba35c075c8c837 Mon Sep 17 00:00:00 2001 From: maroje Date: Wed, 1 Apr 2026 14:13:43 +0200 Subject: [PATCH] fix: cap oversized header/footer instead of throwing When header + footer heights exceed the available page space (leaving less than bodyHeightMinimumFactor for the body), proportionally reduce all section setting heights to fit. Previously this threw an error crashing the entire PDF generation. Real-world case: A4 page (842px) with header 274px + footer 701px produced negative body height (-131px) and threw "Header/footer too big". Now sections are scaled down proportionally, clipping excess content while still producing a valid PDF. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../layout/create-page-layout-settings.ts | 39 +++++++- .../create-page-layout-settings.test.ts | 89 +++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/utils/layout/create-page-layout-settings.ts b/src/utils/layout/create-page-layout-settings.ts index f33ef82..39291b1 100644 --- a/src/utils/layout/create-page-layout-settings.ts +++ b/src/utils/layout/create-page-layout-settings.ts @@ -1,4 +1,4 @@ -import calculatePageLayout from '@app/utils/layout/calculate-page-layout'; +import calculatePageLayout, {getMaxHeight} from '@app/utils/layout/calculate-page-layout'; import {hasPageNumbers, hasSectionPageNumbers} from '@app/utils/layout/has-page-numbers'; import type {SectionSettings, SectionSetting} from '@app/evaluators/section-settings'; @@ -34,14 +34,47 @@ interface CreatePageLayoutSettingsOpts { bodyHeightMinimumFactor: number; } +/** + * When the combined header + footer height exceeds the available page space + * (leaving less than bodyHeightMinimumFactor of the page for the body), + * proportionally reduce all section setting heights so they fit. + * + * Returns the original settings unchanged if no capping is needed, + * or cloned settings with reduced heights if capping is required. + */ +function capOversizedSections(sectionSettings: SectionSettings, opts: CreatePageLayoutSettingsOpts): SectionSettings { + const {pageHeight, bodyHeightMinimumFactor} = opts; + if (!pageHeight) return sectionSettings; + + const headerHeight = getMaxHeight(sectionSettings.headers); + const footerHeight = getMaxHeight(sectionSettings.footers); + const totalSectionsHeight = headerHeight + footerHeight; + if (!totalSectionsHeight) return sectionSettings; + + // +2 accounts for the overlap pixel added in calculatePageLayout + const maxSectionsHeight = Math.floor(pageHeight * (1 - bodyHeightMinimumFactor)) + 2; + if (totalSectionsHeight <= maxSectionsHeight) return sectionSettings; + + const scaleFactor = maxSectionsHeight / totalSectionsHeight; + const capHeight = (s: SectionSetting): SectionSetting => ({...s, height: Math.floor(s.height * scaleFactor)}); + + return { + headers: sectionSettings.headers.map(capHeight), + footers: sectionSettings.footers.map(capHeight), + backgrounds: sectionSettings.backgrounds, + }; +} + export function createPageLayoutSettings( sectionSettings: SectionSettings | undefined, opts: CreatePageLayoutSettingsOpts ): PageLayout { - const pageLayout = calculatePageLayout(sectionSettings, opts); + const cappedSettings = sectionSettings ? capOversizedSections(sectionSettings, opts) : undefined; + + const pageLayout = calculatePageLayout(cappedSettings, opts); const transparentBg = !!sectionSettings?.backgrounds.length; - const {headers = [], footers = [], backgrounds = []} = sectionSettings ?? {}; + const {headers = [], footers = [], backgrounds = []} = cappedSettings ?? {}; const hasAnySection = !!(headers.length || footers.length || backgrounds.length); diff --git a/test/utils/layout/create-page-layout-settings.test.ts b/test/utils/layout/create-page-layout-settings.test.ts index 9bc6259..1c3a727 100644 --- a/test/utils/layout/create-page-layout-settings.test.ts +++ b/test/utils/layout/create-page-layout-settings.test.ts @@ -179,4 +179,93 @@ describe('createPageLayoutSettings', () => { background: undefined, }); }); + + describe('oversized header/footer capping', () => { + test('caps header and footer proportionally when they exceed available page space', () => { + // Reproduces the real-world bug: A4 page (842px), header 274px, footer 701px + // Without capping: body = 842 - 274 - 701 + 2 = -131px → would throw + const settings: SectionSettings = { + headers: [makeSectionSetting({height: 274})], + footers: [makeSectionSetting({height: 701})], + backgrounds: [], + }; + + const result = createPageLayoutSettings(settings, makeLayoutOpts({height: 842, width: 595, factor: 0.3})); + + // Should not throw — sections are capped to fit + expect(result.body.height).toBeGreaterThan(0); + expect(result.body.height).toBeGreaterThanOrEqual(Math.floor(842 * 0.3)); + + // Capped settings should have reduced heights + const cappedHeaderHeight = result.header!.settings[0].height; + const cappedFooterHeight = result.footer!.settings[0].height; + expect(cappedHeaderHeight).toBeLessThan(274); + expect(cappedFooterHeight).toBeLessThan(701); + + // Proportions should be preserved + const originalRatio = 274 / 701; + const cappedRatio = cappedHeaderHeight / cappedFooterHeight; + expect(cappedRatio).toBeCloseTo(originalRatio, 1); + }); + + test('does not cap sections when they fit within the page', () => { + const settings: SectionSettings = { + headers: [makeSectionSetting({height: 200})], + footers: [makeSectionSetting({height: 150})], + backgrounds: [], + }; + + const result = createPageLayoutSettings(settings, makeLayoutOpts()); + + expect(result.header!.settings[0].height).toBe(200); + expect(result.footer!.settings[0].height).toBe(150); + }); + + test('caps all variants when multiple physical-page variants exist', () => { + const settings: SectionSettings = { + headers: [ + makeSectionSetting({height: 500, physicalPageType: 'first'}), + makeSectionSetting({height: 400, physicalPageType: 'default'}), + ], + footers: [makeSectionSetting({height: 500})], + backgrounds: [], + }; + + // max header = 500, footer = 500, total = 1000 > 1000 * (1 - 1/3) + 2 = 668 + const result = createPageLayoutSettings(settings, makeLayoutOpts()); + + expect(result.header!.settings[0].height).toBeLessThan(500); + expect(result.header!.settings[1].height).toBeLessThan(400); + expect(result.footer!.settings[0].height).toBeLessThan(500); + expect(result.body.height).toBeGreaterThan(0); + }); + + test('preserves page number flags on capped settings', () => { + const settings: SectionSettings = { + headers: [makeSectionSetting({height: 600, hasCurrentPageNumber: true})], + footers: [makeSectionSetting({height: 600, hasTotalPagesNumber: true})], + backgrounds: [], + }; + + const result = createPageLayoutSettings(settings, makeLayoutOpts()); + + expect(result.hasPageNumbers).toBe(true); + expect(result.header!.hasPageNumbers).toBe(true); + expect(result.footer!.hasPageNumbers).toBe(true); + expect(result.header!.settings[0].hasCurrentPageNumber).toBe(true); + expect(result.footer!.settings[0].hasTotalPagesNumber).toBe(true); + }); + + test('does not modify background heights when capping', () => { + const settings: SectionSettings = { + headers: [makeSectionSetting({height: 600})], + footers: [makeSectionSetting({height: 600})], + backgrounds: [makeSectionSetting({height: 1000})], + }; + + const result = createPageLayoutSettings(settings, makeLayoutOpts()); + + expect(result.background!.settings[0].height).toBe(1000); + }); + }); });