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
This commit is contained in:
Amruth Pillai
2026-09-05 09:33:13 -07:00
committed by GitHub
parent 0207e5dfcc
commit bf71253ca4
7 changed files with 280 additions and 14 deletions
@@ -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 (
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
style={composeStyles(
styles.page,
{ paddingVertical: metrics.page.paddingVertical },
pageMinHeightStyle,
semanticPageStyle,
)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
<SemanticRegionView
region="main"
style={composeStyles(styles.mainColumn, {
marginTop: -metrics.page.paddingVertical,
paddingTop: metrics.page.paddingVertical,
paddingRight: page.fullWidth ? metrics.page.paddingHorizontal : metrics.columnGap,
paddingBottom: metrics.page.paddingVertical,
paddingLeft: metrics.page.paddingHorizontal,
rowGap: metrics.sectionGap,
})}
@@ -95,16 +102,20 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader,
style={composeStyles(styles.sidebarColumn, {
display: page.fullWidth ? "none" : "flex",
flexBasis: `${metadata.layout.sidebarWidth}%`,
marginTop: -metrics.page.paddingVertical,
paddingTop:
showHeader && hasPicture
? metrics.page.paddingVertical + picture.size + metrics.itemGapY * 3
: metrics.page.paddingVertical,
paddingRight: metrics.page.paddingHorizontal,
paddingBottom: metrics.page.paddingVertical,
paddingLeft: metrics.columnGap,
rowGap: metrics.sectionGap,
})}
>
<PageMarginBackground
color={sidebarStyle?.backgroundColor ?? colors.primary}
margin={metrics.page.paddingVertical}
/>
{sidebarSections.map((section) => (
<Fragment key={section}>
<Section section={section} placement="sidebar" />
@@ -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
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
style={composeStyles(
styles.page,
{ paddingVertical: metrics.page.paddingVertical },
pageMinHeightStyle,
semanticPageStyle,
)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors} features={ditgarFeatures}>
{showSidebar && (
<View
style={composeStyles(styles.sidebarColumn, {
width: `${metadata.layout.sidebarWidth}%`,
marginTop: -metrics.page.paddingVertical,
})}
>
<PageMarginBackground
color={colors.sidebarBackground ?? colors.background}
margin={metrics.page.paddingVertical}
/>
{showHeader && <Header styles={styles} colors={colors} />}
{!page.fullWidth && (
@@ -111,7 +122,7 @@ export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pag
</View>
)}
<View style={styles.mainColumn}>
<View style={composeStyles(styles.mainColumn, { marginTop: -metrics.page.paddingVertical })}>
{featuredSummarySection && (
<SemanticRegionTemplatePartView
region="featured"
@@ -244,7 +255,6 @@ const useDitgarTemplate = (): DitgarTemplate => {
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,
@@ -75,12 +75,17 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader, page
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
style={composeStyles(
styles.page,
{ paddingVertical: metrics.page.paddingVertical },
pageMinHeightStyle,
semanticPageStyle,
)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.contentRow, { paddingTop: metrics.headerGap })}>
<View style={composeStyles(styles.contentRow, { paddingTop: showHeader ? metrics.headerGap : 0 })}>
<SemanticRegionView
region="sidebar"
style={composeStyles(styles.sidebarColumn, {
@@ -199,6 +204,7 @@ const useDittoTemplate = (): DittoTemplate => {
backgroundColor: primary,
},
header: {
marginTop: -metrics.page.paddingVertical,
position: "relative",
},
headerBand: {
@@ -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
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
style={composeStyles(
styles.page,
{ paddingVertical: metrics.page.paddingVertical },
pageMinHeightStyle,
semanticPageStyle,
)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors} features={glalieFeatures}>
{showSidebar && (
<SemanticTemplatePartView
ownerNodeKey={semanticNodeKeys.region(pageNodeKey, "sidebar")}
partKeys={["sidebar-background"]}
fixed
style={styles.sidebarBackground}
/>
)}
<View style={styles.layout}>
<View style={composeStyles(styles.layout, { marginTop: -metrics.page.paddingVertical })}>
{showSidebar && (
<View
style={composeStyles(styles.sidebarColumn, {
width: `${metadata.layout.sidebarWidth}%`,
})}
>
<PageMarginBackground
color={colors.sidebarBackground ?? colors.background}
margin={metrics.page.paddingVertical}
/>
{showHeader && <Header styles={styles} />}
{!page.fullWidth && (
@@ -79,12 +79,17 @@ export const LeafishPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pa
<Page
{...semanticPageProps}
size={semanticPageSize ?? pageSize}
style={composeStyles(styles.page, pageMinHeightStyle, semanticPageStyle)}
style={composeStyles(
styles.page,
{ paddingVertical: metrics.page.paddingVertical },
pageMinHeightStyle,
semanticPageStyle,
)}
>
<TemplateProvider pageNodeKey={pageNodeKey} styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={styles.body}>
<View style={composeStyles(styles.body, showHeader ? undefined : { paddingTop: 0 })}>
<SemanticRegionView region="main" style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
{mainSections.map((section) => (
<Section key={section} section={section} placement="main" />
@@ -183,7 +188,7 @@ const useLeafishTemplate = (): LeafishTemplate => {
levelItemActive: {
backgroundColor: primary,
},
header: {},
header: { marginTop: -metrics.page.paddingVertical },
headerIntro: {
backgroundColor: primaryTintLight,
paddingHorizontal: metrics.page.paddingHorizontal,
@@ -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) => (
<>
<View
fixed
style={{ position: "absolute", top: -margin, height: margin, left: 0, right: 0, backgroundColor: color }}
/>
<View
fixed
style={{ position: "absolute", bottom: -margin, height: margin, left: 0, right: 0, backgroundColor: color }}
/>
</>
);
@@ -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) => `<p>Body line ${index} with work details and sample content for page flow.</p>`,
).join(""),
},
];
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof renderToBuffer>[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<ReturnType<typeof rasterizePdf>>[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);
},
);
}
});