From bf71253ca4703529d995a4d96ca1abec2236ab20 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 09:33:13 -0700 Subject: [PATCH] fix: preserve margins on PDF overflow pages (#3422) * fix: preserve page margins across PDF overflow pages * test(pdf): preserve styled and full-width Glalie backgrounds * test(pdf): assert exact semantic margin colors --- .../src/templates/chikorita/ChikoritaPage.tsx | 17 +- .../pdf/src/templates/ditgar/DitgarPage.tsx | 17 +- .../pdf/src/templates/ditto/DittoPage.tsx | 10 +- .../pdf/src/templates/glalie/GlaliePage.tsx | 15 +- .../pdf/src/templates/leafish/LeafishPage.tsx | 11 +- .../shared/page-margin-background.tsx | 24 +++ .../templates/shared/page-margins.test.tsx | 200 ++++++++++++++++++ 7 files changed, 280 insertions(+), 14 deletions(-) create mode 100644 packages/pdf/src/templates/shared/page-margin-background.tsx create mode 100644 packages/pdf/src/templates/shared/page-margins.test.tsx diff --git a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx index 3d3540e92..de3de8a84 100644 --- a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx +++ b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx @@ -18,6 +18,7 @@ import { import { TemplateProvider } from "../shared/context"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; +import { PageMarginBackground } from "../shared/page-margin-background"; import { hasTemplatePicture } from "../shared/picture"; import { Heading, @@ -65,20 +66,26 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader, const hasPicture = hasTemplatePicture(picture); const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data)); const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data)); + const sidebarStyle = useResolvedNode(semanticNodeKeys.region(pageNodeKey, "sidebar")).style; return ( + {sidebarSections.map((section) => (
diff --git a/packages/pdf/src/templates/ditgar/DitgarPage.tsx b/packages/pdf/src/templates/ditgar/DitgarPage.tsx index 2670cc3d8..b840959fb 100644 --- a/packages/pdf/src/templates/ditgar/DitgarPage.tsx +++ b/packages/pdf/src/templates/ditgar/DitgarPage.tsx @@ -20,6 +20,7 @@ import { TemplateProvider } from "../shared/context"; import { getFeaturedSummaryLayout } from "../shared/featured-summary"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; +import { PageMarginBackground } from "../shared/page-margin-background"; import { hasTemplatePicture } from "../shared/picture"; import { Heading, @@ -87,15 +88,25 @@ export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pag {showSidebar && ( + {showHeader &&
} {!page.fullWidth && ( @@ -111,7 +122,7 @@ export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pag )} - + {featuredSummarySection && ( { sidebarContent: { paddingHorizontal: metrics.page.paddingHorizontal, paddingTop: metrics.page.paddingVertical, - paddingBottom: metrics.page.paddingVertical, }, mainColumn: { flex: 1, @@ -252,7 +262,6 @@ const useDitgarTemplate = (): DitgarTemplate => { mainContent: { paddingHorizontal: metrics.page.paddingHorizontal, paddingTop: metrics.page.paddingVertical, - paddingBottom: metrics.page.paddingVertical, }, specialContainer: { backgroundColor: primaryTint, diff --git a/packages/pdf/src/templates/ditto/DittoPage.tsx b/packages/pdf/src/templates/ditto/DittoPage.tsx index f61f3b085..01aabcfaa 100644 --- a/packages/pdf/src/templates/ditto/DittoPage.tsx +++ b/packages/pdf/src/templates/ditto/DittoPage.tsx @@ -75,12 +75,17 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader, page {showHeader &&
} - + { backgroundColor: primary, }, header: { + marginTop: -metrics.page.paddingVertical, position: "relative", }, headerBand: { diff --git a/packages/pdf/src/templates/glalie/GlaliePage.tsx b/packages/pdf/src/templates/glalie/GlaliePage.tsx index 6b2646924..1cd14e923 100644 --- a/packages/pdf/src/templates/glalie/GlaliePage.tsx +++ b/packages/pdf/src/templates/glalie/GlaliePage.tsx @@ -19,6 +19,7 @@ import { import { TemplateProvider } from "../shared/context"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; +import { PageMarginBackground } from "../shared/page-margin-background"; import { hasTemplatePicture } from "../shared/picture"; import { Heading, @@ -78,24 +79,34 @@ export const GlaliePage = ({ page, pageSize, pageMinHeightStyle, showHeader, pag {showSidebar && ( )} - + {showSidebar && ( + {showHeader &&
} {!page.fullWidth && ( diff --git a/packages/pdf/src/templates/leafish/LeafishPage.tsx b/packages/pdf/src/templates/leafish/LeafishPage.tsx index 2d4110642..5cdcb5df2 100644 --- a/packages/pdf/src/templates/leafish/LeafishPage.tsx +++ b/packages/pdf/src/templates/leafish/LeafishPage.tsx @@ -79,12 +79,17 @@ export const LeafishPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pa {showHeader &&
} - + {mainSections.map((section) => (
@@ -183,7 +188,7 @@ const useLeafishTemplate = (): LeafishTemplate => { levelItemActive: { backgroundColor: primary, }, - header: {}, + header: { marginTop: -metrics.page.paddingVertical }, headerIntro: { backgroundColor: primaryTintLight, paddingHorizontal: metrics.page.paddingHorizontal, diff --git a/packages/pdf/src/templates/shared/page-margin-background.tsx b/packages/pdf/src/templates/shared/page-margin-background.tsx new file mode 100644 index 000000000..b671998c5 --- /dev/null +++ b/packages/pdf/src/templates/shared/page-margin-background.tsx @@ -0,0 +1,24 @@ +import { View } from "#react-pdf-renderer"; + +type PageMarginBackgroundProps = { + color: string; + margin: number; +}; + +/** + * Page padding repeats on overflow pages; the negative top margin on each column + * preserves its first-page position and is reset by React PDF when the column splits. + * Extend only its paint through those margins, without painting translucent content twice. + */ +export const PageMarginBackground = ({ color, margin }: PageMarginBackgroundProps) => ( + <> + + + +); diff --git a/packages/pdf/src/templates/shared/page-margins.test.tsx b/packages/pdf/src/templates/shared/page-margins.test.tsx new file mode 100644 index 000000000..7025365a5 --- /dev/null +++ b/packages/pdf/src/templates/shared/page-margins.test.tsx @@ -0,0 +1,200 @@ +import type { Template } from "@reactive-resume/schema/templates"; +import type { TextItem } from "pdfjs-dist/types/src/display/api"; +import { describe, expect, it } from "vitest"; +import { renderToBuffer } from "@react-pdf/renderer"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { act, createElement } from "react"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { ResumeDocument } from "../../document"; +import { rasterizePdf } from "../../semantic/test/rasterize-pdf"; + +const templates = ["ditgar", "chikorita", "glalie", "leafish", "ditto", "pikachu", "onyx"] as const; +const renderOverflow = async ( + template: Template, + placement: "main" | "sidebar", + locale = "en-US", + mode: "semantic" | "legacy" = "semantic", + explicitPage?: { fullWidth: boolean }, + stylesheet = "@version 1;", + fullWidth = false, +) => { + const data = structuredClone(defaultResumeData); + data.basics.name = "Margin Audit"; + data.metadata.stylesheet = { mode, source: { languageVersion: 1, text: stylesheet } }; + data.metadata.typography.body.fontFamily = "Helvetica"; + data.metadata.typography.heading.fontFamily = "Helvetica"; + data.metadata.page.marginY = 48; + data.metadata.page.locale = locale; + data.metadata.page.marginX = 30; + data.metadata.layout.pages = [ + { + fullWidth: explicitPage?.fullWidth ?? fullWidth, + main: placement === "main" ? ["experience"] : [], + sidebar: placement === "sidebar" ? ["experience"] : [], + }, + ]; + if (explicitPage) data.metadata.layout.pages.unshift({ fullWidth: false, main: [], sidebar: [] }); + data.sections.experience.items = [ + { + id: "experience", + hidden: false, + company: "Company", + position: "Engineer", + location: "City", + period: "2020", + roles: [], + website: { url: "", label: "", inlineLink: false }, + description: Array.from( + { length: explicitPage ? 1 : 60 }, + (_, index) => `

Body line ${index} with work details and sample content for page flow.

`, + ).join(""), + }, + ]; + const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters[0]; + let bytes: Uint8Array = new Uint8Array(); + await act(async () => { + bytes = new Uint8Array(await renderToBuffer(element)); + }); + const rasters = + !explicitPage && placement === "main" && template !== "pikachu" && template !== "onyx" + ? await rasterizePdf(bytes.slice()) + : []; + const loadingTask = getDocument({ data: bytes, useSystemFonts: true }); + try { + const document = await loadingTask.promise; + const pages: { height: number; lines: TextItem[] }[] = []; + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber++) { + const page = await document.getPage(pageNumber); + const text = await page.getTextContent(); + pages.push({ + height: page.getViewport({ scale: 1 }).height, + lines: text.items.filter((item): item is TextItem => "str" in item && Boolean(item.str.trim())), + }); + } + return { pages, rasters }; + } finally { + await loadingTask.destroy(); + } +}; + +function backgroundAt(raster: Awaited>[number], x: number, y: number) { + return [...raster.data.slice((y * raster.width + x) * 4, (y * raster.width + x) * 4 + 3)]; +} + +describe("physical page margins (#3337, #3175)", () => { + it.each([ + ["#00ff00", [44, 212, 8]], + ["rgba(0, 255, 0, 0.5)", [146, 212, 110]], + ] as const)("keeps Glalie semantic background continuous through margins (%s)", async (color, expected) => { + const { rasters } = await renderOverflow( + "glalie", + "main", + "en-US", + "semantic", + undefined, + `@version 1; template-part[name="sidebar-background"] { background-color: ${color}; }`, + ); + const overflow = rasters[1]; + if (!overflow) throw new Error("Missing overflow raster"); + const inside = backgroundAt(overflow, 3, 100); + expect(inside).toEqual(expected); + expect(backgroundAt(overflow, 3, 3)).toEqual(inside); + expect(backgroundAt(overflow, 3, overflow.height - 4)).toEqual(inside); + }); + it("preserves the existing Glalie sidebar background on full-width overflow", async () => { + const { rasters } = await renderOverflow("glalie", "main", "en-US", "semantic", undefined, "@version 1;", true); + const overflow = rasters[1]; + if (!overflow) throw new Error("Missing overflow raster"); + expect(backgroundAt(overflow, 3, 100)).toEqual([242, 178, 178]); + expect(backgroundAt(overflow, 3, 3)).toEqual([242, 178, 178]); + expect(backgroundAt(overflow, 3, overflow.height - 4)).toEqual([242, 178, 178]); + }); + for (const placement of ["main", "sidebar"] as const) { + it.each(templates)(`keeps overflowing ${placement} content inside vertical margins (%s)`, async (template) => { + const { pages, rasters } = await renderOverflow(template, placement); + expect(pages.length).toBeGreaterThan(1); + if (placement === "main" && template !== "pikachu" && template !== "onyx") { + const firstPage = pages[0]; + if (!firstPage) throw new Error("Missing first PDF page"); + const name = firstPage.lines.find((line) => line.str === "Margin Audit"); + if (!name) throw new Error("Missing first-page header"); + expect(firstPage.height - name.transform[5] - name.height).toBeCloseTo(45.9, 1); + const corner = (pageIndex: number, right: boolean, bottom: boolean) => { + const raster = rasters[pageIndex]; + if (!raster) throw new Error("Missing rasterized PDF page"); + const x = right ? raster.width - 4 : 3; + const y = bottom ? raster.height - 4 : 3; + return [...raster.data.slice((y * raster.width + x) * 4, (y * raster.width + x) * 4 + 3)]; + }; + const white = [255, 255, 255]; + const red = [220, 38, 38]; + const tint = [248, 212, 212]; + const doubleTint = [242, 178, 178]; + const headerColor = + template === "chikorita" + ? white + : template === "leafish" + ? [251, 233, 233] + : template === "glalie" + ? doubleTint + : red; + expect(corner(0, false, false)).toEqual(headerColor); + for (let index = 0; index < rasters.length; index++) { + const sidebarColor = template === "ditgar" ? tint : template === "glalie" ? doubleTint : white; + expect(corner(index, false, true)).toEqual(sidebarColor); + expect(corner(index, true, true)).toEqual(template === "chikorita" ? red : white); + if (index > 0) { + expect(corner(index, false, false)).toEqual(sidebarColor); + expect(corner(index, true, false)).toEqual(template === "chikorita" ? red : white); + } + } + } + const bodyLines = pages.flatMap((page) => page.lines.filter((line) => line.str.startsWith("Body line"))); + expect(bodyLines).toHaveLength(60); + for (const [index, page] of pages.entries()) { + for (const line of page.lines) { + // Standard Helvetica glyph bounds can extend about 2pt above the line box. + expect( + page.height - line.transform[5] - line.height, + `${template} page ${index + 1}: ${line.str}`, + ).toBeGreaterThanOrEqual(45.8); + expect(line.transform[5], `${template} page ${index + 1}: ${line.str}`).toBeGreaterThanOrEqual(47); + } + } + }); + } + it.each(templates)("keeps RTL overflowing content within margins (%s)", async (template) => { + const { pages } = await renderOverflow(template, "sidebar", "ar-SA"); + expect(pages.length).toBeGreaterThan(1); + for (const page of pages) + for (const line of page.lines) { + expect(page.height - line.transform[5] - line.height).toBeGreaterThanOrEqual(45.8); + expect(line.transform[5]).toBeGreaterThanOrEqual(47); + } + }); + it.each(templates)("keeps legacy overflow within margins (%s)", async (template) => { + const { pages } = await renderOverflow(template, "sidebar", "en-US", "legacy"); + expect(pages.length).toBeGreaterThan(1); + for (const page of pages) + for (const line of page.lines) { + expect(page.height - line.transform[5] - line.height).toBeGreaterThanOrEqual(45.8); + expect(line.transform[5]).toBeGreaterThanOrEqual(47); + } + }); + for (const fullWidth of [false, true]) { + it.each(templates)( + `starts explicit headerless pages at the margin (fullWidth: ${fullWidth}, %s)`, + async (template) => { + const { pages } = await renderOverflow(template, "main", "en-US", "semantic", { fullWidth }); + expect(pages).toHaveLength(2); + const page = pages[1]; + if (!page) throw new Error("Missing explicit second page"); + expect(page.lines.some((line) => line.str === "Margin Audit")).toBe(false); + const top = Math.min(...page.lines.map((line) => page.height - line.transform[5] - line.height)); + expect(top).toBeGreaterThanOrEqual(45.8); + expect(top).toBeLessThanOrEqual(54); + expect(page.lines.some((line) => line.str.startsWith("Body line 0"))).toBe(true); + }, + ); + } +});