feat(pdf): map rendered header, section and item boxes

Templates tag the views that own the header, each section and each item
with data-resume-node. ResumeDocument's new onPageMap callback reads React
PDF's layout tree after each render and returns page-relative boxes, so the
editor can link lines on the page to entries in the panel. The attribute
stays on layout nodes and never reaches the PDF.
This commit is contained in:
Amruth Pillai
2026-09-28 16:01:16 +02:00
parent b0aecdfb5a
commit def4f72169
7 changed files with 278 additions and 3 deletions
+1
View File
@@ -6,6 +6,7 @@
"exports": {
"./browser": "./src/browser.tsx",
"./document": "./src/document.tsx",
"./page-map": "./src/page-map.ts",
"./semantic": "./src/semantic/index.ts",
"./semantic-legacy": "./src/semantic/legacy-converter.ts",
"./semantic-tree": "./src/semantic/tree.ts",
+19
View File
@@ -3,12 +3,14 @@ import type { Template } from "@reactive-resume/schema/templates";
import type { Locale } from "@reactive-resume/utils/locale";
import type { ComponentType } from "react";
import type { ResumeRenderOptions } from "./context";
import type { PageMap } from "./page-map";
import type { SectionTitleResolver } from "./section-title";
import type { ResolvedResumeRuntime } from "./semantic";
import { useMemo } from "react";
import { Document } from "#react-pdf-renderer";
import { RenderProvider } from "./context";
import { registerFonts, resumeContentContainsCJK, resumeContentScripts } from "./hooks/use-register-fonts";
import { extractPageMap } from "./page-map";
import { SemanticRenderProvider } from "./semantic/context";
import { resolveResumeRuntime, resolveStylesheetMode } from "./semantic/resolve";
import { getTemplatePage } from "./templates";
@@ -31,6 +33,8 @@ type ResumeDocumentProps = {
renderOptions?: ResumeRenderOptions | undefined;
resolveSectionTitle?: SectionTitleResolver | undefined;
semanticRuntime?: ResolvedResumeRuntime | undefined;
/** Receives the rendered page map (header, section and item boxes) after each render. */
onPageMap?: ((pageMap: PageMap) => void) | undefined;
};
const getLayoutPageKey = (page: LayoutPage, pageIndex: number) =>
@@ -42,6 +46,7 @@ export const ResumeDocument = ({
renderOptions,
resolveSectionTitle,
semanticRuntime,
onPageMap,
}: ResumeDocumentProps) => {
const TemplatePageComponent = getTemplatePage(template);
const creationDate = useMemo(() => new Date(), []);
@@ -67,6 +72,19 @@ export const ResumeDocument = ({
[resumeData, semanticRuntime, stylesheetMode, template],
);
const semanticMode = semanticRuntime ? "semantic" : stylesheetMode;
// React PDF calls `onRender` inside its stream handler, so a throw here would fail the render.
const pageMapProps = onPageMap
? {
onRender: (params: unknown) => {
try {
const layout = (params as { _INTERNAL__LAYOUT__DATA_?: unknown } | undefined)?._INTERNAL__LAYOUT__DATA_;
onPageMap(extractPageMap(layout));
} catch {
// The page map is an editor aid; a PDF must never fail because of it.
}
},
}
: {};
return (
<SemanticRenderProvider
@@ -85,6 +103,7 @@ export const ResumeDocument = ({
creator={resumeData.basics.name}
subject={resumeData.basics.headline}
language={resumeData.metadata.page.locale}
{...pageMapProps}
>
{resumeData.metadata.layout.pages.map((page, index) => (
<TemplatePageComponent
@@ -0,0 +1,141 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { PageMap } from "./page-map";
import type { SectionTitleResolver } from "./section-title";
import { describe, expect, it } from "vitest";
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { templateSchema } from "@reactive-resume/schema/templates";
import { ResumeDocument } from "./document";
import { extractPageMap, parseResumeNodeKey } from "./page-map";
const resolveSectionTitle: SectionTitleResolver = (input) => input.defaultEnglishTitle ?? input.sectionId;
// The sample's picture points at a web path that doesn't exist in Node; hide it so renders stay quiet.
const data: ResumeData = { ...sampleResumeData, picture: { ...sampleResumeData.picture, hidden: true } };
const renderPageMap = async (template: Template): Promise<PageMap> => {
let pageMap: PageMap | undefined;
const element = createElement(ResumeDocument, {
data,
template,
resolveSectionTitle,
onPageMap: (map) => {
pageMap = map;
},
}) as unknown as Parameters<typeof renderToBuffer>[0];
await renderToBuffer(element);
if (!pageMap) throw new Error("onPageMap was not called");
return pageMap;
};
describe("parseResumeNodeKey", () => {
it("reads headers, sections and items, and ignores deeper nodes", () => {
expect(parseResumeNodeKey("page-1/region-header/header")).toEqual({ kind: "header" });
expect(parseResumeNodeKey("page-1/region-main/section-experience")).toEqual({
kind: "section",
sectionId: "experience",
});
expect(parseResumeNodeKey("page-2/region-main/section-experience/section-items/item-a%2Fb")).toEqual({
kind: "item",
sectionId: "experience",
itemId: "a/b",
});
expect(parseResumeNodeKey("page-1/region-main/section-sidebar%3Askills/section-items/item-s")).toEqual({
kind: "item",
sectionId: "skills",
itemId: "s",
});
expect(parseResumeNodeKey("page-1/region-main/section-experience/section-heading")).toBeUndefined();
expect(
parseResumeNodeKey("page-1/region-main/section-experience/section-items/item-a/item-header"),
).toBeUndefined();
expect(parseResumeNodeKey("page-1/region-main")).toBeUndefined();
});
});
describe("extractPageMap", () => {
it("sums parent offsets into page-relative boxes", () => {
const layout = {
type: "DOCUMENT",
children: [
{
type: "PAGE",
box: { left: 0, top: 0, width: 600, height: 800 },
children: [
{
type: "VIEW",
box: { left: 20, top: 30, width: 500, height: 200 },
props: { "data-resume-node": "page-1/region-main/section-skills" },
children: [
{
type: "VIEW",
box: { left: 5, top: 40, width: 100, height: 20 },
props: { "data-resume-node": "page-1/region-main/section-skills/section-items/item-x" },
},
],
},
],
},
],
};
expect(extractPageMap(layout)).toEqual({
pages: [{ width: 600, height: 800 }],
nodes: [
{
kind: "section",
sectionId: "skills",
key: "page-1/region-main/section-skills",
page: 0,
x: 20,
y: 30,
width: 500,
height: 200,
},
{
kind: "item",
sectionId: "skills",
itemId: "x",
key: "page-1/region-main/section-skills/section-items/item-x",
page: 0,
x: 25,
y: 70,
width: 100,
height: 20,
},
],
});
});
it("returns an empty map for missing layout data", () => {
expect(extractPageMap(undefined)).toEqual({ pages: [], nodes: [] });
});
});
describe("rendered page maps", () => {
const visibleExperienceIds = data.sections.experience.items.filter((item) => !item.hidden).map((item) => item.id);
it.each(templateSchema.options)("%s maps the header and every experience entry onto the page", async (template) => {
const { pages, nodes } = await renderPageMap(template);
expect(pages.length).toBeGreaterThan(0);
expect(nodes.some((node) => node.kind === "header" && node.page === 0)).toBe(true);
const mappedItems = new Set(
nodes.flatMap((node) => (node.kind === "item" && node.sectionId === "experience" ? [node.itemId] : [])),
);
for (const id of visibleExperienceIds) expect(mappedItems, `${template}: experience item ${id}`).toContain(id);
for (const node of nodes) {
const page = pages[node.page];
expect(page, `${template}: ${node.key} page`).toBeDefined();
if (!page) continue;
expect(node.x, `${template}: ${node.key} x`).toBeGreaterThanOrEqual(-0.5);
expect(node.y, `${template}: ${node.key} y`).toBeGreaterThanOrEqual(-0.5);
expect(node.x + node.width, `${template}: ${node.key} right`).toBeLessThanOrEqual(page.width + 0.5);
expect(node.y + node.height, `${template}: ${node.key} bottom`).toBeLessThanOrEqual(page.height + 0.5);
}
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Maps rendered PDF regions back to resume data, so the editor can outline the block a field
* belongs to and select an entry when its line on the page is clicked.
*
* Templates tag the views that own a semantic node with `RESUME_NODE_PROP` (see
* `templates/shared/primitives.tsx` and `templates/shared/sections.tsx`). React PDF keeps non-style
* props on its layout nodes and hands the layout tree to `Document.onRender` as
* `_INTERNAL__LAYOUT__DATA_`. That tree isn't a public API, so everything here reads it defensively.
*/
export const RESUME_NODE_PROP = "data-resume-node";
export type PageMapTarget =
| { kind: "header" }
| { kind: "section"; sectionId: string }
| { kind: "item"; sectionId: string; itemId: string };
export type PageMapNode = PageMapTarget & {
key: string;
/** Physical page index, 0-based. One authored page can spill onto several physical pages. */
page: number;
/** Position and size in PDF points, relative to the page's top-left corner. */
x: number;
y: number;
width: number;
height: number;
};
export type PageMap = {
pages: { width: number; height: number }[];
nodes: PageMapNode[];
};
type LayoutBox = { left?: number; top?: number; width?: number; height?: number };
type LayoutNode = {
type?: string;
box?: LayoutBox;
props?: Record<string, unknown>;
children?: LayoutNode[];
};
const decode = (value: string) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
/**
* Reads a semantic node key (see `semantic/node-keys.ts`), e.g.
* `page-1/region-main/section-experience/section-items/item-<id>`, as the part of the resume it
* renders. Only the header, sections and items are addressable; deeper keys (fields, icons,
* template parts) return `undefined` because they live inside one of those blocks.
*/
export const parseResumeNodeKey = (key: string): PageMapTarget | undefined => {
const segments = key.split("/");
const last = segments.at(-1);
if (!last) return;
if (last === "header") return { kind: "header" };
const sectionSegment = segments.find(
(segment) => segment.startsWith("section-") && segment !== "section-items" && segment !== "section-heading",
);
if (!sectionSegment) return;
// Templates that merge main and sidebar into one region qualify ids with their origin
// (`main:experience`, see `semantic/tree.ts`); section ids never contain a colon otherwise.
const sectionId = decode(sectionSegment.slice("section-".length)).replace(/^(main|sidebar):/, "");
if (last === sectionSegment) return { kind: "section", sectionId };
if (last.startsWith("item-") && segments.at(-2) === "section-items") {
return { kind: "item", sectionId, itemId: decode(last.slice("item-".length)) };
}
};
const finite = (value: number | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
/**
* Walks React PDF's layout tree and returns page-relative boxes for every tagged header, section
* and item. Child boxes in that tree are relative to their parent's box (React PDF translates by
* each parent's `left`/`top` while painting), so offsets are summed on the way down.
*
* ponytail: CSS `transform` on a tagged node or its ancestors isn't applied; templates don't
* transform blocks today. Apply the transform matrix here if one ever does.
*/
export const extractPageMap = (layout: unknown): PageMap => {
const root = layout as LayoutNode | undefined;
const pages: PageMap["pages"] = [];
const nodes: PageMapNode[] = [];
const visit = (node: LayoutNode, page: number, offsetX: number, offsetY: number) => {
const box = node.box ?? {};
const x = offsetX + finite(box.left);
const y = offsetY + finite(box.top);
const key = node.props?.[RESUME_NODE_PROP];
if (typeof key === "string") {
const target = parseResumeNodeKey(key);
if (target) nodes.push({ ...target, key, page, x, y, width: finite(box.width), height: finite(box.height) });
}
for (const child of node.children ?? []) visit(child, page, x, y);
};
for (const pageNode of root?.children ?? []) {
const page = pages.length;
pages.push({ width: finite(pageNode.box?.width), height: finite(pageNode.box?.height) });
for (const child of pageNode.children ?? []) visit(child, page, 0, 0);
}
return { pages, nodes };
};
@@ -119,6 +119,7 @@ export const Div = ({
return (
<View
{...props}
data-resume-node={resolvedNodeKey}
{...resolvedPdfFlowProps(resolved)}
style={composeStyles(divStyle, style as Style | Style[] | undefined, resolved.style)}
/>
@@ -341,6 +342,7 @@ export const SemanticHeaderView = ({ style, ...props }: ComponentProps<typeof Vi
<SemanticNodeKeyProvider nodeKey={nodeKey}>
<View
{...props}
data-resume-node={nodeKey}
{...resolvedPdfFlowProps(regionResolved)}
{...resolvedPdfFlowProps(resolved)}
style={composeStyles(asStyleInput(style), regionResolved.style, resolved.style)}
@@ -36,7 +36,7 @@ describe("SectionShell", () => {
expect(source).toContain(
"const resolvedSectionStyle = composeStyles(sectionStyle, sectionRuleStyle, resolved.style)",
);
expect(source).toContain("<View style={resolvedSectionStyle} {...flowProps}>");
expect(source).toContain("<View style={resolvedSectionStyle} {...flowProps} data-resume-node={sectionNodeKey}>");
expect(source).toContain("<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>");
});
@@ -347,7 +347,7 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio
// No icon: render heading exactly as before (no structural change)
return (
<SemanticNodeKeyProvider nodeKey={sectionNodeKey}>
<View style={resolvedSectionStyle} {...flowProps}>
<View style={resolvedSectionStyle} {...flowProps} data-resume-node={sectionNodeKey}>
{showHeading && sectionHeadingEnabled && (
<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>{sectionTitle}</Heading>
)}
@@ -360,7 +360,7 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio
// With icon: wrap in a flex row container that inherits the heading's border/decoration
return (
<SemanticNodeKeyProvider nodeKey={sectionNodeKey}>
<View style={resolvedSectionStyle} {...flowProps}>
<View style={resolvedSectionStyle} {...flowProps} data-resume-node={sectionNodeKey}>
{showHeading && sectionHeadingEnabled && sectionHeadingVisible && (
<View
{...resolvedPdfFlowProps(sectionHeadingResolved)}