From 873835a571842b50e68462fdcb7d2de99e396649 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 10:14:12 -0700 Subject: [PATCH] fix(pdf): align Ditgar item headers with body text (#3445) * fix(pdf): align Ditgar item headers with body text * refactor(pdf): share Ditgar header border width --- .../src/templates/ditgar/DitgarPage.test.tsx | 116 ++++++++++++++++++ .../pdf/src/templates/ditgar/DitgarPage.tsx | 7 +- 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 packages/pdf/src/templates/ditgar/DitgarPage.test.tsx diff --git a/packages/pdf/src/templates/ditgar/DitgarPage.test.tsx b/packages/pdf/src/templates/ditgar/DitgarPage.test.tsx new file mode 100644 index 000000000..a718f471d --- /dev/null +++ b/packages/pdf/src/templates/ditgar/DitgarPage.test.tsx @@ -0,0 +1,116 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { describe, expect, it } from "vitest"; +import { renderToBuffer } from "@react-pdf/renderer"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { act } from "react"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { ResumeDocument } from "../../document"; + +type FixtureOptions = { + mode: "legacy" | "semantic"; + columns: number; + gapX: number; + rtl?: boolean; + sidebar?: boolean; + longTitle?: boolean; + experience?: boolean; + css?: string; +}; + +type TextRun = { text: string; x: number; right: number; y: number }; + +function fixture({ mode, columns, gapX, rtl, sidebar, longTitle, experience, css = "" }: FixtureOptions): ResumeData { + const data = structuredClone(defaultResumeData); + data.metadata.typography.body.fontFamily = "Helvetica"; + data.metadata.typography.heading.fontFamily = "Helvetica"; + data.metadata.page.gapX = gapX; + data.metadata.page.hideIcons = true; + if (rtl) data.metadata.page.locale = "ar-SA"; + const section = experience ? "experience" : "projects"; + data.metadata.layout.pages = [ + { fullWidth: false, main: sidebar ? [] : [section], sidebar: sidebar ? [section] : [] }, + ]; + data.metadata.stylesheet = { mode, source: { languageVersion: 1, text: `@version 1; ${css}` } }; + data.sections.projects.columns = columns; + data.sections.experience.columns = columns; + for (let index = 0; index < 3; index++) { + const name = `Project${index}${longTitle ? " wrapping project title with several additional words" : ""}`; + const shared = { + id: `project-${index}`, + hidden: false, + period: "", + description: `

Description${index} with enough words to wrap across the narrower column and preserve alignment.

`, + website: { url: `https://example.com/${index}`, label: `Website${index}`, inlineLink: false }, + }; + if (experience) { + data.sections.experience.items.push({ ...shared, company: name, position: "", location: "", roles: [] }); + } else data.sections.projects.items.push({ ...shared, name }); + } + return data; +} + +async function renderText(options: FixtureOptions): Promise { + const bytes = await act(() => renderToBuffer()); + const loading = getDocument({ data: new Uint8Array(bytes), useSystemFonts: true }); + try { + const document = await loading.promise; + expect(document.numPages).toBe(1); + const page = await document.getPage(1); + return (await page.getTextContent()).items.flatMap((item) => + "str" in item && item.str + ? [{ text: item.str, x: item.transform[4], right: item.transform[4] + item.width, y: item.transform[5] }] + : [], + ); + } finally { + await loading.destroy(); + } +} + +function assertAligned(runs: TextRun[], rtl = false, expectedOffset = 0) { + for (let index = 0; index < 3; index++) { + const title = runs.find((run) => run.text.startsWith(`Project${index}`)); + const description = runs.find((run) => run.text.startsWith(`Description${index}`)); + const website = runs.find((run) => run.text === `Website${index}`); + if (!title || !description || !website) throw new Error(`Missing project ${index} text`); + const edge = rtl ? "right" : "x"; + expect(title[edge] - description[edge]).toBeCloseTo(expectedOffset, 3); + // Ditgar's separate website links stay left-aligned in RTL layouts. + if (!rtl) expect(title.x - website.x).toBeCloseTo(expectedOffset, 3); + else expect(website.right).toBeLessThanOrEqual(title.right); + expect(title.y).toBeGreaterThan(description.y); + } +} + +describe("Ditgar item-header alignment (#3068)", () => { + for (const mode of ["legacy", "semantic"] as const) { + for (const columns of [1, 2]) { + it.each([0, 4, 12])(`aligns ${mode} Projects in ${columns} columns at gapX %i`, async (gapX) => { + assertAligned(await renderText({ mode, columns, gapX })); + }); + } + it(`aligns wrapped ${mode} project titles with their descriptions and links`, async () => { + assertAligned(await renderText({ mode, columns: 2, gapX: 4, longTitle: true })); + }); + it(`aligns the shared ${mode} Experience header`, async () => { + assertAligned(await renderText({ mode, columns: 2, gapX: 4, experience: true })); + }); + it(`preserves ${mode} sidebar alignment`, async () => { + assertAligned(await renderText({ mode, columns: 1, gapX: 4, sidebar: true })); + }); + it(`preserves ${mode} RTL alignment`, async () => { + assertAligned(await renderText({ mode, columns: 2, gapX: 4, rtl: true }), true); + }); + } + it("retains an authored Semantic CSS header inset", async () => { + assertAligned( + await renderText({ + mode: "semantic", + columns: 2, + gapX: 4, + css: "item-header { margin-left: 7pt; padding-left: 3pt; border-left: 2pt solid #0000ff; }", + }), + false, + 12, + ); + }); +}); diff --git a/packages/pdf/src/templates/ditgar/DitgarPage.tsx b/packages/pdf/src/templates/ditgar/DitgarPage.tsx index b840959fb..3d793445d 100644 --- a/packages/pdf/src/templates/ditgar/DitgarPage.tsx +++ b/packages/pdf/src/templates/ditgar/DitgarPage.tsx @@ -66,6 +66,8 @@ const ditgarFeatures = { stackSidebarItemHeader: true, } satisfies TemplateFeatures; +const ditgarItemHeaderBorderWidth = 2; + export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => { const data = useRender(); const pageNodeKey = semanticNodeKeys.page(pageNumber); @@ -331,11 +333,12 @@ const useDitgarTemplate = (): DitgarTemplate => { rowGap: 0, ...(context.placement === "main" ? { - borderLeftWidth: 2, + borderLeftWidth: ditgarItemHeaderBorderWidth, borderLeftColor: accentFor(context), paddingLeft: metrics.gapX(0.5), paddingVertical: metrics.gapY(0.125), - marginLeft: -metrics.gapX(0.625), + // Keep header text aligned with the body after its border and padding. + marginLeft: -(ditgarItemHeaderBorderWidth + metrics.gapX(0.5)), } : {}), }),