From def4f72169e768bd39b7f62e450c63eae08fd843 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 16:01:16 +0200 Subject: [PATCH] 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. --- packages/pdf/package.json | 1 + packages/pdf/src/document.tsx | 19 +++ .../pdf/src/page-map.integration.test.tsx | 141 ++++++++++++++++++ packages/pdf/src/page-map.ts | 112 ++++++++++++++ .../pdf/src/templates/shared/primitives.tsx | 2 + .../pdf/src/templates/shared/sections.test.ts | 2 +- .../pdf/src/templates/shared/sections.tsx | 4 +- 7 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 packages/pdf/src/page-map.integration.test.tsx create mode 100644 packages/pdf/src/page-map.ts diff --git a/packages/pdf/package.json b/packages/pdf/package.json index c284fb86d..16cdc8afb 100644 --- a/packages/pdf/package.json +++ b/packages/pdf/package.json @@ -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", diff --git a/packages/pdf/src/document.tsx b/packages/pdf/src/document.tsx index 53aed556f..7f58492a6 100644 --- a/packages/pdf/src/document.tsx +++ b/packages/pdf/src/document.tsx @@ -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 ( {resumeData.metadata.layout.pages.map((page, index) => ( 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 => { + let pageMap: PageMap | undefined; + const element = createElement(ResumeDocument, { + data, + template, + resolveSectionTitle, + onPageMap: (map) => { + pageMap = map; + }, + }) as unknown as Parameters[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); + } + }); +}); diff --git a/packages/pdf/src/page-map.ts b/packages/pdf/src/page-map.ts new file mode 100644 index 000000000..cedb4a049 --- /dev/null +++ b/packages/pdf/src/page-map.ts @@ -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; + 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-`, 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 }; +}; diff --git a/packages/pdf/src/templates/shared/primitives.tsx b/packages/pdf/src/templates/shared/primitives.tsx index 2f673ece4..83c74d3e1 100644 --- a/packages/pdf/src/templates/shared/primitives.tsx +++ b/packages/pdf/src/templates/shared/primitives.tsx @@ -119,6 +119,7 @@ export const Div = ({ return ( @@ -341,6 +342,7 @@ export const SemanticHeaderView = ({ style, ...props }: ComponentProps { expect(source).toContain( "const resolvedSectionStyle = composeStyles(sectionStyle, sectionRuleStyle, resolved.style)", ); - expect(source).toContain(""); + expect(source).toContain(""); expect(source).toContain(""); }); diff --git a/packages/pdf/src/templates/shared/sections.tsx b/packages/pdf/src/templates/shared/sections.tsx index 4697d8e94..f55fc4b9c 100644 --- a/packages/pdf/src/templates/shared/sections.tsx +++ b/packages/pdf/src/templates/shared/sections.tsx @@ -347,7 +347,7 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio // No icon: render heading exactly as before (no structural change) return ( - + {showHeading && sectionHeadingEnabled && ( {sectionTitle} )} @@ -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 ( - + {showHeading && sectionHeadingEnabled && sectionHeadingVisible && (