diff --git a/NOTICE b/NOTICE index c72a23d0..dcaa2b1b 100644 --- a/NOTICE +++ b/NOTICE @@ -16,7 +16,9 @@ limitations under the License. Third-party fonts ----------------- -This product includes the Poppins typeface (src/assets/fonts/), embedded in -downloaded resume PDFs. Copyright 2020 The Poppins Project Authors -(https://github.com/itfoundry/Poppins), licensed under the SIL Open Font -License, Version 1.1. Full license text: src/assets/fonts/Poppins-OFL.txt. +This product includes the Liberation Sans typeface (src/assets/fonts/), +embedded in downloaded resume PDFs. Digitized data copyright 2010 Google +Corporation with Reserved Font Arimo, Tinos and Cousine; copyright 2012 Red +Hat, Inc. with Reserved Font Name Liberation, licensed under the SIL Open +Font License, Version 1.1. Full license text: +src/assets/fonts/LiberationSans-OFL.txt. diff --git a/scripts/check-no-literal-nul.mjs b/scripts/check-no-literal-nul.mjs index 51a3de6e..6e0bb0d3 100644 --- a/scripts/check-no-literal-nul.mjs +++ b/scripts/check-no-literal-nul.mjs @@ -55,7 +55,7 @@ const ROOT = fileURLToPath(new URL("..", import.meta.url)); * * Every entry is a format that carries NULs as a matter of course, so scanning * it would report thousands of findings about files nobody edits by hand. The - * live ones in this repo are the two Poppins faces under `src/assets/fonts/` + * live ones in this repo are the two Liberation Sans faces under `src/assets/fonts/` * (~15k NULs each) and the 58 PDF fixtures under `tests/fixtures/pdfs/`; the * rest are here so the first `.png` or `.woff2` someone commits does not fail * the build for a reason that has nothing to do with them. diff --git a/scripts/check-no-literal-nul.test.mjs b/scripts/check-no-literal-nul.test.mjs index 0d57cc37..15b247b5 100644 --- a/scripts/check-no-literal-nul.test.mjs +++ b/scripts/check-no-literal-nul.test.mjs @@ -61,7 +61,7 @@ describe("nulPositions", () => { describe("isBinaryPath", () => { it("skips the font and PDF assets that legitimately carry NULs", () => { - expect(isBinaryPath("src/assets/fonts/Poppins-Bold.ttf")).toBe(true); + expect(isBinaryPath("src/assets/fonts/LiberationSans-Bold.ttf")).toBe(true); expect(isBinaryPath("tests/fixtures/pdfs/latex/awesome-cv-cv.pdf")).toBe(true); }); diff --git a/scripts/fixture-derive.ts b/scripts/fixture-derive.ts index 988ec3e7..c35bd986 100644 --- a/scripts/fixture-derive.ts +++ b/scripts/fixture-derive.ts @@ -29,7 +29,7 @@ * prints or persists a résumé field VALUE. * * Runs under vite-node (`runCascade` needs no pdfjs `?url` setup here). The - * round-trip hop's Poppins font fetch fails under vite-node and falls back to + * round-trip hop's body-font fetch fails under vite-node and falls back to * Helvetica with a stderr warning — expected and harmless; the render succeeds. * * Usage: diff --git a/scripts/fixtures/gen-extended-latin-name.mjs b/scripts/fixtures/gen-extended-latin-name.mjs index 2243d67f..2a183fcc 100644 --- a/scripts/fixtures/gen-extended-latin-name.mjs +++ b/scripts/fixtures/gen-extended-latin-name.mjs @@ -16,7 +16,7 @@ * The mechanism is the #295 `toWinAnsi()` render sanitizer, whose lossy * degradation #326 recorded as an accepted by-design tradeoff (degrade a glyph, * never crash the export). `toWinAnsi()` runs on the STANDARD-FONT path — i.e. - * whenever the Poppins embed does not happen: always under Node (the font is a + * whenever the Liberation Sans embed does not happen: always under Node (the font is a * bundler `?url` asset there is no server to fetch), and in the browser whenever * that fetch fails. The two `experience` baselines #326 records * (`google-docs-skia-proxy-classic`, `weasyprint-cairo-classic`) are the same @@ -35,8 +35,8 @@ * left to round-trip — a separate, real defect, filed nowhere yet. * * The SOURCE pdf must embed a font that actually has the glyph (pdf-lib's - * `StandardFonts` throw on it), so this generator embeds the repo's own Poppins - * via fontkit — the same TTF `render-ats-pdf.ts` tries to embed. + * `StandardFonts` throw on it), so this generator embeds the repo's own + * Liberation Sans via fontkit — the same TTF `render-ats-pdf.ts` embeds. * * Everything is drawn as a SINGLE column so `detectColumnBoundaries` finds no * gutter (`triggers` == `[]`). @@ -73,14 +73,14 @@ const BLACK = rgb(0, 0, 0); const doc = await PDFDocument.create(); doc.registerFontkit(fontkit); const page = doc.addPage([612, 792]); -// `subset: true` keeps the committed fixture small — the full Poppins pair is -// ~300 kB, and this résumé touches under a hundred glyphs. +// `subset: true` keeps the committed fixture small — the full Liberation Sans +// pair is well over 100 kB, and this résumé touches under a hundred glyphs. const font = await doc.embedFont( - readFileSync(join(FONT_DIR, "Poppins-Regular.ttf")), + readFileSync(join(FONT_DIR, "LiberationSans-Regular.ttf")), { subset: true }, ); const bold = await doc.embedFont( - readFileSync(join(FONT_DIR, "Poppins-Bold.ttf")), + readFileSync(join(FONT_DIR, "LiberationSans-Bold.ttf")), { subset: true }, ); diff --git a/src/assets/fonts/LiberationSans-Bold.ttf b/src/assets/fonts/LiberationSans-Bold.ttf new file mode 100644 index 00000000..ee237154 Binary files /dev/null and b/src/assets/fonts/LiberationSans-Bold.ttf differ diff --git a/src/assets/fonts/LiberationSans-OFL.txt b/src/assets/fonts/LiberationSans-OFL.txt new file mode 100644 index 00000000..aba73e8a --- /dev/null +++ b/src/assets/fonts/LiberationSans-OFL.txt @@ -0,0 +1,102 @@ +Digitized data copyright (c) 2010 Google Corporation + with Reserved Font Arimo, Tinos and Cousine. +Copyright (c) 2012 Red Hat, Inc. + with Reserved Font Name Liberation. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE The goals of the Open Font License (OFL) are to stimulate +worldwide development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to provide +a free and open framework in which fonts may be shared and improved in +partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. +The fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + + + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. +This may include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components +as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting ? in part or in whole ? +any of the components of the Original Version, by changing formats or +by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer +or other person who contributed to the Font Software. + + +PERMISSION & CONDITIONS + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components,in + Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the + corresponding Copyright Holder. This restriction only applies to the + primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +5) The Font Software, modified or unmodified, in part or in whole, must + be distributed entirely under this license, and must not be distributed + under any other license. The requirement for fonts to remain under + this license does not apply to any document created using the Font + Software. + + + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + + + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + diff --git a/src/assets/fonts/LiberationSans-Regular.ttf b/src/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 00000000..366d1489 Binary files /dev/null and b/src/assets/fonts/LiberationSans-Regular.ttf differ diff --git a/src/assets/fonts/Poppins-Bold.ttf b/src/assets/fonts/Poppins-Bold.ttf deleted file mode 100644 index 1982f38a..00000000 Binary files a/src/assets/fonts/Poppins-Bold.ttf and /dev/null differ diff --git a/src/assets/fonts/Poppins-OFL.txt b/src/assets/fonts/Poppins-OFL.txt deleted file mode 100644 index 76df3b56..00000000 --- a/src/assets/fonts/Poppins-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/fonts/Poppins-Regular.ttf b/src/assets/fonts/Poppins-Regular.ttf deleted file mode 100644 index 0bda228a..00000000 Binary files a/src/assets/fonts/Poppins-Regular.ttf and /dev/null differ diff --git a/src/hooks/useDownloadPdf.test.tsx b/src/hooks/useDownloadPdf.test.tsx index bbc00711..9e8ad4eb 100644 --- a/src/hooks/useDownloadPdf.test.tsx +++ b/src/hooks/useDownloadPdf.test.tsx @@ -159,13 +159,37 @@ describe("useDownloadPdf — download-source tagging (#313)", () => { /** * #664 — refuse rather than silently substituting "?" in the user's own fields. * - * These cases need no font stub: under Node the Poppins `?url` asset is a bare - * path that `fetch` cannot resolve, so the export ALWAYS takes the Helvetica - * fallback here. That is the same condition a browser hits when the font fetch - * fails, which makes this environment the natural place to test the refusal — - * the ASCII cases above prove the same path still downloads normally. + * The real, correctly-served embedded font (Liberation Sans) covers ś/ł, so + * there is nothing to refuse over on the happy path — `render-ats-pdf.fonts. + * test.ts`'s "reports nothing when the embedded font loads" pins that. To + * exercise the refusal, this block stubs `fetch` to fail and forces a fresh + * module instance via `vi.resetModules()` + a dynamic re-import (the same + * pattern `render-ats-pdf.fonts.test.ts` uses) rather than relying on the + * statically-imported `useDownloadPdf` above: `render-ats-pdf.ts` memoizes a + * SUCCESSFUL font fetch at module scope for the life of that module instance + * (#664 — so a repeat download doesn't re-fetch), and the tagging describe + * above already forced one real success against the shared instance. Without + * a fresh module, `loadBodyFontBytes()` would short-circuit on that cached + * success and never call the failing stub at all. */ describe("useDownloadPdf — glyph-loss refusal (#664)", () => { + let freshUseDownloadPdf: typeof useDownloadPdf; + + beforeEach(async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network unavailable (simulated)"); + }), + ); + vi.resetModules(); + ({ useDownloadPdf: freshUseDownloadPdf } = await import("./useDownloadPdf.ts")); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + function namedResult(fullName: string): CascadeResult { const base = uploadedResult(); return { @@ -177,8 +201,27 @@ describe("useDownloadPdf — glyph-loss refusal (#664)", () => { }; } + function FreshProbe({ result }: { result: CascadeResult }) { + const score = computeAnonymousAtsScore({ + parsed: result.canonical.fields, + fieldConfidence: result.canonical.fieldConfidence, + triggers: result.triggers, + rawText: result.rawText, + sections: result.canonical.sections, + }); + api = freshUseDownloadPdf(result, score, onDownloaded); + return null; + } + + function mountFresh(result: CascadeResult): void { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root.render()); + } + it("refuses the download and names the field when the fallback would mangle the name", async () => { - mount(namedResult("ANNA WIŚNIEWSKA")); + mountFresh(namedResult("ANNA WIŚNIEWSKA")); await act(async () => { await api.download(); @@ -192,7 +235,7 @@ describe("useDownloadPdf — glyph-loss refusal (#664)", () => { }); it("fires no download, no analytics event and no journey mark when it refuses", async () => { - mount(namedResult("ANNA WIŚNIEWSKA")); + mountFresh(namedResult("ANNA WIŚNIEWSKA")); await act(async () => { await api.download(); @@ -209,7 +252,7 @@ describe("useDownloadPdf — glyph-loss refusal (#664)", () => { it("still downloads a pure-ASCII résumé on the same failing-font path", async () => { // The gate that keeps a font problem from blocking everyone: this is the // identical code path as the refusal above, differing only in the data. - mount(namedResult("Jane Candidate")); + mountFresh(namedResult("Jane Candidate")); await act(async () => { await api.download(); diff --git a/src/hooks/useDownloadPdf.ts b/src/hooks/useDownloadPdf.ts index e387d3d4..0be97864 100644 --- a/src/hooks/useDownloadPdf.ts +++ b/src/hooks/useDownloadPdf.ts @@ -11,7 +11,7 @@ * temporary object URL. * * Zero-egress holds, but not because nothing is fetched: the renderer DOES issue - * a `fetch` for the vendored Poppins TTFs, and this docblock previously claimed + * a `fetch` for the vendored Liberation Sans TTFs, and this docblock previously claimed * "no network request is made (no font fetch, no upload)", which was false. The * fetch targets the app's own bundled-asset origin — never a font CDN — so no * résumé bytes leave the browser, which is the actual guarantee. Say custody, @@ -22,7 +22,7 @@ * after the bytes have reached the user: the refusal below is the one thing that * stops a download, and reporting must never grow into a second one. * - * This hook owns the refusal for #664. When the Poppins fetch fails, the + * This hook owns the refusal for #664. When the font fetch fails, the * renderer falls back to Helvetica, whose WinAnsi codec replaces anything * outside it with `?` — including a candidate's own name. Rather than hand back * a PDF reading `ANNA WI?NIEWSKA`, the hook probes first and refuses, because diff --git a/src/lib/heuristics/corpus-roundtrip.known-failures.json b/src/lib/heuristics/corpus-roundtrip.known-failures.json index a2cff9ec..b6862c67 100644 --- a/src/lib/heuristics/corpus-roundtrip.known-failures.json +++ b/src/lib/heuristics/corpus-roundtrip.known-failures.json @@ -26,30 +26,5 @@ "summary", "render" ], - "baselines": { - "google-docs/google-docs-skia-proxy-classic.pdf": [ - { - "category": "experience", - "issue": 326, - "status": "accepted", - "note": "The ONLY diff is a Unicode glyph the #295 toWinAnsi() render sanitizer rewrites lossily: a role title '… Intern → Junior Engineer' round-trips as '… Intern -> Junior Engineer' (→ U+2192 → '->'). #326 records the decision: no-crash beats glyph fidelity, and a real fix needs a Unicode-capable embedded font. Delete when such a font lands." - } - ], - "unknown/weasyprint-cairo-classic.pdf": [ - { - "category": "experience", - "issue": 326, - "status": "accepted", - "note": "Same single-glyph toWinAnsi() substitution as google-docs-skia-proxy-classic; see #326's decision record." - } - ], - "unknown/extended-latin-name-roundtrip.pdf": [ - { - "category": "contact", - "issue": 664, - "status": "accepted", - "note": "full_name 'ANNA WIŚNIEWSKA' → 'ANNA WI?NIEWSKA'. ś (U+015B) is Latin Extended-A, outside WinAnsi, so toWinAnsi() degrades it to '?' on the standard-font path. ACCEPTED under #664 with an explicit written decision — not inherited from #326, whose recorded tradeoff covers a punctuation glyph in a role TITLE and does not extend to an identity field. The decision: the USER-FACING export refuses rather than degrading. useDownloadPdf calls findExportGlyphLosses() before rendering and, on the Helvetica fallback, blocks the download and names the affected fields instead of handing back a mangled name. That refusal deliberately does NOT live in renderAtsResumePdf, so this corpus row keeps exercising the un-refused path: the round-trip oracle needs a rendered PDF to measure, and it is the only fixture in the corpus with a character outside WinAnsi (verified: 0 of 57 expected snapshots contain one). So the '?' recorded here is the intentional Node/CI behaviour, not an open defect. Delete this row if a Unicode-capable font ever removes the fallback entirely. Minted under #654 as the corpus's first evidence for roundtrip-contact-value-changed." - } - ] - } + "baselines": {} } diff --git a/src/lib/heuristics/corpus.test.ts b/src/lib/heuristics/corpus.test.ts index a549a1c9..33743690 100644 --- a/src/lib/heuristics/corpus.test.ts +++ b/src/lib/heuristics/corpus.test.ts @@ -412,7 +412,7 @@ describe("corpus snapshots", () => { // The nine `*ChangedAcrossRoundtrip` bits come from the export → // re-parse hop, so they pin EXACT BITS of `pdf-lib`'s render output // AND of the font-fallback path (the current goldens were baked with - // "Poppins font embed failed, falling back to Helvetica"). A pdf-lib + // "Body font embed failed, falling back to Helvetica"). A pdf-lib // bump, a font-loading fix, or a change to `render-ats-pdf.ts` can // therefore turn corpus tests red WITHOUT any parser change. // diff --git a/src/lib/heuristics/multi-experience-roundtrip.test.ts b/src/lib/heuristics/multi-experience-roundtrip.test.ts index 1ae865d0..ed6d80f4 100644 --- a/src/lib/heuristics/multi-experience-roundtrip.test.ts +++ b/src/lib/heuristics/multi-experience-roundtrip.test.ts @@ -10,11 +10,12 @@ * reconstructed-PDF export → re-parse. The verbatim source heading of each group * is preserved (extending #285 from one heading to per-group), and the * round-trip fidelity target holds at the SECTION level on the way IN and at the - * ROLE level end-to-end (no role lost). The 2 → 2 SECTION round-trip is a #436 - * known gap: main's one-line experience header (#434) renders each role as a - * single dated line under its category heading, which the text-only re-parser - * reads as a company entry (#354 suppression) and flattens the two groups to one. - * Closing that needs the one-line-header title/company disambiguation in #436. + * ROLE level end-to-end (no role lost). The 2 → 2 SECTION round-trip currently + * holds too, but only because the export keeps main's one-line experience header + * (#434) on ONE line. When that header wraps, the text-only re-parser reads the + * dated continuation as a company entry (#354 suppression) and flattens the two + * groups to one — the #436 mechanism, still unfixed. The durable fix is the + * one-line-header title/company disambiguation tracked in #436. * * Scoring is intentionally NOT grouped — every role pools flat regardless of * label — so this file asserts only the grouping/round-trip contract; the corpus @@ -105,16 +106,45 @@ describe("#311 multiple experience sections — parse + round-trip", { timeout: expect(distinctLabels(parse1.canonical.fields.experience ?? []).length).toBe(2); // No roles lost across the round-trip — every role survives export + re-parse. expect(parse3.canonical.fields.experience?.length).toBe(parse1.canonical.fields.experience?.length); - // #436 known gap: on the way BACK OUT the grouping currently flattens to a - // single unlabeled experience section — 0 distinct category labels survive. - // Main's one-line experience header (#434) renders each role as a single - // "Title · Company, Location Dates" line under the category heading; the - // text-only re-parser then reads the dated role line under "TEACHING - // EXPERIENCE" as a company entry (`isInstitutionRepeat` / #354 suppression) - // rather than a new category, so neither category heading re-emits a - // section_label. Restoring the 2 → 2 round-trip needs the one-line-header - // title/company disambiguation tracked in #436; this assertion tightens back - // to `.toBe(2)` when #436 lands. - expect(distinctLabels(parse3.canonical.fields.experience ?? []).length).toBe(0); + // Both category labels survive the round-trip — but by GEOMETRY, not because + // #436 landed, so do not read this as the known gap being closed. + // + // The #436 mechanism is unchanged: main's one-line experience header (#434) + // renders each role as a single "Title · Company, Location Dates" line under + // its category heading, and when that line WRAPS the text-only re-parser reads + // the dated continuation as a company entry (`isInstitutionRepeat` / #354 + // suppression) rather than a new category, flattening both groups to 0 labels. + // While that header fits on one line the misread never happens. + // + // Measured on this fixture at every rung of the export fit ladder (8, 8.5, 9, + // 9.5, 10): 2 labels survive at all five. That is a wider margin than it was + // under the Poppins this engine used to embed, where the same fixture held 2 + // only at <= 8.5pt and flattened to 0 at >= 9 — Liberation Sans is narrow + // enough that the header no longer wraps at any size we ship. + // + // The assertion is still load-bearing on that header not wrapping, so a longer + // company name would put it back at 0 even now. Until #436's title/company + // disambiguation lands, a change here means the export geometry moved, not + // that the parser regressed. + expect(distinctLabels(parse3.canonical.fields.experience ?? []).length).toBe(2); + }); + + it("holds the 2 → 2 grouping at every fit-ladder rung (8, 8.5, 9, 9.5, 10)", async () => { + // Sweeps what the previous test's comment only claimed: the one-line + // experience header must not wrap at ANY rung `fitToPage` can pick, since + // the round-trip in the previous test only exercises whichever rung the + // fit pass happens to choose for this fixture. + const bytes = await fsp.readFile(FIXTURE); + const parse1 = await runCascade(new Uint8Array(bytes)); + const model = buildAtsResumeModel(parse1, scoreOf(parse1)); + + for (const bodyPt of [8, 8.5, 9, 9.5, 10] as const) { + const { bytes: exportedBytes } = await renderAtsResumePdf(model, { bodyPt }); + const parse3 = await runCascade(new Uint8Array(exportedBytes)); + expect( + distinctLabels(parse3.canonical.fields.experience ?? []).length, + `rung ${bodyPt}pt`, + ).toBe(2); + } }); }); diff --git a/src/lib/pdf/export-layout-contract.test.ts b/src/lib/pdf/export-layout-contract.test.ts index a5738614..35daa757 100644 --- a/src/lib/pdf/export-layout-contract.test.ts +++ b/src/lib/pdf/export-layout-contract.test.ts @@ -9,7 +9,7 @@ * - REVERSE-CHRONOLOGICAL entries (document order, as parsed). * - CANONICAL section headers — every heading the exporter emits must be a * header our OWN parser re-recognizes on re-upload. - * - STANDARD fonts (Poppins with a Helvetica fallback, both text-layer fonts). + * - STANDARD fonts (Liberation Sans with a Helvetica fallback, both text-layer fonts). * * This test enforces the headers half: it asserts that every heading the * exporter can emit — the canonical fallback set, PLUS every verbatim @@ -42,7 +42,7 @@ import { computeAnonymousAtsScore } from "../score/score.ts"; import type { CascadeResult } from "../heuristics/types.ts"; import type { AtsEntry, AtsResumeModel } from "./ats-resume-model.ts"; import { buildAtsResumeModel } from "./ats-resume-model.ts"; -import { renderAtsResumePdf } from "./render-ats-pdf.ts"; +import { REFERENCE_BODY_PT, renderAtsResumePdf } from "./render-ats-pdf.ts"; import { extractPdfDrawnLines, type PdfDrawnLine, @@ -147,20 +147,24 @@ function fillerEntry(bullets: number): AtsEntry { * of a 2/1 or 1/2 break strands a lone line — so it can only move whole. */ const THREE_LINE_BULLET = - "BULLETSTART partnered across engineering, product and design to land a " + - "platform initiative that measurably improved customer outcomes BULLETMID " + - "and then carried the same practice into the wider organisation BULLETEND"; + "BULLETSTART partnered closely across engineering, product, design and " + + "operations leadership to land a platform initiative that measurably " + + "improved customer outcomes across every core segment BULLETMID and then " + + "carried the same practice into the wider organisation over several " + + "subsequent quarters of sustained delivery BULLETEND"; const THREE_LINE_TOKENS = ["BULLETSTART", "BULLETMID", "BULLETEND"]; /** A bullet wrapping to exactly FOUR drawn lines, one token per line. Four is * the shortest bullet that CAN legally split (2/2), so it is the case that * exercises break placement rather than a whole-bullet reservation (#631). */ const FOUR_LINE_BULLET = - "BULLETSTART partnered across engineering, product and design to land a " + - "platform initiative that measurably improved customer outcomes BULLETTWO " + - "and then carried the same practice into the wider organisation, writing " + - "the runbooks BULLETTHREE and training the on-call rotation before handing " + - "the whole programme over to its permanent owners BULLETEND"; + "BULLETSTART partnered closely across engineering, product, design and " + + "operations leadership to land a platform initiative that measurably " + + "improved customer outcomes across every core segment BULLETTWO and then " + + "carried the same practice into the wider organisation, writing the " + + "complete set of on-call runbooks and operational playbooks BULLETTHREE " + + "and training the whole on-call rotation before handing the entire " + + "programme over to its permanent long-term owners BULLETEND"; const FOUR_LINE_TOKENS = [ "BULLETSTART", "BULLETTWO", @@ -181,11 +185,25 @@ const withFillerBefore = ], }); +/** + * Every contract in this file is about WHERE the engine breaks a page, so each + * render pins the body size instead of letting the fit pass choose it. + * + * Unpinned, these tests would be vacuous rather than wrong: the fit pass exists + * to keep a résumé on one page, and it would shrink each deliberately-oversized + * fixture below until there was no page break left to make a claim about. The + * rung itself is arbitrary — the reference size keeps the fixtures calibrated + * where they already were. + */ +const PINNED = { bodyPt: REFERENCE_BODY_PT } as const; + async function drawnLines( build: ModelBuilder, filler: number, ): Promise { - return extractPdfDrawnLines((await renderAtsResumePdf(build(filler))).bytes); + return extractPdfDrawnLines( + (await renderAtsResumePdf(build(filler), PINNED)).bytes, + ); } /** Index of the single drawn line containing `token` (fails if 0 or 2+ match). */ @@ -766,7 +784,9 @@ describe("export layout contract — keep-with-next pagination (#629)", () => { }, ], }; - const lines = await extractPdfDrawnLines((await renderAtsResumePdf(model)).bytes); + const lines = await extractPdfDrawnLines( + (await renderAtsResumePdf(model, PINNED)).bytes, + ); const pages = Math.max(...lines.map((l) => l.page)); const densest = Math.max( ...Array.from({ length: pages }, (_, i) => linesOnPage(lines, i + 1)), @@ -827,7 +847,7 @@ describe("export layout contract — the Summary body honours widow control", () * {@link bulletLinesPerPage} returns, so a `1` is the widow. */ async function summaryLinesPerPage(words: number): Promise { const lines = await extractPdfDrawnLines( - (await renderAtsResumePdf(summaryModel(words))).bytes, + (await renderAtsResumePdf(summaryModel(words), PINNED)).bytes, ); const perPage = new Map(); for (const line of lines) { @@ -843,7 +863,7 @@ describe("export layout contract — the Summary body honours widow control", () * Bracketed on both sides: the low end must fit page one entirely and the high * end must spill, which is what stops the window below from being vacuous. */ - async function spillBoundary(max = 800): Promise { + async function spillBoundary(max = 1400): Promise { expect( (await summaryLinesPerPage(0)).length, "an empty summary must not spill onto page two", diff --git a/src/lib/pdf/load-pdf-lib.ts b/src/lib/pdf/load-pdf-lib.ts index da899a06..3ad06584 100644 --- a/src/lib/pdf/load-pdf-lib.ts +++ b/src/lib/pdf/load-pdf-lib.ts @@ -12,13 +12,13 @@ * * We load `PDFDocument`, `StandardFonts`, `rgb` — the pdf-lib core the * exporter always needs — plus `fontkit`, the custom-font engine pdf-lib - * requires to embed a TrueType font (Poppins, #314) instead of relying solely + * requires to embed a TrueType font (Liberation Sans, #314) instead of relying solely * on the 14 built-in Helvetica-family fonts. `fontkit` ships no usable `.d.ts` * default-export shape (`export as namespace fontkit`), so it's typed * `unknown` here and cast at the one call site that hands it to * `doc.registerFontkit()` (render-ats-pdf.ts) — this keeps the untyped surface * to a single line rather than threading `any` through this module. Both - * fontkit and the Poppins TTF bytes it embeds are dynamic-imported / fetched + * fontkit and the TTF bytes it embeds are dynamic-imported / fetched * lazily (see render-ats-pdf.ts), so the entry chunk does not grow. */ diff --git a/src/lib/pdf/render-ats-pdf.flush-right-links.test.ts b/src/lib/pdf/render-ats-pdf.flush-right-links.test.ts index 573a2c6a..9f73f714 100644 --- a/src/lib/pdf/render-ats-pdf.flush-right-links.test.ts +++ b/src/lib/pdf/render-ats-pdf.flush-right-links.test.ts @@ -13,8 +13,8 @@ import { renderAtsResumePdf } from "./render-ats-pdf.ts"; import type { AtsResumeModel } from "./ats-resume-model.ts"; const PAGE_WIDTH = 612; -const MARGIN = 54; -const RIGHT_EDGE = PAGE_WIDTH - MARGIN; // 558 +const MARGIN = 36; // matches production's MARGIN (render-ats-pdf.ts), post-#878 +const RIGHT_EDGE = PAGE_WIDTH - MARGIN; // 576 interface Item { str: string; diff --git a/src/lib/pdf/render-ats-pdf.fonts.test.ts b/src/lib/pdf/render-ats-pdf.fonts.test.ts index 40b1fa51..b18300a5 100644 --- a/src/lib/pdf/render-ats-pdf.fonts.test.ts +++ b/src/lib/pdf/render-ats-pdf.fonts.test.ts @@ -2,12 +2,12 @@ // Copyright 2026 The offlinecv Authors /** - * render-ats-pdf.fonts.test.ts — Poppins font-embed behavior (#314). + * render-ats-pdf.fonts.test.ts — Body-font embed behavior (#314). * * Split from render-ats-pdf.test.ts because these tests need to control - * `global.fetch` (the mechanism `loadPoppinsBytes()` uses to read the - * bundled Poppins TTFs) and reset the module registry between cases — the - * module-scoped `poppinsBytesPromise` cache means a rejected fetch would + * `global.fetch` (the mechanism `loadBodyFontBytes()` uses to read the + * bundled Liberation Sans TTFs) and reset the module registry between cases — the + * module-scoped `bodyFontBytesPromise` cache means a rejected fetch would * otherwise "stick" for the rest of the file. Each case stubs `fetch`, then * `vi.resetModules()` + a fresh dynamic `import()` so it starts from a clean * cache. @@ -22,8 +22,8 @@ import type { AtsResumeModel } from "./ats-resume-model.ts"; const FONTS_DIR = fileURLToPath( new URL("../../assets/fonts/", import.meta.url), ); -const REGULAR_BYTES = readFileSync(`${FONTS_DIR}Poppins-Regular.ttf`); -const BOLD_BYTES = readFileSync(`${FONTS_DIR}Poppins-Bold.ttf`); +const REGULAR_BYTES = readFileSync(`${FONTS_DIR}LiberationSans-Regular.ttf`); +const BOLD_BYTES = readFileSync(`${FONTS_DIR}LiberationSans-Bold.ttf`); function toArrayBuffer(buf: Buffer): ArrayBuffer { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); @@ -33,7 +33,7 @@ function toArrayBuffer(buf: Buffer): ArrayBuffer { function stubFetchSucceeds() { const fetchMock = vi.fn(async (input: string | URL) => { const url = String(input); - // Zero-egress guard (#314 AC): the URL loadPoppinsBytes() fetches must be + // Zero-egress guard (#314 AC): the URL loadBodyFontBytes() fetches must be // a local/bundled asset path, never an external host or font CDN. expect(url).not.toMatch(/^https?:\/\//); expect(url.toLowerCase()).not.toContain("fonts.gstatic.com"); @@ -108,22 +108,22 @@ const model = (text: string): AtsResumeModel => ({ sections: [], }); -// Each case does a real fontkit Poppins-embed render (the failing glyph case +// Each case does a real fontkit embed render (the failing glyph case // renders twice); slow under a coverage-instrumented full-suite `verify` run, // so scope a higher timeout to just this suite rather than bumping vitest's // global default (#360). -describe("Poppins font embed (#314)", { timeout: 20000 }, () => { +describe("Body font embed (#314)", { timeout: 20000 }, () => { afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); }); - it("embeds Poppins (a /FontFile2 TrueType program is present) when the local asset fetch succeeds", async () => { + it("embeds Liberation Sans (a /FontFile2 TrueType program is present) when the local asset fetch succeeds", async () => { const fetchMock = stubFetchSucceeds(); vi.resetModules(); const { renderAtsResumePdf } = await import("./render-ats-pdf.ts"); - const { bytes } = await renderAtsResumePdf(model("Poppins embed check")); + const { bytes } = await renderAtsResumePdf(model("Liberation Sans embed check")); expect(fetchMock).toHaveBeenCalled(); await expect(hasEmbeddedFontFile2(bytes)).resolves.toBe(true); }); @@ -139,8 +139,8 @@ describe("Poppins font embed (#314)", { timeout: 20000 }, () => { await expect(hasEmbeddedFontFile2(bytes)).resolves.toBe(false); }); - it("renders a Latin-Extended glyph (ł) under embedded Poppins that the Helvetica fallback degrades to '?'", async () => { - // Embedded path: Poppins' cmap covers "ł" (verified via fontkit). + it("renders a Latin-Extended glyph (ł) under the embedded font that the Helvetica fallback degrades to '?'", async () => { + // Embedded path: Liberation Sans' cmap covers "ł" (verified via fontkit). stubFetchSucceeds(); vi.resetModules(); const { renderAtsResumePdf: renderEmbedded } = await import( @@ -172,10 +172,11 @@ describe("Poppins font embed (#314)", { timeout: 20000 }, () => { // A NUL is strictly worse than the WinAnsi path's "?": it is invisible on // screen, and it rides into every downstream consumer of the re-parsed field. describe("a glyph the embedded font lacks (#664, embedded half)", () => { - // Verified against the vendored Poppins-Regular.ttf via fontkit's - // `hasGlyphForCodePoint`: Poppins covers "ś"/"ł" but NOT these three. + // Verified against the vendored LiberationSans-Regular.ttf via fontkit's + // `hasGlyphForCodePoint`: Liberation Sans covers "ś"/"ł" — and, unlike the + // Poppins it replaced, "→" as well (see the covered-glyph case below) — but + // NOT these two. const UNCOVERED = [ - { ch: "→", name: "rightwards arrow", want: "->" }, { ch: "★", name: "black star", want: "?" }, { ch: "✓", name: "check mark", want: "?" }, ]; @@ -199,29 +200,51 @@ describe("Poppins font embed (#314)", { timeout: 20000 }, () => { }); } - it("still embeds Poppins — the fix sanitizes, it does not fall back to Helvetica", async () => { - // Guards the conservative fallback in `makePoppinsSanitizer`: if the + // The arrow is the case that motivated #664 — a real role title read back + // out of a download with a NUL where "→" had been. Under Poppins the best + // available outcome was the "->" transliteration; Liberation Sans has the + // glyph, so the character now survives the round-trip as itself. + // + // Asserted as its own case rather than folded into UNCOVERED because the + // two make opposite claims, and this one is the reason the font changed. + it("draws → intact — Liberation Sans covers it, so no transliteration", async () => { + stubFetchSucceeds(); + vi.resetModules(); + const { renderAtsResumePdf } = await import("./render-ats-pdf.ts"); + + const { bytes } = await renderAtsResumePdf( + model("Software Engineer Intern → Junior Engineer"), + ); + const text = await extractPdfText(bytes); + + expect(text).not.toContain("\0"); + expect(text).toContain("→"); + expect(text).not.toContain("->"); + }); + + it("still embeds Liberation Sans — the fix sanitizes, it does not fall back to Helvetica", async () => { + // Guards the conservative fallback in `makeEmbeddedFontSanitizer`: if the // coverage probe silently failed we would sanitize with `toWinAnsi`, the - // "→" case above would STILL pass, and every Latin-Extended glyph would + // "★" case above would STILL pass, and every Latin-Extended glyph would // regress unnoticed. Pin that the embedded font is genuinely in use and // that both properties hold at once. stubFetchSucceeds(); vi.resetModules(); const { renderAtsResumePdf } = await import("./render-ats-pdf.ts"); - const { bytes } = await renderAtsResumePdf(model("Łukasz → Wrocław")); + const { bytes } = await renderAtsResumePdf(model("Łukasz ★ Wrocław")); const text = await extractPdfText(bytes); await expect(hasEmbeddedFontFile2(bytes)).resolves.toBe(true); expect(text).not.toContain("\0"); - expect(text).toContain("->"); + expect(text).toContain("?"); // The whole point of the embedded path, still true after the fix. expect(text).toContain("ł"); }); }); // A NUL already IN the input is a distinct class from an uncovered glyph, and - // the one the coverage probe cannot be trusted for: Poppins reports a real + // the one the coverage probe cannot be trusted for: the font reports a real // glyph for U+0000 (`hasGlyphForCodePoint(0) === true` in both vendored // faces), so a probe-first sanitizer emits it verbatim. // @@ -230,7 +253,7 @@ describe("Poppins font embed (#314)", { timeout: 20000 }, () => { // every later export. Both font paths must eat it. describe("a NUL already in the input (#664, the probe's blind spot)", () => { for (const { label, stub } of [ - { label: "embedded Poppins", stub: stubFetchSucceeds }, + { label: "embedded Liberation Sans", stub: stubFetchSucceeds }, { label: "Helvetica fallback", stub: stubFetchFails }, ]) { it(`drops a NUL under ${label}`, async () => { @@ -254,7 +277,7 @@ describe("Poppins font embed (#314)", { timeout: 20000 }, () => { // `fonts.bold` — a different TTF from `fonts.regular`. Both faces are // probed, so this holds independently of the two files' coverage // matching; without that, a Regular-only probe would be right by - // accident and a Poppins bump could silently reinstate the NUL here. + // accident and a font bump could silently reinstate the NUL here. stubFetchSucceeds(); vi.resetModules(); const { renderAtsResumePdf } = await import("./render-ats-pdf.ts"); @@ -301,8 +324,8 @@ describe("Poppins font embed (#314)", { timeout: 20000 }, () => { }); it("re-fetches after a failure instead of replaying the cached rejection", async () => { - // The defect: `poppinsBytesPromise` is memoized and the guard is - // `!poppinsBytesPromise`, so a REJECTED promise used to stay cached for + // The defect: `bodyFontBytesPromise` is memoized and the guard is + // `!bodyFontBytesPromise`, so a REJECTED promise used to stay cached for // the life of the page. Every later download replayed it without issuing // a request, which silently made "check your connection and try again" // impossible to satisfy. @@ -350,7 +373,7 @@ describe("Poppins font embed (#314)", { timeout: 20000 }, () => { }); it("reports nothing when the embedded font loads, even with Latin-Extended text", async () => { - // Poppins covers ś/ł, so there is no loss to refuse over — the whole + // Liberation Sans covers ś/ł, so there is no loss to refuse over — the whole // reason this issue's original framing stopped being accurate. stubFetchSucceeds(); vi.resetModules(); diff --git a/src/lib/pdf/render-ats-pdf.test.ts b/src/lib/pdf/render-ats-pdf.test.ts index 524ddf64..2a010da9 100644 --- a/src/lib/pdf/render-ats-pdf.test.ts +++ b/src/lib/pdf/render-ats-pdf.test.ts @@ -2,7 +2,12 @@ // Copyright 2026 The offlinecv Authors import { describe, expect, it } from "vitest"; -import { renderAtsResumePdf, toWinAnsi, parseBoldRuns } from "./render-ats-pdf.ts"; +import { + renderAtsResumePdf, + toWinAnsi, + parseBoldRuns, + REFERENCE_BODY_PT, +} from "./render-ats-pdf.ts"; import { EMPHASIS_OPEN, EMPHASIS_CLOSE, @@ -74,7 +79,9 @@ describe("renderAtsResumePdf", () => { contact: { name: "Jane Candidate", links: [] }, sections: [{ heading: "Experience", entries: manyEntries }], }; - const { bytes } = await renderAtsResumePdf(bigModel); + const { bytes } = await renderAtsResumePdf(bigModel, { + bodyPt: REFERENCE_BODY_PT, + }); const pdfjs = await import("pdfjs-dist"); const doc = await pdfjs.getDocument({ data: bytes.slice(), @@ -162,7 +169,8 @@ describe("renderAtsResumePdf", () => { headerLine: "Principal Engineer", subLine: "Global Interdisciplinary Research and Development Consortium " + - "International Institute Limited · " + + "International Institute Limited for Advanced Cross-Sector " + + "Collaborative Studies and Applied Innovation · " + "San Francisco Bay Area 2020 – 2024", bullets: [], }, diff --git a/src/lib/pdf/render-ats-pdf.ts b/src/lib/pdf/render-ats-pdf.ts index fcc87c05..3e0b7589 100644 --- a/src/lib/pdf/render-ats-pdf.ts +++ b/src/lib/pdf/render-ats-pdf.ts @@ -5,15 +5,15 @@ * render-ats-pdf — the single-column, text-only ATS PDF draw engine (#171). * * Renders an `AtsResumeModel` to PDF bytes using pdf-lib. The brand font - * (Poppins) is embedded when its vendored TTF bytes load and pdf-lib accepts + * (Liberation Sans) is embedded when its vendored TTF bytes load and pdf-lib accepts * them (#314); on ANY failure the engine falls back to pdf-lib's built-in * Helvetica / Helvetica-Bold (`StandardFonts`), so a downloaded PDF is never * blocked by a font problem. Either way: no images, no rasterization, no * network egress — every glyph is selectable, searchable text, and the - * Poppins bytes are bundled locally + fetched from the app's own origin (see + * Font bytes are bundled locally + fetched from the app's own origin (see * `loadFonts()` below), never a CDN. * - * Layout: US Letter (612×792 pt), single column, ~54pt margins. The engine + * Layout: US Letter (612×792 pt), single column, 36pt margins. The engine * tracks a `y` cursor from the top margin downward; when the next line would * cross the bottom margin it adds a page and resets the cursor. Long lines are * word-wrapped by measuring with `font.widthOfTextAtSize`; bullets get a "• " @@ -91,14 +91,16 @@ import { type BulletSplit, type RenderFinding, } from "./render-findings.ts"; -import poppinsRegularUrl from "../../assets/fonts/Poppins-Regular.ttf?url"; -import poppinsBoldUrl from "../../assets/fonts/Poppins-Bold.ttf?url"; +import bodyFontRegularUrl from "../../assets/fonts/LiberationSans-Regular.ttf?url"; +import bodyFontBoldUrl from "../../assets/fonts/LiberationSans-Bold.ttf?url"; // ── Page geometry (points) ──────────────────────────────────────────────────── const PAGE_WIDTH = 612; // US Letter const PAGE_HEIGHT = 792; -const MARGIN = 54; +// 54 → 36 (#878): narrower margin recovers page width for the fit ladder's +// larger rungs; #878's own contact-line example (540.0pt) is worked out at 36. +const MARGIN = 36; const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2; const CONTENT_BOTTOM = MARGIN; // Drawable height of a FRESH page — `newPage()` resets the cursor to @@ -111,7 +113,7 @@ const USABLE_PAGE_HEIGHT = PAGE_HEIGHT - MARGIN - CONTENT_BOTTOM; // // Font-signal stance (#284, Part 2 — documented limitation, not a fix here). // This engine DOES emit bold (Helvetica-Bold) and a real type scale — a role -// header is bold at SIZE_HEADER, its date muted at SIZE_SUB, bullets at SIZE_BODY. +// header is bold at `t.header`, its date muted at `t.sub`, bullets at `t.body`. // Those signals are, however, invisible to the round-trip: our own text-only // parser classifies role title / company / bullet purely from text shape and // x/y geometry — `groupIntoLines` collapses per-glyph `fontSize`/`fontName` away @@ -123,32 +125,101 @@ const USABLE_PAGE_HEIGHT = PAGE_HEIGHT - MARGIN - CONTENT_BOTTOM; // separate change (it would touch `groupIntoLines` retention + `entry-blocks` // anchoring) and is intentionally out of scope here; if we later want // font-aware parsing, file it as its own follow-up. -const SIZE_NAME = 18; -// Professional headline under the name (#425) — regular weight, sized between -// the name and the contact line so it reads as a subordinate title, not a -// second name. -const SIZE_HEADLINE = 11; -const SIZE_CONTACT = 9; -const SIZE_SECTION = 11; -const SIZE_HEADER = 10.5; -const SIZE_SUB = 9.5; -const SIZE_BODY = 10; - -// Line-height multiplier applied to the font size for vertical advance. -const LINE_GAP = 1.25; -// Extra vertical breathing room (points) between blocks. -const GAP_AFTER_CONTACT = 10; -const GAP_BEFORE_SECTION = 12; -const GAP_AFTER_RULE = 6; -const GAP_BETWEEN_ENTRIES = 7; -const GAP_AFTER_HEADER = 2; -// Vertical space the section-heading rule consumes (`drawRule`). Named because -// the keep-with-next reservation for a section heading must include it (#629) — -// the heading, its rule, and the first line of its content move as one unit. -const RULE_HEIGHT = 2; +// Every size and gap below is expressed at ONE reference body size and scaled +// together by {@link makeTypeScale}, because the fit pass re-renders the same +// résumé at successively smaller rungs and the proportions have to survive the +// shrink. Scaling the body text alone does not work: the gaps, the rule and the +// name block are ~140pt of fixed overhead — roughly 19% of the usable page — so +// leaving them at full size cuts the achievable shrink by about a third. +/** The body size {@link BASE} is written at. Exported so a test that must hold + * geometry still — every pagination contract — can pin the size the fit pass + * would otherwise choose for it. */ +export const REFERENCE_BODY_PT = 8.5; +const BASE = { + name: 18, + // Professional headline under the name (#425) — regular weight, sized between + // the name and the contact line so it reads as a subordinate title, not a + // second name. + headline: 11, + contact: 9, + section: 10, + header: 9.5, + sub: 8.5, + body: 8.5, + gapAfterContact: 7, + gapBeforeSection: 7, + gapAfterRule: 3, + gapBetweenEntries: 3, + gapAfterHeader: 2, + // Vertical space the section-heading rule consumes (`drawRule`). Named because + // the keep-with-next reservation for a section heading must include it (#629) — + // the heading, its rule, and the first line of its content move as one unit. + ruleHeight: 2, +} as const; + +/** Line-height multiplier applied to the font size for vertical advance. A + * ratio, so it is the one value {@link makeTypeScale} must NOT scale. + * 1.25 → 1.15 (#878): the old value was tuned against the fixed 8.5pt body; + * at the fit ladder's larger rungs it cost more vertical space than the + * bigger type needed, so it's tightened here alongside the margin change. */ +const LINE_GAP = 1.15; + +/** Every point-valued layout input for one render pass, at one body size. */ +export type TypeScale = { readonly [K in keyof typeof BASE]: number } & { + /** The rung this scale was built at — what the fit pass selected. */ + readonly bodyPt: number; +}; + +/** + * Build the type scale for a body size, holding every proportion fixed. + * + * `makeTypeScale(REFERENCE_BODY_PT)` reproduces {@link BASE} exactly, so the + * geometry at the reference rung is the same layout this engine drew before the + * fit pass existed — the property `render-ats-pdf.type-scale.test.ts` pins. + */ +export function makeTypeScale(bodyPt: number): TypeScale { + const k = bodyPt / REFERENCE_BODY_PT; + const scaled = Object.fromEntries( + Object.entries(BASE).map(([key, pt]) => [key, pt * k]), + ) as { [K in keyof typeof BASE]: number }; + return { ...scaled, bodyPt }; +} + +/** + * Body sizes the fit pass tries, largest first (#-fit). + * + * The rungs are 0.5pt apart because the page height a résumé occupies is + * quantized by wrap counts — a three-line bullet stays three lines until it + * drops to two — so a finer ladder costs renders without moving the break. + * + * The floor is a READABILITY floor, not a layout one: at 8pt Liberation Sans + * the x-height is 4.22pt, which is about as small as résumé body text can be + * set and still survive a recruiter skim on screen. {@link fitToPage} ships + * whichever rung achieves the fewest pages (tie-broken to the largest) — see + * its docblock for the exact rule, which is narrower than "the floor" or "the + * top rung": a résumé that spills at every rung ships at whichever rung first + * reaches the minimum page count, not necessarily either extreme. + */ +const FIT_LADDER = [10, 9.5, 9, 8.5, 8] as const; + +/** How far {@link Layout.fitToOneLine} may shrink the contact line before it + * gives up and lets it wrap. Below this the line stops reading as contact + * detail and starts reading as a mistake. */ +const CONTACT_MIN_SIZE_RATIO = 0.8; + +/** + * The shave {@link Layout.fitToOneLine} applies to the size it computes. + * + * `size * CONTENT_WIDTH / width` is the exact fitting size in the reals, but + * re-multiplying it by the font's width-per-point in binary floating point can + * land a hair ABOVE `CONTENT_WIDTH` — enough for the wrapper's `<=` test to + * fail and hand back the two-line contact this whole path exists to prevent. + * One part in a billion is far below a rendering difference and far above the + * rounding error. + */ +const FIT_ONE_LINE_SHAVE = 1 - 1e-9; const BULLET_MARKER = "• "; -const BULLET_INDENT = 12; // hanging-indent width for wrapped bullet lines // Minimum drawn lines of a wrapped bullet that must land on EACH side of a page // break it straddles — the orphan half (#629, no lone line at a page bottom) and @@ -280,9 +351,9 @@ const WINANSI_TRANSLITERATIONS: Record = { * * An embedded font is not a licence to skip sanitization. pdf-lib does not * throw on a code point the embedded font lacks — it emits the font's - * `.notdef` glyph, which extracts back as **U+0000**. So a character Poppins + * `.notdef` glyph, which extracts back as **U+0000**. So a character the font * has no glyph for (U+2192 "→", U+2605 "★", U+2713 "✓" — verified against the - * vendored `Poppins-Regular.ttf`) survived to `drawText` unsanitized and + * vendored `LiberationSans-Regular.ttf`) survived to `drawText` unsanitized and * landed in the downloaded PDF as a NUL byte. Re-parsing that PDF reads the * NUL straight back into a user-facing field (a role title, a name), where it * is invisible on screen and travels into every downstream consumer. @@ -293,12 +364,12 @@ const WINANSI_TRANSLITERATIONS: Record = { * font could not reproduce it. * * `hasGlyph` is the font's own coverage predicate, so this degrades ONLY what - * the font genuinely cannot draw. Latin-Extended glyphs Poppins does cover + * the font genuinely cannot draw. Latin-Extended glyphs the font does cover * (e.g. "ś" in a candidate's name) still pass through untouched — the reason * the embedded path skipped `toWinAnsi()` in the first place, preserved here. * * The predicate is NOT trusted for control characters, which are dropped - * before it runs: Poppins answers `true` for U+0000, so a NUL already in an + * before it runs: the font answers `true` for U+0000, so a NUL already in an * input field (re-uploading a PDF exported by a pre-fix build puts one there) * would otherwise pass the probe and ride straight back out. */ @@ -316,7 +387,7 @@ function toEmbeddedFontSafe( continue; } // Other C0/C1 control characters: drop silently (as toWinAnsi does). - // This MUST precede the coverage probe. Poppins reports a real glyph for + // This MUST precede the coverage probe. The font reports a real glyph for // U+0000 (`hasGlyphForCodePoint(0) === true` in both vendored faces — the // one control code point it claims), so probing first would let the exact // byte this function exists to eliminate through verbatim, and would make @@ -394,16 +465,16 @@ type Doc = Awaited>; type Page = ReturnType; type PdfFont = Awaited>; -// ── Poppins font embed (#314) ───────────────────────────────────────────────── +// ── Body font embed (#314) ──────────────────────────────────────────────────── // -// The Poppins TTF bytes are bundled as Vite assets (imported via `?url` above +// The Liberation Sans TTF bytes are bundled as Vite assets (imported via `?url` above // — the same mechanism `src/main.tsx` uses for the pdfjs worker) and fetched // from the app's own bundled-asset origin at download time. That `fetch()` // never leaves the browser's own origin, so it does NOT violate offlinecv's // zero-egress guarantee — this is loading a local asset, not calling a font // CDN (e.g. `fonts.gstatic.com`), which is explicitly forbidden here. // Cached module-scoped so repeat downloads reuse the same fetched bytes. -let poppinsBytesPromise: Promise<{ +let bodyFontBytesPromise: Promise<{ regular: ArrayBuffer; bold: ArrayBuffer; }> | null = null; @@ -427,31 +498,31 @@ async function fetchFontBytes(url: string): Promise { return res.arrayBuffer(); } -function loadPoppinsBytes(): Promise<{ +function loadBodyFontBytes(): Promise<{ regular: ArrayBuffer; bold: ArrayBuffer; }> { - if (!poppinsBytesPromise) { - poppinsBytesPromise = Promise.all([ - fetchFontBytes(poppinsRegularUrl), - fetchFontBytes(poppinsBoldUrl), + if (!bodyFontBytesPromise) { + bodyFontBytesPromise = Promise.all([ + fetchFontBytes(bodyFontRegularUrl), + fetchFontBytes(bodyFontBoldUrl), ]) .then(([regular, bold]) => ({ regular, bold })) .catch((err: unknown) => { // Clear the memo on failure so the NEXT call re-fetches (#664). // - // The guard above is `!poppinsBytesPromise`, so without this the + // The guard above is `!bodyFontBytesPromise`, so without this the // REJECTED promise stays cached forever: one transient blip degraded // every subsequent download for the life of the page, even after the // network recovered. That also made a user-facing "try again" a lie — // the retry resolved to the same stored rejection without issuing a // request. Only the failure path is cleared; a successful fetch stays // memoized, which is what this cache is for. - poppinsBytesPromise = null; + bodyFontBytesPromise = null; throw err; }); } - return poppinsBytesPromise; + return bodyFontBytesPromise; } /** One field whose text the Helvetica fallback cannot draw intact (#664). */ @@ -484,7 +555,7 @@ export interface ExportGlyphLoss { * which is the complement of this, not a replacement: reporting never * refuses. * 2. **Empty when the embedded font loads.** It shares the memoized - * {@link loadPoppinsBytes}, so the probe and the subsequent render make one + * {@link loadBodyFontBytes}, so the probe and the subsequent render make one * fetch attempt between them, not two. On the failure path the memo is * cleared (that is what makes Retry work), so a *no-loss* résumé costs a * second failing attempt when the render then re-tries — acceptable, since @@ -495,7 +566,7 @@ export interface ExportGlyphLoss { * outside WinAnsi. * * Deliberate limit: this covers the **fallback** path only. On the embedded path - * `toEmbeddedFontSafe` can still emit `?` for a code point Poppins lacks (`★`, + * `toEmbeddedFontSafe` can still emit `?` for a code point the font lacks (`★`, * `✓`) — decorative symbols, not identity fields, and outside #664's scope. That * half is now REPORTED rather than refused, by the export findings * `renderAtsResumePdf` returns (#621); this remains the only gate that stops a @@ -514,7 +585,7 @@ export async function findExportGlyphLosses( model: AtsResumeModel, ): Promise { try { - await loadPoppinsBytes(); + await loadBodyFontBytes(); return []; } catch { // The embedded font is unavailable, so the render below would fall back to @@ -539,7 +610,7 @@ export async function findExportGlyphLosses( /** * Load the `{ regular, bold }` font pair the renderer draws with. Tries - * embedding the vendored Poppins TTFs (registering `@pdf-lib/fontkit` first, + * embedding the vendored Liberation Sans TTFs (registering `@pdf-lib/fontkit` first, * since pdf-lib's built-in `embedFont` can only parse the 14 standard fonts * without it); on ANY failure — fetch error, corrupt bytes, an embed * rejection — falls back to pdf-lib's built-in Helvetica / Helvetica-Bold, so @@ -548,7 +619,7 @@ export async function findExportGlyphLosses( * Returns the `sanitize` step to run before every `drawText`, chosen to match * the font that was actually loaded — never skipped. On the Helvetica fallback * that is `toWinAnsi()` (StandardFonts encode WinAnsi only, #295); on the - * embedded path it is {@link toEmbeddedFontSafe} bound to Poppins' own glyph + * embedded path it is {@link toEmbeddedFontSafe} bound to the embedded font's own glyph * coverage, because an embedded font silently emits `.notdef` (extracting as * U+0000) for a glyph it lacks rather than throwing. * @@ -575,10 +646,10 @@ async function loadFonts( // that hands it to pdf-lib's `registerFontkit` — the narrowest possible // untyped surface, rather than threading `any` through load-pdf-lib.ts. doc.registerFontkit(parts.fontkit as Parameters[0]); - const bytes = await loadPoppinsBytes(); + const bytes = await loadBodyFontBytes(); // `subset: true` prunes the embedded font to only the glyphs the résumé // actually uses — a downloaded PDF touches ~60–80 glyphs, so this trims the - // full Poppins Regular + Bold (a few hundred KB) down to what's on the page. + // full Liberation Sans Regular + Bold (~276KB) down to what's on the page. // Orthogonal to the sanitizer choice: subsetting prunes unused glyphs, it // doesn't change which code points the font can encode. const regular = await doc.embedFont(bytes.regular, { subset: true }); @@ -586,11 +657,11 @@ async function loadFonts( return { regular, bold, - sanitize: makePoppinsSanitizer(parts, bytes.regular, bytes.bold), + sanitize: makeEmbeddedFontSanitizer(parts, bytes.regular, bytes.bold), }; } catch (err) { console.warn( - "Poppins font embed failed, falling back to Helvetica:", + "Body font embed failed, falling back to Helvetica:", err, ); } @@ -600,8 +671,8 @@ async function loadFonts( } /** - * The sanitizer for the embedded-Poppins path: {@link toEmbeddedFontSafe} - * bound to Poppins' real coverage, read off the same TTF bytes pdf-lib just + * The sanitizer for the embedded-font path: {@link toEmbeddedFontSafe} + * bound to Liberation Sans' real coverage, read off the same TTF bytes pdf-lib just * embedded (so the predicate cannot drift from the font on the page). * * BOTH faces are probed and a code point must be covered by both to survive. @@ -610,7 +681,7 @@ async function loadFonts( * files happen to have identical coverage today (diffed across U+0020–U+10FFFF * minus surrogates: zero differences), so a Regular-only probe would be * correct by accident; requiring both makes the fix independent of that, so a - * weight swap, a variable-font migration or a Poppins bump cannot silently + * weight swap, a variable-font migration or a font bump cannot silently * reinstate `.notdef` in bold text. * * Coverage is memoized per byte-buffer and probed lazily — `hasGlyphForCodePoint` @@ -619,11 +690,11 @@ async function loadFonts( * * Falls back to `toWinAnsi` if fontkit can't parse the bytes here for any * reason. That is deliberately the CONSERVATIVE direction: `toWinAnsi` may - * degrade a Latin-Extended glyph Poppins could have drawn, but it can never + * degrade a Latin-Extended glyph the embedded font could have drawn, but it can never * emit a NUL — and a fallback that silently returned the text unsanitized * would reinstate the exact defect this function exists to prevent. */ -function makePoppinsSanitizer( +function makeEmbeddedFontSanitizer( parts: PdfLibParts, regularBytes: ArrayBuffer, boldBytes: ArrayBuffer, @@ -653,7 +724,7 @@ function makePoppinsSanitizer( return (text) => toEmbeddedFontSafe(text, hasGlyph); } catch (err) { console.warn( - "Poppins glyph-coverage probe failed, sanitizing to WinAnsi:", + "Body font glyph-coverage probe failed, sanitizing to WinAnsi:", err, ); return toWinAnsi; @@ -835,16 +906,26 @@ class Layout { */ private pageOrdinal = 1; + /** How many pages this layout has drawn on so far — 1 until a break. Read by + * {@link fitToPage} to decide whether a rung fit. */ + get pageCount(): number { + return this.pageOrdinal; + } + constructor( private doc: Doc, private fonts: { regular: PdfFont; bold: PdfFont }, + /** The point sizes and gaps for this pass. Public because the module-level + * draw helpers below all receive a `Layout` and read their sizes off it — + * which is what keeps a single rung's geometry internally consistent. */ + readonly t: TypeScale, private black: RGB, private gray: RGB, // Literal-string constructor from pdf-lib, used to build Link-annotation // `/URI` values (#425 — see `registerLink`). private pdfString: PdfLibParts["PDFString"], // The font-matched sanitizer every string passes through before it is - // measured or drawn — `toWinAnsi` on the Helvetica fallback, Poppins' + // measured or drawn — `toWinAnsi` on the Helvetica fallback, the embedded font's // coverage-aware sanitizer on the embedded path (see `loadFonts`). It is // never a no-op: an embedded font emits `.notdef` (which extracts as // U+0000) for a glyph it lacks, so "the font can encode it" is a claim @@ -862,6 +943,27 @@ class Layout { this.pageOrdinal++; } + /** + * Hanging indent (pt) for a wrapped bullet at `size` — exactly the width of + * {@link BULLET_MARKER}, so a continuation line starts under the first line's + * TEXT rather than a fixed distance from the margin. + * + * Measured off the font rather than carried as a scale constant. It was a + * constant (12pt at the reference size, ~14pt at the top rung) against a + * marker only ~6pt wide, which drew every wrapped bullet's tail visibly + * further right than the text it continues. A constant also cannot follow a + * font swap: the correct value is a property of the marker glyph, not of the + * layout. + * + * Still clear of `isWrappedContinuation`'s `markerX + 2` threshold + * (entry-blocks.ts) at every rung — the marker sits at `MARGIN` and its own + * width is the smallest indent that puts the tail past it — so the re-parse + * still folds a wrapped tail back into the bullet it belongs to. + */ + bulletIndent(size: number): number { + return this.fonts.regular.widthOfTextAtSize(BULLET_MARKER, size); + } + /** * Register a clickable URI link annotation over the rect `[x0,y0,x1,y1]` (all * in pdf-lib's bottom-origin page space, matching `this.y`) on the current @@ -1090,7 +1192,7 @@ class Layout { rValue: string; rSize: number; } { - const size = opts.size ?? SIZE_BODY; + const size = opts.size ?? this.t.body; const font = opts.bold ? this.fonts.bold : this.fonts.regular; const x = opts.x ?? MARGIN; // Sanitize LAST — after the case transform — so a case-expansion can never @@ -1136,6 +1238,56 @@ class Layout { return lines.length * lineHeight; } + /** + * The largest size at or below `size` that draws `text` on ONE line, floored + * at `CONTACT_MIN_SIZE_RATIO * size`. + * + * Used for the contact line, which must not wrap. It is not a cosmetic + * preference: the re-parse reads contact fields off a single header line, so + * a contact line that wraps loses whichever links land on the continuation — + * `latex/multi-degree-coursework.pdf` drops its `linkedin_url` exactly this + * way. That was unreachable while the export drew at one fixed size and the + * fixtures happened to fit; the fit pass can now choose a rung a point and a + * half larger, which is enough to push a long contact line over. + * + * Returns a size, not a decision: if even the floor will not fit, the caller + * splits the line at a field boundary ({@link contactSplitIndex}) or, failing + * that, draws at the floor and lets it wrap. Legibility wins over a guarantee + * we cannot make for an arbitrarily long contact line. + */ + fitToOneLine(text: string, size: number): number { + return Math.max(size * CONTACT_MIN_SIZE_RATIO, this.oneLineSize(text, size)); + } + + /** + * The size at which `text` exactly fills one line, UNFLOORED — the same + * quantity {@link fitToOneLine} returns before the readability floor is + * applied, capped at `size` for text that already fits. + * + * Separate because the floor makes every over-long candidate score + * identically, which is useless to a caller choosing BETWEEN candidates + * (see {@link contactSplitIndex}). Sizing a draw still goes through + * `fitToOneLine`; this only ranks. + */ + oneLineSize(text: string, size: number): number { + const width = this.fonts.regular.widthOfTextAtSize( + this.sanitize(text), + size, + ); + if (width <= CONTENT_WIDTH) return size; + return (size * CONTENT_WIDTH * FIT_ONE_LINE_SHAVE) / width; + } + + /** + * Whether `text` draws on ONE line at `size` — measured through the same + * wrapper that will draw it, so the answer cannot disagree with the draw. + * {@link fitToOneLine} returns a size rather than a verdict, so this is how a + * caller learns whether the floor was enough. + */ + fitsOneLine(text: string, size: number): boolean { + return this.resolveDrawLines(text, { size }).lines.length === 1; + } + /** * Draw a wrapped block of text. `x` is the left edge; `hangingIndent` * indents continuation lines (for bullet hanging indent). `atomicSegments` @@ -1495,14 +1647,14 @@ class Layout { } drawRule() { - this.ensure(RULE_HEIGHT); + this.ensure(this.t.ruleHeight); this.page.drawLine({ start: { x: MARGIN, y: this.y }, end: { x: PAGE_WIDTH - MARGIN, y: this.y }, thickness: 0.75, color: this.gray, }); - this.advance(RULE_HEIGHT); + this.advance(this.t.ruleHeight); } } @@ -1515,11 +1667,147 @@ export interface RenderAtsPdfResult { /** Empty for a clean résumé — the common case, and the one that must render * no warning chrome at all downstream. */ findings: RenderFinding[]; + /** The body point size this render was drawn at — the rung {@link fitToPage} + * selected, or `bodyPt` if the caller pinned one. */ + bodyPt: number; + /** Pages the render produced. `1` whenever the fit pass succeeded. */ + pages: number; +} + +/** + * Where to break a contact line that will not hold one row — an index into + * `parts`, everything from it moving to the second row. + * + * Only indices at or below the links boundary are considered, so the links + * move down together and are never split between rows: they are the parts most + * likely to overflow (a URL slug is the longest field on the line) and the ones + * the re-parse can least afford to lose track of. + * + * Among those, the winner is the index that lets the rows be drawn LARGEST — + * scored by the size the wider of the two rows can hold, which is what the + * caller will set both rows at. That reduces to "the boundary itself" whenever + * the boundary already yields two rows that fit, and degrades usefully when it + * does not: moving one more field down is worth it exactly when it buys type + * size. Ties go to the larger index, keeping the break as close to the links + * boundary as the geometry allows. + * + * `parts` always leads with the email when there is one, so the returned index + * is at least 1 and the email never leaves the first row. + */ +function contactSplitIndex( + layout: Layout, + parts: readonly string[], + linkCount: number, +): number { + const boundary = Math.max(1, parts.length - linkCount); + const size = layout.t.contact; + let best = boundary; + let bestFit = -1; + for (let cut = boundary; cut >= 1; cut--) { + const fit = Math.min( + layout.oneLineSize(parts.slice(0, cut).join(" • "), size), + layout.oneLineSize(parts.slice(cut).join(" • "), size), + ); + if (fit > bestFit) { + bestFit = fit; + best = cut; + } + } + return best; } /** - * Render an ATS résumé model to PDF bytes, plus the export findings gathered - * while drawing them (#621). + * Draw the header's contact line — one row when it fits, a field-boundary + * split into two when it doesn't, falling back to a single wrapping row when + * even the split can't produce two clean rows. Split out of + * {@link renderAtsResumePdfAtSize} (#878) so that function's own complexity + * doesn't grow with the contact-line decision tree. + */ +function drawContactLine(layout: Layout, contact: AtsResumeModel["contact"], muted: RGB): void { + // Work authorization (#792) sits after location and before the links, so the + // statement rides the existing contact line and costs the header no extra + // row. It is deliberately absent from `linkSpans` below: it is a sentence, + // not a URL, so it receives no clickable overlay and no scheme-stripping. + const identityParts = [contact.email, contact.phone, contact.location, contact.workAuthorization].filter( + (p): p is string => Boolean(p), + ); + const contactParts = [...identityParts, ...contact.links.filter(Boolean)]; + if (contactParts.length === 0) return; + + // Clickable overlays (#425): email → mailto:, each scheme-stripped link slug + // → its real target. The visible text stays the shortened display; the + // annotation carries the real target. Annotations are outside the content + // stream, so the text round-trip is unaffected. + // + // The href is the ORIGINAL parsed URL (`contact.linkHrefs`, aligned with + // `links`) rather than one rebuilt from the `www.`-stripped display: rebuilding + // `https://${slug}` from the display would force `https` and drop any `www.` + // the source URL carried, so a portfolio/website served only at `www.host` or + // over `http` would get a 404-ing link. The display stays `www.`-less; only + // the click target uses the original. + const emailSpans = contact.email + ? [{ display: contact.email, href: `mailto:${contact.email}` }] + : []; + const linkSpans = contact.links.map((link, i) => ({ + display: link, + href: contact.linkHrefs?.[i] ?? `https://${link}`, + })); + const contactLine = contactParts.join(" • "); + // Sized to hold one line (see `fitToOneLine`) — a wrapped contact line + // costs the re-parse whichever links land on the continuation. + const fitted = layout.fitToOneLine(contactLine, layout.t.contact); + const drawSingleLine = () => + layout.drawText(contactLine, { + size: fitted, + color: muted, + linkSpans: [...emailSpans, ...linkSpans], + }); + + if (layout.fitsOneLine(contactLine, fitted)) { + drawSingleLine(); + return; + } + if (identityParts.length === 0 || contactParts.length <= 1) { + // Nothing to split on — one row of a single kind, too long even at the + // floor. It wraps, as it did before any of this existed. + drawSingleLine(); + return; + } + + // Even the floor could not hold one row, so the break is going to happen — + // take it at a FIELD boundary instead of wherever the word wrapper lands. + // Word wrapping strands whichever fields overflow, which reads as a mistake + // (one lone slug under a full row, its separator left dangling above it) + // and hands the re-parse a continuation with no field boundary to key on. + // + // Each row is drawn separately, so each gets its own `linkSpans` pass — + // `decorateFirstLine` only overlays a call's FIRST line, so a link on a + // wrapped continuation loses its clickable annotation as well as its field. + const cut = contactSplitIndex(layout, contactParts, contact.links.length); + const top = contactParts.slice(0, cut).join(" • "); + const bottom = contactParts.slice(cut).join(" • "); + // One size for both rows — two rows of a single header block set at + // visibly different sizes read as two different things. + const rowSize = Math.min( + layout.fitToOneLine(top, layout.t.contact), + layout.fitToOneLine(bottom, layout.t.contact), + ); + // `fitToOneLine` is floored at CONTACT_MIN_SIZE_RATIO — below that floor it + // still returns the floor size as though the text fit. Verify the split + // actually bought two clean rows before trusting it; otherwise fall back to + // the single (still-wrapping) line rather than silently stranding a field + // with no separator. + if (!layout.fitsOneLine(top, rowSize) || !layout.fitsOneLine(bottom, rowSize)) { + drawSingleLine(); + return; + } + layout.drawText(top, { size: rowSize, color: muted, linkSpans: emailSpans }); + layout.drawText(bottom, { size: rowSize, color: muted, linkSpans }); +} + +/** + * Render an ATS résumé model to PDF bytes at ONE body size, plus the export + * findings gathered while drawing them (#621). * * The findings are ADVISORY and produced strictly as a side observation: the * glyph pass reads the same font-matched sanitizer the draw uses, and the @@ -1527,9 +1815,14 @@ export interface RenderAtsPdfResult { * feeds a layout decision, so `bytes` for a clean résumé is byte-for-byte what * this function produced before findings existed — the property the round-trip * corpus pins. + * + * Private: the size is chosen by {@link fitToPage} or pinned through + * `renderAtsResumePdf`'s `bodyPt` option, so nothing outside this module needs + * to name a rung directly. */ -export async function renderAtsResumePdf( +async function renderAtsResumePdfAtSize( model: AtsResumeModel, + bodyPt: number, ): Promise { const parts = await loadPdfLibOnce(); const { PDFDocument, rgb } = parts; @@ -1553,6 +1846,7 @@ export async function renderAtsResumePdf( const layout = new Layout( doc, { regular, bold }, + makeTypeScale(bodyPt), black, gray, parts.PDFString, @@ -1561,7 +1855,7 @@ export async function renderAtsResumePdf( // ── Header: name + (headline) + contact line ── if (model.contact.name) { - layout.drawText(model.contact.name, { bold: true, size: SIZE_NAME }); + layout.drawText(model.contact.name, { bold: true, size: layout.t.name }); } // Professional headline (#425) — regular weight, muted, under the name. // Populated when the parser lifted a standalone title tagline from the header @@ -1569,58 +1863,18 @@ export async function renderAtsResumePdf( // otherwise, so most résumés draw just name + contact line as before. if (model.contact.headline) { layout.drawText(model.contact.headline, { - size: SIZE_HEADLINE, + size: layout.t.headline, color: muted, }); } - // Work authorization (#792) sits after location and before the links, so the - // statement rides the existing contact line and costs the header no extra - // row. It is deliberately absent from `linkSpans` below: it is a sentence, - // not a URL, so it receives no clickable overlay and no scheme-stripping. - const contactParts = [ - model.contact.email, - model.contact.phone, - model.contact.location, - model.contact.workAuthorization, - ...model.contact.links, - ].filter((p): p is string => Boolean(p)); - if (contactParts.length > 0) { - // Clickable overlays (#425): email → mailto:, each scheme-stripped link slug - // → its real target. The visible text stays the shortened display; the - // annotation carries the real target. Annotations are outside the content - // stream, so the text round-trip is unaffected. - // - // The href is the ORIGINAL parsed URL (`contact.linkHrefs`, aligned with - // `links`) rather than one rebuilt from the `www.`-stripped display: rebuilding - // `https://${slug}` from the display would force `https` and drop any `www.` - // the source URL carried, so a portfolio/website served only at `www.host` or - // over `http` would get a 404-ing link. The display stays `www.`-less; only - // the click target uses the original. - const linkSpans: Array<{ display: string; href: string }> = []; - if (model.contact.email) - linkSpans.push({ - display: model.contact.email, - href: `mailto:${model.contact.email}`, - }); - model.contact.links.forEach((link, i) => - linkSpans.push({ - display: link, - href: model.contact.linkHrefs?.[i] ?? `https://${link}`, - }), - ); - layout.drawText(contactParts.join(" • "), { - size: SIZE_CONTACT, - color: muted, - linkSpans, - }); - } - layout.advance(GAP_AFTER_CONTACT); + drawContactLine(layout, model.contact, muted); + layout.advance(layout.t.gapAfterContact); // ── Summary ── if (model.summary) { // The summary body is plain wrapped text, so the heading must keep one BODY // line with it (#629). - drawSectionHeading(layout, model.summaryHeading ?? "Summary", SIZE_BODY * LINE_GAP); + drawSectionHeading(layout, model.summaryHeading ?? "Summary", layout.t.body * LINE_GAP); // The body takes `widowControl` for the same reason a bullet does (#631): it // is a wrapped block, so per-line pagination can leave its last line alone at // a page top. It is the only NON-bullet caller — a summary long enough to @@ -1631,8 +1885,8 @@ export async function renderAtsResumePdf( // The orphan half needs nothing: the heading above already reserves one body // line, and this block can only ever BEGIN at the top of page one, so its // opening can never sit at a page bottom. - layout.drawText(model.summary, { size: SIZE_BODY, widowControl: true }); - layout.advance(GAP_BETWEEN_ENTRIES); + layout.drawText(model.summary, { size: layout.t.body, widowControl: true }); + layout.advance(layout.t.gapBetweenEntries); } // ── Sections ── @@ -1655,9 +1909,9 @@ export async function renderAtsResumePdf( entryPath: entryPathLabel(section.heading || "Section", section.entries[i], i), findings, }); - if (i < section.entries.length - 1) layout.advance(GAP_BETWEEN_ENTRIES); + if (i < section.entries.length - 1) layout.advance(layout.t.gapBetweenEntries); } - layout.advance(GAP_BETWEEN_ENTRIES); + layout.advance(layout.t.gapBetweenEntries); } // ── Embedded machine-readable copy (#334, Europass pattern) ── @@ -1686,16 +1940,73 @@ export async function renderAtsResumePdf( description: "JSON Resume (jsonresume.org) — machine-readable copy", }); - return { bytes: await doc.save(), findings }; + return { + bytes: await doc.save(), + findings, + bodyPt, + pages: layout.pageCount, + }; +} + +/** + * Render at the largest {@link FIT_LADDER} rung that costs the fewest pages. + * + * Descending scan with early exit, which makes the common case cheap: a résumé + * that already fits at the top rung costs exactly one render, and one that + * spills costs one render per rung tried. There is no separate "measure" path + * on purpose — the pass that decides the size IS the pass that produces the + * bytes, so the decision cannot be made against geometry the download does not + * have. (A cheaper dry-run measure is possible; a divergent one is not worth + * the milliseconds it would save.) + * + * The rung chosen is the LARGEST one that achieves the fewest pages — which + * makes the top rung the fallback whenever shrinking buys nothing at all. + * Saving a page is worth type size whether the saving is 2→1 or 4→3; being + * smaller for its own sake never is. So a résumé with more content than the + * floor can absorb stays at the top rung and runs long, rather than being set + * cramped AND long. + */ +async function fitToPage(model: AtsResumeModel): Promise { + let best: RenderAtsPdfResult | null = null; + for (const pt of FIT_LADDER) { + const attempt = await renderAtsResumePdfAtSize(model, pt); + // The top rung is the fallback, so it is `best` until something beats it. + if (!best) best = attempt; + else if (attempt.pages < best.pages) best = attempt; + // Nothing below can do better than one page, so stop paying for renders. + if (best.pages <= 1) break; + } + // `FIT_LADDER` is a non-empty literal, so the loop always assigns. + return best as RenderAtsPdfResult; +} + +/** + * Render an ATS résumé model to PDF bytes, choosing the body size that fits. + * + * Sizing is per-résumé because a single fixed size cannot serve both ends: the + * size that keeps a dense résumé on one page sets a short one several points + * smaller than a typographer would, and the size that reads well on a short one + * spills a dense one onto a second page for want of a few lines. + * + * `bodyPt` pins a rung and skips the fit pass entirely — for tests that assert + * a specific geometry, and for a caller that has already decided. + */ +export async function renderAtsResumePdf( + model: AtsResumeModel, + opts: { bodyPt?: number } = {}, +): Promise { + return opts.bodyPt === undefined + ? fitToPage(model) + : renderAtsResumePdfAtSize(model, opts.bodyPt); } /** The `drawText` options for a section/summary heading — shared by the draw and * its keep-with-next measurement (#629) so the two cannot drift. */ -const HEADING_OPTS = { +const headingOpts = (t: TypeScale): DrawTextOpts => ({ bold: true, - size: SIZE_SECTION, + size: t.section, uppercase: true, -} as const; +}); /** * Draw a section (or Summary) heading plus its rule. `followHeight` is the @@ -1709,49 +2020,57 @@ function drawSectionHeading( heading: string, followHeight: number, ) { - layout.advance(GAP_BEFORE_SECTION); + layout.advance(layout.t.gapBeforeSection); layout.ensureBlock( - layout.measureTextHeight(heading, HEADING_OPTS) + - RULE_HEIGHT + - GAP_AFTER_RULE + + layout.measureTextHeight(heading, headingOpts(layout.t)) + + layout.t.ruleHeight + + layout.t.gapAfterRule + followHeight, ); - layout.drawText(heading, HEADING_OPTS); + layout.drawText(heading, headingOpts(layout.t)); layout.drawRule(); - layout.advance(GAP_AFTER_RULE); + layout.advance(layout.t.gapAfterRule); } /** The `drawText` options for an entry's header line — shared by the draw and its * keep-with-next measurement (#629). */ -function headerLineOpts(entry: AtsEntry, mutedColor: RGB): DrawTextOpts { +function headerLineOpts( + entry: AtsEntry, + mutedColor: RGB, + t: TypeScale, +): DrawTextOpts { return { // Every header is bold EXCEPT where the model opts out — the skills list, // which reads as regular-weight body text (#425). bold: entry.headerBold ?? true, - size: SIZE_HEADER, + size: t.header, atomicSegments: entry.atomicSegments, hangingIndent: entry.headerHangingIndent, // Flush-right date on the header line (#425) — set for a title-less role / // degree-less program, where the org/date anchor lives on the header. rightText: entry.headerLineDate, rightColor: mutedColor, - rightSize: SIZE_SUB, + rightSize: t.sub, }; } /** The `drawText` options for an entry's sub-line — shared by the draw and its * keep-with-next measurement (#629). See `drawEntry` for why the middot * segments are atomic here. */ -function subLineOpts(entry: AtsEntry, mutedColor: RGB): DrawTextOpts { +function subLineOpts( + entry: AtsEntry, + mutedColor: RGB, + t: TypeScale, +): DrawTextOpts { return { - size: SIZE_SUB, + size: t.sub, color: mutedColor, atomicSegments: true, // Flush-right date on the sub-line (#425) — set for a titled role / // degreed entry, where the org anchor lives on the sub-line. rightText: entry.subLineDate, rightColor: mutedColor, - rightSize: SIZE_SUB, + rightSize: t.sub, }; } @@ -1814,22 +2133,22 @@ function entryHeadHeight( let height = 0; if (entry.headerLine) { if (entry.headerLine.includes(EMPHASIS_OPEN)) { - height += layout.measureHeaderRunsHeight(entry.headerLine, SIZE_HEADER); + height += layout.measureHeaderRunsHeight(entry.headerLine, layout.t.header); } else { const full = layout.measureTextHeight( entry.headerLine, - headerLineOpts(entry, mutedColor), + headerLineOpts(entry, mutedColor, layout.t), ); height += entry.headerBold === false && entry.bullets.length === 0 - ? Math.min(full, BODY_HEADER_KEEP_LINES * SIZE_HEADER * LINE_GAP) + ? Math.min(full, BODY_HEADER_KEEP_LINES * layout.t.header * LINE_GAP) : full; } } if (entry.subLine) { height += - GAP_AFTER_HEADER + - layout.measureTextHeight(entry.subLine, subLineOpts(entry, mutedColor)); + layout.t.gapAfterHeader + + layout.measureTextHeight(entry.subLine, subLineOpts(entry, mutedColor, layout.t)); } return height; } @@ -1865,13 +2184,13 @@ function entryKeepHeight( entry: AtsEntry, mutedColor: RGB, ): number { - const bulletLine = SIZE_BODY * LINE_GAP; + const bulletLine = layout.t.body * LINE_GAP; const head = entryHeadHeight(layout, entry, mutedColor); if (entry.bullets.length === 0) return head; const firstBulletLines = layout.measureBulletLines( entry.bullets[0], - SIZE_BODY, - BULLET_INDENT, + layout.t.body, + layout.bulletIndent(layout.t.body), ); const keep = bulletKeepLines(firstBulletLines); const base = head + keep * bulletLine; @@ -1908,10 +2227,10 @@ function entryKeepHeight( function trailingBulletKeepHeight(layout: Layout, entry: AtsEntry): number { const lines = layout.measureBulletLines( entry.bullets[entry.bullets.length - 1], - SIZE_BODY, - BULLET_INDENT, + layout.t.body, + layout.bulletIndent(layout.t.body), ); - return bulletKeepLines(lines) * SIZE_BODY * LINE_GAP; + return bulletKeepLines(lines) * layout.t.body * LINE_GAP; } function drawEntry( @@ -1936,20 +2255,20 @@ function drawEntry( // Mixed-weight header (#425 — an achievement "type" label bolded, the rest // regular). Routed to the run-aware draw; these headers carry no flush-right // date, so the marker-less run path covers them. - layout.drawHeaderRuns(entry.headerLine, SIZE_HEADER); + layout.drawHeaderRuns(entry.headerLine, layout.t.header); } else { - layout.drawText(entry.headerLine, headerLineOpts(entry, mutedColor)); + layout.drawText(entry.headerLine, headerLineOpts(entry, mutedColor, layout.t)); } } if (entry.subLine) { - layout.advance(GAP_AFTER_HEADER); + layout.advance(layout.t.gapAfterHeader); // Sub-lines are the "Company · Location · Team Dates" / "Institution · // Location Dates" org lines (see `ats-resume-model.ts`) — the middot here // is a re-parse-critical boundary, NOT a display joiner: word-wrapping // inside a multi-word location (e.g. "San Francisco Bay Area") re-parses it // into fragmented location tokens (#301). Unlike the 3+ segment achievement // HEADER lines (#307), these must stay atomic, so opt in unconditionally. - layout.drawText(entry.subLine, subLineOpts(entry, mutedColor)); + layout.drawText(entry.subLine, subLineOpts(entry, mutedColor, layout.t)); } // #632: the LAST bullet of a multi-bullet entry must never be the only thing // its page opens with. The whole rule is one hand-off — the SECOND-TO-LAST @@ -1973,7 +2292,7 @@ function drawEntry( // for the one shape where it must (see there), and passing it on here is // harmless: `drawBullet` uses it only on the reservation covering the bullet's // final line, which for a divisible bullet is its tail — not suppressed. - layout.drawBullet(entry.bullets[i], SIZE_BODY, BULLET_INDENT, { + layout.drawBullet(entry.bullets[i], layout.t.body, layout.bulletIndent(layout.t.body), { alreadyReserved: i === 0 && keepHeight > 0, followKeepHeight: i === entry.bullets.length - 2 ? trailingKeep : 0, onSplit: (split) => diff --git a/src/lib/pdf/render-ats-pdf.type-scale.test.ts b/src/lib/pdf/render-ats-pdf.type-scale.test.ts new file mode 100644 index 00000000..b35a5d57 --- /dev/null +++ b/src/lib/pdf/render-ats-pdf.type-scale.test.ts @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The offlinecv Authors + +/** + * How the export chooses its geometry: the proportional type scale, the fit + * ladder that picks a body size per résumé, and the contact line's own fit. + * + * Separate from the pagination contracts in `export-layout-contract.test.ts`, + * which pin a size precisely so the fit pass cannot move it. This file is the + * other half — it asserts that the pass moves it, and by how much. + */ + +import { describe, expect, it } from "vitest"; +import { + REFERENCE_BODY_PT, + makeTypeScale, + renderAtsResumePdf, +} from "./render-ats-pdf.ts"; +import type { AtsEntry, AtsResumeModel } from "./ats-resume-model.ts"; + +/** Body text sizes read off the drawn page, most-used first. */ +async function drawnSizes(bytes: Uint8Array): Promise { + const pdfjs = await import("pdfjs-dist"); + const doc = await pdfjs.getDocument({ + data: bytes.slice(), + useWorkerFetch: false, + isEvalSupported: false, + useSystemFonts: false, + }).promise; + const tally = new Map(); + for (let p = 1; p <= doc.numPages; p++) { + const content = await doc.getPage(p).then((pg) => pg.getTextContent()); + for (const it of content.items) { + if (!("str" in it)) continue; + const t = it as { str: string; transform: number[] }; + if (!t.str.trim()) continue; + const size = Math.round(Math.hypot(t.transform[0], t.transform[1]) * 100) / 100; + tally.set(size, (tally.get(size) ?? 0) + 1); + } + } + return [...tally.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s); +} + +/** Lines drawn on page 1 with their left edge and size, in top-down order. */ +async function pageOneRows( + bytes: Uint8Array, +): Promise> { + const pdfjs = await import("pdfjs-dist"); + const doc = await pdfjs.getDocument({ + data: bytes.slice(), + useWorkerFetch: false, + isEvalSupported: false, + useSystemFonts: false, + }).promise; + const content = await doc.getPage(1).then((pg) => pg.getTextContent()); + const rows = new Map(); + for (const it of content.items) { + if (!("str" in it)) continue; + const t = it as { str: string; transform: number[] }; + const y = Math.round(t.transform[5] * 2) / 2; + const prev = rows.get(y); + rows.set(y, { + x: prev ? Math.min(prev.x, t.transform[4]) : t.transform[4], + size: Math.round(Math.hypot(t.transform[0], t.transform[1]) * 100) / 100, + text: (prev?.text ?? "") + t.str, + }); + } + return [...rows.entries()] + .sort((a, b) => b[0] - a[0]) + .map(([, v]) => v) + .filter((v) => v.text.trim()); +} + +/** Lines drawn on page 1, in top-down order. */ +async function pageOneLines(bytes: Uint8Array): Promise { + const pdfjs = await import("pdfjs-dist"); + const doc = await pdfjs.getDocument({ + data: bytes.slice(), + useWorkerFetch: false, + isEvalSupported: false, + useSystemFonts: false, + }).promise; + const content = await doc.getPage(1).then((pg) => pg.getTextContent()); + const rows = new Map(); + for (const it of content.items) { + if (!("str" in it)) continue; + const t = it as { str: string; transform: number[] }; + const y = Math.round(t.transform[5] * 2) / 2; + rows.set(y, (rows.get(y) ?? "") + t.str); + } + return [...rows.entries()] + .sort((a, b) => b[0] - a[0]) + .map(([, text]) => text) + .filter((t) => t.trim()); +} + +function entry(i: number, bulletWords: number): AtsEntry { + return { + headerLine: `Senior Staff Engineer ${i} · Northwind Systems`, + subLine: `Bellevue, WA Jan. 20${10 + i} – Dec. 20${11 + i}`, + bullets: Array.from({ length: 3 }, (_, b) => + Array.from({ length: bulletWords }, (_, w) => `word${i}x${b}x${w}`).join(" "), + ), + } as AtsEntry; +} + +function model(entries: number, bulletWords: number): AtsResumeModel { + return { + contact: { name: "Jordan Bennett", links: [] }, + sections: [ + { + heading: "Experience", + entries: Array.from({ length: entries }, (_, i) => entry(i, bulletWords)), + }, + ], + } as AtsResumeModel; +} + +describe("proportional type scale", () => { + it("reproduces the reference geometry exactly at the reference size", () => { + // The whole scale is expressed at ONE size and multiplied, so this is the + // check that the multiplication is identity where it must be — the layout + // this engine drew before the fit pass existed. + const t = makeTypeScale(REFERENCE_BODY_PT); + expect(t.body).toBe(8.5); + expect(t.name).toBe(18); + expect(t.contact).toBe(9); + expect(t.section).toBe(10); + expect(t.header).toBe(9.5); + expect(t.gapBeforeSection).toBe(7); + expect(t.ruleHeight).toBe(2); + }); + + it("scales every size and gap by the same factor", () => { + // Body-only scaling was measured and rejected: the gaps, rule and name block + // are fixed overhead worth ~19% of the usable page, so leaving them at full + // size costs about a third of the achievable shrink. + const ref = makeTypeScale(REFERENCE_BODY_PT); + const half = makeTypeScale(REFERENCE_BODY_PT / 2); + for (const key of Object.keys(ref) as Array) { + if (key === "bodyPt") continue; + expect(half[key], `${key} did not halve`).toBeCloseTo(ref[key] / 2, 10); + } + }); +}); + +/** The rungs `fitToPage` walks, descending — mirrored here so the ladder's + * contract can be asserted without exporting the array itself. */ +const LADDER = [10, 9.5, 9, 8.5, 8] as const; + +describe("fit ladder", () => { + it("draws a short résumé at the top rung rather than a fixed small size", async () => { + // The point of the ladder is NOT only to rescue a spilling résumé. A single + // fixed size has to be small enough for the dense case, which leaves every + // short résumé set several points below what a typographer would choose. + const result = await renderAtsResumePdf(model(2, 8)); + expect(result.pages).toBe(1); + expect(result.bodyPt).toBe(10); + expect(await drawnSizes(result.bytes)).toContain(10); + }); + + it("rescues a résumé that spills at the top rung but fits lower down", async () => { + const big = model(6, 22); + const atTop = await renderAtsResumePdf(big, { bodyPt: 10 }); + expect(atTop.pages, "fixture must spill at the top rung or this is vacuous").toBeGreaterThan(1); + + const fitted = await renderAtsResumePdf(big); + expect(fitted.pages).toBe(1); + expect(fitted.bodyPt).toBeLessThan(10); + expect(fitted.bodyPt).toBeGreaterThanOrEqual(8); + }); + + it("picks the LARGEST rung that achieves the fewest pages", async () => { + // The general contract, asserted against the ladder itself rather than a + // hand-computed answer — so it holds for a résumé too long to reach one page + // as well as one that fits. Saving a page is worth type size whether the + // saving is 2→1 or 4→3; being smaller for its own sake never is, which is + // what makes the top rung the fallback when shrinking buys nothing. + const huge = model(24, 28); + const perRung = await Promise.all( + LADDER.map(async (pt) => ({ + pt, + pages: (await renderAtsResumePdf(huge, { bodyPt: pt })).pages, + })), + ); + const fewest = Math.min(...perRung.map((r) => r.pages)); + const expected = perRung.find((r) => r.pages === fewest)!.pt; // LADDER is descending + + expect(fewest, "fixture must not fit on one page or this is vacuous").toBeGreaterThan(1); + const fitted = await renderAtsResumePdf(huge); + expect(fitted.pages).toBe(fewest); + expect(fitted.bodyPt).toBe(expected); + }); + + it("honours a pinned size and skips the pass entirely", async () => { + const pinned = await renderAtsResumePdf(model(2, 8), { bodyPt: 8.5 }); + expect(pinned.bodyPt).toBe(8.5); + expect(await drawnSizes(pinned.bytes)).toContain(8.5); + }); +}); + +describe("contact line", () => { + it("holds one line at the top rung, shrinking itself rather than wrapping", async () => { + // Regression guard, not a cosmetic one: the re-parse reads contact fields off + // a single header line, so a wrapped contact line silently drops whichever + // links land on the continuation. `latex/multi-degree-coursework.pdf` lost its + // linkedin_url exactly this way the moment the fit pass could pick 10pt. + // + // The shrink is bounded (CONTACT_MIN_SIZE_RATIO), so a contact line longer + // than this one still wraps — legibility wins over a guarantee that cannot + // hold for arbitrary input. This fixture is the realistic worst case: full + // email, phone, spelled-out location and two profile links. + const withLongContact = { + contact: { + name: "Jordan Bennett", + email: "jordan.bennett@example.com", + phone: "(973) 555-0123", + location: "Bellevue, Washington", + links: ["example.com/in/jordan-bennett", "example.com/jbennett"], + }, + sections: [{ heading: "Experience", entries: [entry(0, 8)] }], + } as AtsResumeModel; + + const result = await renderAtsResumePdf(withLongContact); + const lines = await pageOneLines(result.bytes); + + // Non-vacuity: the contact line really is long enough to be at risk. + const contactLine = lines.find((l) => l.includes("jordan.bennett@example.com")); + expect(contactLine).toBeDefined(); + // Every link on the ONE contact line — nothing pushed to a continuation. + for (const link of withLongContact.contact.links) { + expect(contactLine, `"${link}" left the contact line`).toContain(link); + } + }); + + it("never strands a field, at any contact length", async () => { + // Swept rather than pinned to one fixture. The contact block has two ways to + // avoid a stranded field — shrink to one row, or break at a field boundary — + // and which one applies is decided by a size comparison that lands on a + // floating-point boundary for some inputs and not others. A single fixture + // therefore proves very little; the length that shipped the orphaned link + // was one nobody would have guessed. Every length must satisfy the same + // contract: at most two rows, each a whole number of fields, links together. + const failures: string[] = []; + let shrank = false; + for (let pad = 0; pad <= 18; pad++) { + const links = [ + `example.com/in/jordan-bennett${"x".repeat(pad)}`, + "example.com/jbennett", + ]; + const full = { + contact: { + name: "Jordan Bennett", + email: "jordan.bennett@example.com", + phone: "(973) 555-0123", + location: "Bellevue, WA", + workAuthorization: "U.S. Citizen", + links, + }, + sections: [{ heading: "Experience", entries: [entry(0, 8)] }], + } as AtsResumeModel; + + const rows = await pageOneRows( + (await renderAtsResumePdf(full, { bodyPt: 10 })).bytes, + ); + const contactRows = rows.filter((r) => r.text.includes("example.com")); + const at = `pad=${pad}`; + if (contactRows.length > 2) { + failures.push(`${at}: ${contactRows.length} contact rows`); + continue; + } + if (contactRows[0].size < makeTypeScale(10).contact) shrank = true; + // Whole fields only — a row that ends on a separator was cut mid-list by + // the word wrapper, which is exactly the orphan this guards. + for (const row of contactRows) { + if (row.text.trim().endsWith("•")) failures.push(`${at}: row ends on a separator`); + } + // Links together, wherever they landed. + const withLinks = contactRows.filter((r) => links.some((l) => r.text.includes(l))); + if (withLinks.length !== 1) failures.push(`${at}: links split across rows`); + } + expect(failures).toEqual([]); + expect(shrank, "no length exercised the shrink path — the sweep is vacuous").toBe(true); + }); + + it("does not split a line the shrink can still hold, at the exact boundary", async () => { + // The last length in the sweep above that fits one row — where the fitted + // size lands within a rounding error of the content width. Without the shave + // `fitToOneLine` applies, re-multiplying that size overshoots by one ulp, the + // wrapper refuses the row, and this line splits in two for no reason: a row + // of vertical space spent on a line that fits. Pinned separately because the + // sweep's contract (nothing stranded) is satisfied by the split too — the + // cost of getting this wrong is density, not correctness. + const boundary = { + contact: { + name: "Jordan Bennett", + email: "jordan.bennett@example.com", + phone: "(973) 555-0123", + location: "Bellevue, WA", + workAuthorization: "U.S. Citizen", + links: ["example.com/in/jordan-bennettxxxxxxxx", "example.com/jbennett"], + }, + sections: [{ heading: "Experience", entries: [entry(0, 8)] }], + } as AtsResumeModel; + + const rows = await pageOneRows( + (await renderAtsResumePdf(boundary, { bodyPt: 10 })).bytes, + ); + const contactRows = rows.filter((r) => r.text.includes("example.com")); + expect(contactRows).toHaveLength(1); + // Non-vacuity: it only fits because it shrank, and it shrank nearly to the + // floor — this really is the boundary, not a line with room to spare. + const full = makeTypeScale(10).contact; + expect(contactRows[0].size).toBeLessThan(full); + expect(contactRows[0].size).toBeLessThan(full * 0.81); + }); + + it("breaks a contact line too long for the floor at a field boundary, links together", async () => { + // Past the readability floor the break is unavoidable, so it is taken at a + // field boundary rather than wherever the word wrapper lands — the links + // move down TOGETHER instead of one slug being stranded under a full row + // with its separator dangling above it. + const links = [ + "example.com/in/jordan-bennett-staff-software-engineer", + "example.com/jbennett", + "jordan-bennett-portfolio.example.com", + ]; + const huge = { + contact: { + name: "Jordan Bennett", + email: "jordan.bennett.hiring.inbox@long-domain-name.example.com", + phone: "(973) 555-0123", + location: "Bellevue, Washington, United States of America", + workAuthorization: "U.S. Citizen", + links, + }, + sections: [{ heading: "Experience", entries: [entry(0, 8)] }], + } as AtsResumeModel; + + const rows = await pageOneRows((await renderAtsResumePdf(huge)).bytes); + const contactRows = rows.filter( + (r) => r.text.includes("example.com") || r.text.includes("555-0123"), + ); + // Non-vacuity: this really is past the floor, so it really did have to break. + expect(contactRows.length, "fixture must not fit one row or this is vacuous").toBe(2); + // Every link on the SECOND row, none stranded — the point of the boundary. + for (const link of links) { + expect(contactRows[1].text, `"${link}" is not on the links row`).toContain(link); + } + expect(contactRows[0].text).toContain("jordan.bennett.hiring.inbox@long-domain-name.example.com"); + // One block, one size. + expect(contactRows[0].size).toBe(contactRows[1].size); + }); + + it("leaves a contact line that already fits at full size", async () => { + const short = { + contact: { name: "Jordan Bennett", email: "j@example.com", links: [] }, + sections: [{ heading: "Experience", entries: [entry(0, 8)] }], + } as AtsResumeModel; + const result = await renderAtsResumePdf(short); + // 10pt rung → contact rides the unscaled 9/8.5 ratio off the body size. + expect(await drawnSizes(result.bytes)).toContain( + Math.round(makeTypeScale(10).contact * 100) / 100, + ); + }); +}); + +describe("bullet hanging indent", () => { + it("starts a wrapped bullet's tail under the first line's TEXT, not past it", async () => { + // The indent is the marker's own width, measured off the font, so the tail + // aligns with the words it continues at every rung. It was a scale constant + // (12pt at the reference size, ~14pt at the top rung) against a marker only + // ~6pt wide, which drew every tail visibly further right than its own text. + const long = { + contact: { name: "Jordan Bennett", links: [] }, + sections: [ + { + heading: "Experience", + entries: [ + { + headerLine: "Senior Staff Engineer · Northwind Systems", + subLine: "Bellevue, WA Jan. 2019 – Dec. 2023", + bullets: [ + "Rebuilt the ingestion path so a batch that used to take most of a night " + + "finished inside the hour, which is long enough to wrap onto a second " + + "drawn line at any rung this ladder can pick.", + ], + }, + ], + }, + ], + } as unknown as AtsResumeModel; + + const rows = await pageOneRows((await renderAtsResumePdf(long)).bytes); + const head = rows.findIndex((r) => r.text.startsWith("•")); + // Non-vacuity: the bullet must actually have wrapped. + expect(head, "no bullet row drawn").toBeGreaterThanOrEqual(0); + expect(rows.length, "bullet did not wrap — the test is vacuous").toBeGreaterThan(head + 1); + + const marker = rows[head]; + const tail = rows[head + 1]; + // The tail's left edge is the marker row's left edge plus the marker width, + // which is exactly where the marker row's own text starts. + const markerWidth = tail.x - marker.x; + expect(markerWidth).toBeGreaterThan(0); + expect(markerWidth).toBeLessThan(marker.size); // "• " is well under one em + }); +}); diff --git a/src/lib/pdf/render-audit-report.ts b/src/lib/pdf/render-audit-report.ts index db01dfc2..681ef4f0 100644 --- a/src/lib/pdf/render-audit-report.ts +++ b/src/lib/pdf/render-audit-report.ts @@ -17,7 +17,7 @@ * is drawn ONLY when `input.includeIdentity` is true AND an `identity` block is * present. Default-off upstream, so the default artifact is anonymous. * - * This uses pdf-lib's built-in Helvetica (no Poppins fetch): the report is a + * This uses pdf-lib's built-in Helvetica (no embedded-font fetch): the report is a * plain document, not the brand-faithful résumé, so the 14 standard fonts are * enough and it keeps the module dependency-light. Every string is run through * `toWinAnsi()` because StandardFonts encode WinAnsi only (#295). The `rgb()` diff --git a/src/lib/pdf/render-findings.test.ts b/src/lib/pdf/render-findings.test.ts index a2dd0540..ea020065 100644 --- a/src/lib/pdf/render-findings.test.ts +++ b/src/lib/pdf/render-findings.test.ts @@ -10,7 +10,7 @@ * only exists once something has been paginated. * * The font path is chosen the same way `render-ats-pdf.fonts.test.ts` chooses - * it — by stubbing `fetch` and re-importing the module — so the embedded-Poppins + * it — by stubbing `fetch` and re-importing the module — so the embedded-font * case (this issue's target: a character the embedded font has no glyph for) is * tested on the embedded path and not by accident on the Helvetica fallback. * @@ -25,8 +25,8 @@ import { findGlyphFindings, type RenderFinding } from "./render-findings.ts"; import { EMPHASIS_OPEN, EMPHASIS_CLOSE } from "./auto-bold-metrics.ts"; const FONTS_DIR = fileURLToPath(new URL("../../assets/fonts/", import.meta.url)); -const REGULAR_BYTES = readFileSync(`${FONTS_DIR}Poppins-Regular.ttf`); -const BOLD_BYTES = readFileSync(`${FONTS_DIR}Poppins-Bold.ttf`); +const REGULAR_BYTES = readFileSync(`${FONTS_DIR}LiberationSans-Regular.ttf`); +const BOLD_BYTES = readFileSync(`${FONTS_DIR}LiberationSans-Bold.ttf`); function toArrayBuffer(buf: Buffer): ArrayBuffer { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); @@ -96,7 +96,7 @@ describe("export findings — glyph coverage (#621)", { timeout: 30000 }, () => }); it("does not report a Latin-Extended glyph the embedded font DOES cover", async () => { - // Poppins covers ś/ł. Reporting them would be the #664 false positive + // Liberation Sans covers ś/ł. Reporting them would be the #664 false positive // wearing a different hat — and it is the whole reason the embedded path // exists. stubFetchSucceeds(); @@ -110,7 +110,7 @@ describe("export findings — glyph coverage (#621)", { timeout: 30000 }, () => }); it("names the character AND its source field for a glyph the font lacks", async () => { - // Poppins has no glyph for ★ (verified in render-ats-pdf.fonts.test.ts), so + // Liberation Sans has no glyph for ★ (verified in render-ats-pdf.fonts.test.ts), so // the export draws "?" — the exact silent degradation #621 exists to stop // being silent. stubFetchSucceeds(); @@ -167,7 +167,7 @@ describe("export findings — glyph coverage (#621)", { timeout: 30000 }, () => }); it("reports a transliterated glyph as info, not as a warning", async () => { - // "→" is degraded — the export draws "->" — but the meaning survives, so it + // "‣" is degraded — the export draws "-" — but the meaning survives, so it // is worth stating and not worth alarming over. Grading every substitution // `warning` would make the badge meaningless on the one that destroys text. stubFetchSucceeds(); @@ -177,14 +177,14 @@ describe("export findings — glyph coverage (#621)", { timeout: 30000 }, () => { heading: "Experience", entries: [ - { headerLine: "Intern → Engineer · Acme", bullets: ["Did work"] }, + { headerLine: "Intern ‣ Engineer · Acme", bullets: ["Did work"] }, ], }, ], }); expect(findings).toHaveLength(1); expect(findings[0].severity).toBe("info"); - expect(findings[0].detail).toContain("->"); + expect(findings[0].detail).toContain("-"); }); it("collapses repeats of one character within one field to a single finding", async () => { @@ -232,16 +232,16 @@ describe("export findings — glyph coverage (#621)", { timeout: 30000 }, () => it("checks a heading through the case transform the renderer applies", async () => { // Headings are drawn upper-cased, and `toUpperCase()` can turn a covered - // glyph into an uncovered one: µ (U+00B5) becomes Μ (U+039C, Greek capital - // mu), which Poppins has no glyph for. Scanning the raw text would miss - // exactly the loss the draw is about to produce. + // glyph into an uncovered one: ƒ (U+0192) becomes Ƒ (U+0191, capital F with + // hook), which Liberation Sans has no glyph for. Scanning the raw text would + // miss exactly the loss the draw is about to produce. stubFetchSucceeds(); const { renderAtsResumePdf } = await loadRenderer(); - const heading = "µ-services"; + const heading = "ƒ-stop"; const raw = ( await renderAtsResumePdf({ contact: { name: "Jane Candidate", links: [] }, - // Same text NOT in a heading: drawn as-is, and Poppins covers µ, so it + // Same text NOT in a heading: drawn as-is, and Liberation Sans covers ƒ, so it // is the control that proves the case transform is what makes the // difference rather than the character itself. summary: heading, @@ -262,10 +262,10 @@ describe("export findings — glyph coverage (#621)", { timeout: 30000 }, () => }) ).findings; expect(cased.map((f) => f.sourceField)).toEqual([heading]); - // The reported character is what the user typed (µ, U+00B5), not what the + // The reported character is what the user typed (ƒ, U+0192), not what the // case transform drew (Μ, U+039C) — the user can search their résumé for // the former; the latter is provably absent from their input. - expect(cased[0]!.detail).toContain('"µ" (U+00B5)'); + expect(cased[0]!.detail).toContain('"ƒ" (U+0192)'); expect(cased[0]!.detail).not.toContain("U+039C"); }); }); @@ -337,18 +337,22 @@ describe("export findings — invisible degradations are not reported", () => { * bullet that can legally split (2/2), and therefore the shortest one this * finding can fire on. */ const FOUR_LINE_BULLET = - "BULLETSTART partnered across engineering, product and design to land a " + - "platform initiative that measurably improved customer outcomes BULLETTWO " + - "and then carried the same practice into the wider organisation, writing " + - "the runbooks BULLETTHREE and training the on-call rotation before handing " + - "the whole programme over to its permanent owners BULLETEND"; + "BULLETSTART partnered closely across engineering, product, design and " + + "operations leadership to land a platform initiative that measurably " + + "improved customer outcomes across every core segment BULLETTWO and then " + + "carried the same practice into the wider organisation, writing the " + + "complete set of on-call runbooks and operational playbooks BULLETTHREE " + + "and training the whole on-call rotation before handing the entire " + + "programme over to its permanent long-term owners BULLETEND"; /** Wraps to exactly three drawn lines — indivisible under #630/#631, so it must * move whole and must never produce this finding. */ const THREE_LINE_BULLET = - "BULLETSTART partnered across engineering, product and design to land a " + - "platform initiative that measurably improved customer outcomes BULLETMID " + - "and then carried the same practice into the wider organisation BULLETEND"; + "BULLETSTART partnered closely across engineering, product, design and " + + "operations leadership to land a platform initiative that measurably " + + "improved customer outcomes across every core segment BULLETMID and then " + + "carried the same practice into the wider organisation over several " + + "subsequent quarters of sustained delivery BULLETEND"; function modelWith(subject: AtsEntry, filler: number): AtsResumeModel { return { @@ -386,8 +390,13 @@ describe( const { extractPdfDrawnLines } = await import( "./render-ats-pdf.test-utils.ts" ); + const { REFERENCE_BODY_PT } = await import("./render-ats-pdf.ts"); + // Pinned for the same reason the export-layout contracts are: this probe + // asserts a page BREAK, and the fit pass would shrink the fixture until + // there was no break left to name. const { bytes, findings } = await renderAtsResumePdf( modelWith(subject, filler), + { bodyPt: REFERENCE_BODY_PT }, ); const lines = await extractPdfDrawnLines(bytes); const pageOf = (token: string) => { diff --git a/src/lib/pdf/render-findings.ts b/src/lib/pdf/render-findings.ts index 60656d30..df5b23a0 100644 --- a/src/lib/pdf/render-findings.ts +++ b/src/lib/pdf/render-findings.ts @@ -214,7 +214,7 @@ function isInvisibleDegradation(ch: string, drawn: string): boolean { * Every character the export font could not draw as authored, one finding per * (field, character) pair (#621). * - * `sanitize` is the renderer's OWN font-matched sanitizer — Poppins' coverage + * `sanitize` is the renderer's OWN font-matched sanitizer — Liberation Sans' coverage * predicate on the embedded path, `toWinAnsi` on the Helvetica fallback — so * this measures the font that is actually on the page rather than a guess about * it. That is what makes the pass render-time: swap the font and the answer diff --git a/src/lib/pdf/render-work-authorization.test.ts b/src/lib/pdf/render-work-authorization.test.ts index c1690dea..7d207b8f 100644 --- a/src/lib/pdf/render-work-authorization.test.ts +++ b/src/lib/pdf/render-work-authorization.test.ts @@ -20,8 +20,8 @@ * per-test default when the suite runs under load. */ -import { describe, expect, it, beforeAll } from "vitest"; -import { renderAtsResumePdf, findExportGlyphLosses } from "./render-ats-pdf.ts"; +import { describe, expect, it, beforeAll, vi } from "vitest"; +import { renderAtsResumePdf } from "./render-ats-pdf.ts"; import { extractPdfDrawnLines, type PdfDrawnLine, @@ -72,6 +72,42 @@ async function linkAnnotationUrls(bytes: Uint8Array): Promise { return urls; } +/** + * The `where` labels {@link findExportGlyphLosses} reports for a work-authorization + * statement carrying a non-WinAnsi code point, measured ON THE FALLBACK PATH. + * + * The audit answers "what would the Helvetica/WinAnsi fallback cost this user", + * and returns an empty list whenever the embedded font loads — so it can only be + * exercised with the font load FAILING. This used to come for free: Node could + * not resolve the renderer's Vite `?url` font specifier, so every test run took + * the fallback by accident. The suite now serves the real Liberation Sans bytes (see + * `src/test-setup.ts`), which is what makes the layout assertions above true of + * the actual download — and which means this audit has to ask for the fallback + * explicitly rather than inherit it. + * + * A fresh module registry is required, not just a failing `fetch`: `loadBodyFontBytes` + * memoizes SUCCESS for the life of the module, and the renders above have already + * populated that memo, so a stub installed afterwards would never be consulted. + */ +async function workAuthGlyphLossSites(): Promise { + vi.resetModules(); + vi.stubGlobal("fetch", async () => { + throw new Error("network unavailable (simulated)"); + }); + try { + const { findExportGlyphLosses: audit } = await import("./render-ats-pdf.ts"); + const losses = await audit({ + ...MODEL, + // `✓` (U+2713) has no WinAnsi code point; an em dash does, which is why + // the sample is not simply punctuation-heavy. + contact: { ...MODEL.contact, workAuthorization: "US Citizen ✓" }, + }); + return losses.map((l) => l.where); + } finally { + vi.unstubAllGlobals(); + } +} + describe("renderAtsResumePdf — work authorization on the contact line (#792)", () => { let stated: PdfDrawnLine[]; let silent: PdfDrawnLine[]; @@ -83,14 +119,7 @@ describe("renderAtsResumePdf — work authorization on the contact line (#792)", stated = await extractPdfDrawnLines(bytes); silent = await extractPdfDrawnLines((await renderAtsResumePdf(SILENT_MODEL)).bytes); urls = await linkAnnotationUrls(bytes); - glyphLossSites = ( - await findExportGlyphLosses({ - ...MODEL, - // `✓` (U+2713) has no WinAnsi code point; an em dash does, which is why - // the sample below is not simply punctuation-heavy. - contact: { ...MODEL.contact, workAuthorization: "US Citizen ✓" }, - }) - ).map((l) => l.where); + glyphLossSites = await workAuthGlyphLossSites(); }, 60_000); const contactLine = (): string => { @@ -124,9 +153,8 @@ describe("renderAtsResumePdf — work authorization on the contact line (#792)", it("is covered by the export glyph-loss audit", () => { // A statement carrying a code point the WinAnsi fallback cannot draw must be - // REPORTED, not silently rewritten to `?`. `findExportGlyphLosses` returns - // an empty list when the embedded font loads; under Node it does not, which - // is the fallback path this audit exists to measure. + // REPORTED, not silently rewritten to `?`. Measured with the font load forced + // to fail — see `workAuthGlyphLossSites` for why that has to be explicit. expect(glyphLossSites).toContain("Work authorization"); }); }); diff --git a/src/test-setup.ts b/src/test-setup.ts index d67d82ec..57a9a4e4 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -34,11 +34,67 @@ */ import { beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; import { installMemoryLocalStorage } from "./hooks/__test-utils__/memory-storage.ts"; +// Resolved once at module scope, with Node's own `fileURLToPath` + `path.join` +// rather than `new URL(relative, import.meta.url)` — under +// `@vitest-environment jsdom`, jsdom's `URL` implementation mis-resolves a +// multi-segment relative against a `file://` base (drops the project-root +// prefix), which silently ENOENTs the font shim below and falls back to +// Helvetica. `import.meta.url` itself resolves correctly in both envs; only +// the relative-URL join is jsdom's bug, so this sidesteps it entirely. +const FONTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "assets/fonts"); + // Before any test file's module body — see (1) above. installMemoryLocalStorage(); +/** + * Serve the vendored Liberation Sans TTFs to `render-ats-pdf.ts` from disk, so export + * tests measure the font PRODUCTION ACTUALLY EMBEDS. + * + * The renderer fetches its font bytes from a Vite `?url` specifier, which + * resolves to a root-relative path ("/src/assets/fonts/LiberationSans-Regular.ttf"). + * Node's `fetch` cannot parse that as a URL, so every render in the test suite + * threw, hit `loadFonts`' catch-all, and silently fell back to the built-in + * Helvetica. That fallback is a REAL production path (it is what a user gets + * when the font asset 404s), but it is the rare one — and the embedded font and + * Helvetica do not share metrics. + * + * So every layout assertion in this repo was calibrated against a font that was + * not the shipped one: wrap points, page breaks, and the fixture + * strings hand-tuned to wrap to an exact line count all encoded Helvetica + * metrics. Anything measured about pagination was measured about a document + * users never receive. Restoring the real bytes here is what makes an export + * test's "this wraps to three lines" claim true of the actual download. + * + * Only the two font assets are intercepted; every other request delegates to the + * real `fetch`, so a suite that stubs `fetch` for its own purposes is unaffected. + * Reinstalled per-test (like the storage shim above) because a suite that calls + * `vi.unstubAllGlobals()` would otherwise strip it for everything after it. + */ +function installBodyFontFetch() { + const previous = globalThis.fetch; + // Idempotent: never wrap a wrapper we already installed. + if ((previous as { __ocvFontShim?: boolean } | undefined)?.__ocvFontShim) return; + const shim = (async (input: RequestInfo | URL, init?: RequestInit) => { + // `String()` covers all three `RequestInfo | URL` shapes: a string is itself, + // a URL stringifies to its href, and a Request is unwrapped to `.url` first. + const url = String(input instanceof Request ? input.url : input); + const match = /LiberationSans-(Regular|Bold)\.ttf/.exec(url); + if (!match) return previous(input, init); + const path = join(FONTS_DIR, `LiberationSans-${match[1]}.ttf`); + return new Response(new Uint8Array(readFileSync(path)), { status: 200 }); + }) as typeof fetch & { __ocvFontShim?: boolean }; + shim.__ocvFontShim = true; + globalThis.fetch = shim; +} + +installBodyFontFetch(); + beforeEach(() => { installMemoryLocalStorage(); + installBodyFontFetch(); }); diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json index d6b7f74a..5fe50c5a 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.expected.json @@ -125,7 +125,7 @@ "phoneChangedAcrossRoundtrip": false, "locationChangedAcrossRoundtrip": false, "linkedinUrlChangedAcrossRoundtrip": false, - "experienceChangedAcrossRoundtrip": true, + "experienceChangedAcrossRoundtrip": false, "educationChangedAcrossRoundtrip": false, "skillsChangedAcrossRoundtrip": false, "summaryChangedAcrossRoundtrip": false, diff --git a/tests/fixtures/pdfs/unknown/extended-latin-name-roundtrip.expected.json b/tests/fixtures/pdfs/unknown/extended-latin-name-roundtrip.expected.json index 4e098077..94348645 100644 --- a/tests/fixtures/pdfs/unknown/extended-latin-name-roundtrip.expected.json +++ b/tests/fixtures/pdfs/unknown/extended-latin-name-roundtrip.expected.json @@ -117,7 +117,7 @@ "experienceEntriesFewerThanDateRangeLines": false, "achievementsParsedEmpty": true, "achievementsEntriesFewerThanHeaderLines": false, - "fullNameChangedAcrossRoundtrip": true, + "fullNameChangedAcrossRoundtrip": false, "emailChangedAcrossRoundtrip": false, "phoneChangedAcrossRoundtrip": false, "locationChangedAcrossRoundtrip": false, diff --git a/tests/fixtures/pdfs/unknown/extended-latin-name-roundtrip.pdf b/tests/fixtures/pdfs/unknown/extended-latin-name-roundtrip.pdf index 8ce1638b..df407ece 100644 Binary files a/tests/fixtures/pdfs/unknown/extended-latin-name-roundtrip.pdf and b/tests/fixtures/pdfs/unknown/extended-latin-name-roundtrip.pdf differ diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json index ee9f0d2d..7122737e 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json @@ -125,7 +125,7 @@ "phoneChangedAcrossRoundtrip": false, "locationChangedAcrossRoundtrip": false, "linkedinUrlChangedAcrossRoundtrip": false, - "experienceChangedAcrossRoundtrip": true, + "experienceChangedAcrossRoundtrip": false, "educationChangedAcrossRoundtrip": false, "skillsChangedAcrossRoundtrip": false, "summaryChangedAcrossRoundtrip": false,