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,