mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 10:43:46 +10:00
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:
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user