fix(verify_pdf): fold LaTeX's typographic substitutions before --contains; guard T1 fontenc for pdflatex (#385, #384) (#458)

`normalize_text()` folded whitespace only, so `--contains` compared what a
user types against what LaTeX renders. The stock CV compiled with the
documented lualatex command turns `'` into U+2019 and `--` into U+2013, so
`--contains "Master's degree"` and `--contains "2016-2024"` both reported
the keyword missing from a document that plainly contains it, through both
extractors. The documented remedy for a missing keyword is to add it, which
is the one thing the ATS section forbids.

Fold both sides at comparison time: NFC, then curly apostrophes and quotes
to ASCII, en/em dashes to `-`, no-break space to space. `--dump-text` still
writes the raw layer - that is what an ATS parses, and the date-range rule
in 05-cv-templates.md needs the raw en-dash visible there.

Separately, pdflatex without T1 font encoding stores accents decomposed
(`e` + U+0300). NFC repairs the pdftotext side of that, but pypdf reads the
same layer as `Z¨ urich` with a spacing accent, which no fold recovers.
moderncv 2.5 loads T1 itself under pdflatex; the apt-packaged 2.3.1 does
not - reproduced by compiling the template against moderncv v2.3.1 with
pdflatex (before: U+0308/U+0300 in pdftotext, `Z¨ urich` in pypdf; after:
U+00FC/U+00E8 in both). The template and the guide's preamble gain
`\ifpdftex\usepackage[T1]{fontenc}\fi`; the lualatex text layer is
byte-identical before and after.

Tests: ten new cases in test_verify_pdf.py (the fold-through and
normalize_text ones fail on the whitespace-only code) and a
test_latex_guidance.py guard that the fontenc line exists and stays inside
the pdflatex branch. framework_version 1.4.3 -> 1.4.4 on 05-cv-templates.md.

Reported and diagnosed by 9scorp4 in Discussions #385 and #384.
This commit is contained in:
Ayobami Adegoke
2026-09-14 18:30:50 +02:00
committed by GitHub
parent c2cd71ddee
commit 73d52e0991
6 changed files with 189 additions and 3 deletions
@@ -1,5 +1,5 @@
--- ---
framework_version: 1.4.3 framework_version: 1.4.4
--- ---
# CV Templates and Tailoring Guide # CV Templates and Tailoring Guide
@@ -42,6 +42,13 @@ Expected output: `Output written on main_<company>_<role>.pdf (2 pages, ...)`. A
\renewcommand*{\sectionstyle}[1]{{\sectionfont\color{color1}#1}} \renewcommand*{\sectionstyle}[1]{{\sectionfont\color{color1}#1}}
\usepackage[utf8]{inputenc} \usepackage[utf8]{inputenc}
% pdflatex fallback only (the documented engine is lualatex, which skips this
% branch). Without T1 font encoding pdflatex builds accented letters with
% \accent, and the PDF text layer stores them decomposed - `e` + U+0300 rather
% than U+00E8 - so an ATS keyword match on "Genève" fails while the page looks
% right. moderncv 2.5 loads T1 itself under pdflatex; 2.3.1 (Debian/Ubuntu apt)
% does not. \ifpdftex comes from iftex, which every moderncv version loads.
\ifpdftex\usepackage[T1]{fontenc}\fi
% moderncv loads hyperref itself in an \AtEndPreamble hook, so \hypersetup % moderncv loads hyperref itself in an \AtEndPreamble hook, so \hypersetup
% must go in an \AtEndPreamble of our own: on moderncv < 2.4 a top-level % must go in an \AtEndPreamble of our own: on moderncv < 2.4 a top-level
% \usepackage{hyperref} clashes with the class's own % \usepackage{hyperref} clashes with the class's own
@@ -277,7 +284,8 @@ What to check in the extraction:
- **Contact details as literal text.** The stock template's fontawesome contact icons extract as glyph names (`MOBILE-ALT`, `Envelope`) - harmless noise, because the actual address and number are printed beside them. The failure mode is a contact detail carried *only* by an icon or a hyperlink (like the `LinkedIn` link text, whose URL is not in the text layer): invisible to an ATS. The email address must always appear as printed text. - **Contact details as literal text.** The stock template's fontawesome contact icons extract as glyph names (`MOBILE-ALT`, `Envelope`) - harmless noise, because the actual address and number are printed beside them. The failure mode is a contact detail carried *only* by an icon or a hyperlink (like the `LinkedIn` link text, whose URL is not in the text layer): invisible to an ATS. The email address must always appear as printed text.
- **No garbled output.** `(cid:NNN)` markers or `` characters mean a font is embedded without a Unicode mapping - an ATS sees the same garbage. This shows up with unusual fonts in custom templates, not with the stock moderncv setup under lualatex. - **No garbled output.** `(cid:NNN)` markers or `` characters mean a font is embedded without a Unicode mapping - an ATS sees the same garbage. This shows up with unusual fonts in custom templates, not with the stock moderncv setup under lualatex.
- **Reading order.** The stock banking style is single-column, so extraction order matches visual order. Custom templates (via `/add-template`) with sidebars or multi-column layouts can interleave unrelated lines; if extraction order is scrambled, the user is trading ATS compatibility for looks and should be told. - **Reading order.** The stock banking style is single-column, so extraction order matches visual order. Custom templates (via `/add-template`) with sidebars or multi-column layouts can interleave unrelated lines; if extraction order is scrambled, the user is trading ATS compatibility for looks and should be told.
- **Keyword coverage.** Match the posting's required/preferred terms against the extracted text, in the posting's language. Prefer the posting's exact term over a synonym when it is truthfully applicable - ATS matching is often literal. Never add a keyword the profile does not support. - **Keyword coverage.** Match the posting's required/preferred terms against the extracted text, in the posting's language. Prefer the posting's exact term over a synonym when it is truthfully applicable - ATS matching is often literal. Never add a keyword the profile does not support. `verify_pdf.py --contains` folds both sides for whitespace, Unicode normalization (NFC) and LaTeX's typographic substitutions before comparing - `'` reaches the text layer as U+2019 and `--` as U+2013, so `--contains "Master's degree"` and `--contains "2016-2024"` match what the template actually renders. The dumped `.txt` is never folded: it is the raw layer the ATS sees, which is why the date-range check below reads the dump, not `--contains`.
- **Accents intact (pdflatex fallback).** Under pdflatex without T1 font encoding the text layer stores accented letters decomposed (`e` + combining grave instead of `è`); pypdf reads that as `Gen` `eve` with a stray spacing accent, and neither form matches a typed keyword. The stock template guards this with `\ifpdftex\usepackage[T1]{fontenc}\fi`; keep the line in tailored CVs and custom templates that may be compiled with pdflatex. It is a no-op under lualatex.
### Date fields must be ASCII ranges (confirmed ATS import failure) ### Date fields must be ASCII ranges (confirmed ATS import failure)
+20
View File
@@ -47,6 +47,26 @@ per-file diff commands.
### Fixed ### Fixed
- **`verify_pdf.py --contains` now sees through LaTeX's typographic substitutions and
the pdflatex text layer keeps accents precomposed** (Discussions #385, #384) - the
comparison folded whitespace only, but LaTeX ligatures `'` into U+2019 and `--` into
U+2013, so on the stock CV compiled with the documented `lualatex` command
`--contains "Master's degree"` and `--contains "2016-2024"` both reported the keyword
missing from a document that plainly contains it (measured through both extractors;
`Six Sigma` and `Statistics` on the same page passed). The documented remedy for a
missing keyword is to add it, so the false negative nudged toward the one thing the ATS
section forbids. `normalize_text()` now folds both sides - NFC, then curly
apostrophes/quotes to ASCII, en/em dashes to `-`, no-break space to space - at
comparison time only; `--dump-text` still writes the raw layer, because that is what an
ATS parses and the date-range rule in `05-cv-templates.md` needs the raw en-dash visible
there. Separately, pdflatex without T1 font encoding stores accents decomposed
(`e` + U+0300; pypdf reads it as a stray spacing accent), which NFC cannot fully
repair - moderncv 2.5 loads T1 itself under pdflatex but the apt-packaged 2.3.1 does
not, so `cv/main_example.tex` and the guide's preamble gain
`\ifpdftex\usepackage[T1]{fontenc}\fi`, a no-op on the lualatex path. Pinned by
ten new `test_verify_pdf.py` cases (the fold-through ones fail on the whitespace-only
code) and a `test_latex_guidance.py` guard that the line exists and stays
pdflatex-only. Reported and diagnosed by 9scorp4.
- **`jobdanmark-search detail` now backs off on 429/5xx like every other portal's detail - **`jobdanmark-search detail` now backs off on 429/5xx like every other portal's detail
command** - the handler called `fetch()` directly instead of going through the CLI's own command** - the handler called `fetch()` directly instead of going through the CLI's own
request wrappers, so it carried none of the three things `apiFetch`/`apiPost` guarantee: request wrappers, so it carried none of the three things `apiFetch`/`apiPost` guarantee:
+7
View File
@@ -23,6 +23,13 @@
\renewcommand*{\sectionstyle}[1]{{\sectionfont\color{color1}#1}} \renewcommand*{\sectionstyle}[1]{{\sectionfont\color{color1}#1}}
\usepackage[utf8]{inputenc} \usepackage[utf8]{inputenc}
% pdflatex fallback only (the documented engine is lualatex, which skips this
% branch). Without T1 font encoding pdflatex builds accented letters with
% \accent, and the PDF text layer stores them decomposed - `e` + U+0300 rather
% than U+00E8 - so an ATS keyword match on "Genève" fails while the page looks
% right. moderncv 2.5 loads T1 itself under pdflatex; 2.3.1 (Debian/Ubuntu apt)
% does not. \ifpdftex comes from iftex, which every moderncv version loads.
\ifpdftex\usepackage[T1]{fontenc}\fi
% moderncv loads hyperref itself in an \AtEndPreamble hook, so \hypersetup % moderncv loads hyperref itself in an \AtEndPreamble hook, so \hypersetup
% must go in an \AtEndPreamble of our own: on moderncv < 2.4 a top-level % must go in an \AtEndPreamble of our own: on moderncv < 2.4 a top-level
% \usepackage{hyperref} clashes with the class's own % \usepackage{hyperref} clashes with the class's own
+40
View File
@@ -136,5 +136,45 @@ class TestAtsExtractionEncoding(unittest.TestCase):
self.assert_pdftotext_commands_pin_utf8(CV_TEMPLATES) self.assert_pdftotext_commands_pin_utf8(CV_TEMPLATES)
class TestPdflatexFontEncodingGuard(unittest.TestCase):
"""#384: the pdflatex fallback must load T1 fontenc, and only under pdflatex.
Without T1, pdflatex stores accented letters decomposed in the text layer
(`e` + U+0300), so an ATS keyword match on `Genève` fails while the PDF
looks right. moderncv 2.5 loads T1 itself; the apt-packaged 2.3.1 does not.
The line must be guarded so the documented lualatex path is untouched.
"""
GUARDED_FONTENC = re.compile(r"\\ifpdftex\s*\\usepackage\[T1\]\{fontenc\}\s*\\fi")
def assert_has_guarded_fontenc(self, path):
text = path.read_text(encoding="utf-8")
self.assertRegex(
text,
self.GUARDED_FONTENC,
f"{path.name} must carry `\\ifpdftex\\usepackage[T1]{{fontenc}}\\fi` so a "
"pdflatex fallback keeps accents precomposed in the text layer",
)
unguarded = [
f"{path.name}:{lineno}: {line.strip()}"
for lineno, line in enumerate(text.splitlines(), 1)
if "fontenc" in line
and not line.lstrip().startswith("%")
and not self.GUARDED_FONTENC.search(line)
]
self.assertEqual(
unguarded,
[],
"fontenc must stay inside the \\ifpdftex guard - lualatex output "
"must not change:\n" + "\n".join(unguarded),
)
def test_example_cv_guards_fontenc_for_pdflatex(self):
self.assert_has_guarded_fontenc(EXAMPLE_CV)
def test_cv_guide_preamble_guards_fontenc_for_pdflatex(self):
self.assert_has_guarded_fontenc(CV_TEMPLATES)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+74
View File
@@ -7,6 +7,7 @@ from unittest.mock import patch
from tools.verify_pdf import ( from tools.verify_pdf import (
VerificationError, VerificationError,
extract_text_layer, extract_text_layer,
normalize_text,
parse_page_count, parse_page_count,
run_tool, run_tool,
verify_pdf, verify_pdf,
@@ -22,6 +23,44 @@ class ParsePageCountTests(unittest.TestCase):
parse_page_count("Title: Example\n") parse_page_count("Title: Example\n")
class NormalizeTextTests(unittest.TestCase):
"""`--contains` must see through what LaTeX does to plain source text.
Measured on the stock CV compiled with the documented `lualatex` command
(#385): the apostrophe in `Master's` reaches the text layer as U+2019 and
the `--` in `2016--2024` as U+2013, so a whitespace-only fold reports both
keywords missing from a CV that plainly contains them. Under pdflatex
without T1 font encoding, accents arrive decomposed (`e` + U+0300) instead
of precomposed (#384).
"""
def test_folds_curly_apostrophe_to_ascii(self):
self.assertEqual(normalize_text("Master\u2019s degree"), "Master's degree")
self.assertEqual(normalize_text("\u2018quoted\u2019"), "'quoted'")
def test_folds_curly_double_quotes_to_ascii(self):
self.assertEqual(normalize_text("\u201cSix Sigma\u201d"), '"Six Sigma"')
def test_folds_en_and_em_dashes_to_hyphen(self):
self.assertEqual(normalize_text("2016\u20132024"), "2016-2024")
self.assertEqual(normalize_text("role\u2014title"), "role-title")
def test_folds_no_break_space_to_space(self):
self.assertEqual(normalize_text("EUR\u00a0600k"), "EUR 600k")
def test_folds_decomposed_accents_to_nfc(self):
decomposed = "Gene\u0300ve Universite\u0301"
precomposed = "Gen\u00e8ve Universit\u00e9"
self.assertEqual(normalize_text(decomposed), precomposed)
def test_still_collapses_whitespace(self):
self.assertEqual(normalize_text("Professional\n Experience "), "Professional Experience")
def test_fold_is_symmetric(self):
# A user who pastes the curly form from a posting must match an ASCII layer too.
self.assertEqual(normalize_text("Master\u2019s"), normalize_text("Master's"))
class VerifyPdfTests(unittest.TestCase): class VerifyPdfTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
@@ -73,6 +112,41 @@ class VerifyPdfTests(unittest.TestCase):
with self.assertRaisesRegex(VerificationError, "Professional Experience"): with self.assertRaisesRegex(VerificationError, "Professional Experience"):
verify_pdf(self.pdf, required_text=("Professional Experience",)) verify_pdf(self.pdf, required_text=("Professional Experience",))
@patch("tools.verify_pdf._extract_pypdf", return_value=None)
@patch("tools.verify_pdf.run_tool")
def test_required_text_matches_latex_typographic_substitutions(self, mock_run_tool, _pypdf):
# What the stock template's lualatex text layer actually contains for the
# source `Master's degree ... 2016--2024` (code points measured, see class
# docstring of NormalizeTextTests).
mock_run_tool.side_effect = [
"Master\u2019s degree in Statistics. Six Sigma Green Belt, 2016\u20132024.\n",
"Pages: 1\n",
]
verify_pdf(self.pdf, required_text=("Master's degree", "2016-2024"))
@patch("tools.verify_pdf._extract_pypdf", return_value=None)
@patch("tools.verify_pdf.run_tool")
def test_required_text_matches_decomposed_pdflatex_accents(self, mock_run_tool, _pypdf):
mock_run_tool.side_effect = [
"Universite\u0301 de Gene\u0300ve\n", # pdflatex without T1 fontenc
"Pages: 1\n",
]
verify_pdf(self.pdf, required_text=("Universit\u00e9 de Gen\u00e8ve",))
@patch("tools.verify_pdf._extract_pypdf", return_value=None)
@patch("tools.verify_pdf.run_tool")
def test_dump_text_keeps_the_raw_layer_unfolded(self, mock_run_tool, _pypdf):
# The fold is comparison-time only: the ATS parser sees the raw layer, and
# the date-range rule in 05-cv-templates.md needs the en-dash visible here.
mock_run_tool.side_effect = ["2016\u20132024\n", "Pages: 1\n"]
dump = Path(self.temp_dir.name) / "dump.txt"
verify_pdf(self.pdf, required_text=("2016-2024",), dump_text=dump)
self.assertEqual(dump.read_text(encoding="utf-8"), "2016\u20132024\n")
def test_rejects_missing_pdf(self): def test_rejects_missing_pdf(self):
with self.assertRaisesRegex(VerificationError, "PDF does not exist"): with self.assertRaisesRegex(VerificationError, "PDF does not exist"):
verify_pdf(Path(self.temp_dir.name) / "missing.pdf") verify_pdf(Path(self.temp_dir.name) / "missing.pdf")
+38 -1
View File
@@ -4,12 +4,18 @@
Text-layer extraction tries pypdf (BSD, optional `pip install pypdf`) first, Text-layer extraction tries pypdf (BSD, optional `pip install pypdf`) first,
then Poppler `pdftotext` if pypdf is missing, raises, or returns zero then Poppler `pdftotext` if pypdf is missing, raises, or returns zero
extractable characters. Poppler remains the fallback. extractable characters. Poppler remains the fallback.
`--contains` compares after `normalize_text()` has folded both sides: whitespace,
Unicode normalization form (NFC), and the typographic substitutions LaTeX makes to
the source text. The fold is comparison-time only - the `--dump-text` output stays
the raw text layer an ATS parser actually sees.
""" """
import argparse import argparse
import re import re
import subprocess import subprocess
import sys import sys
import unicodedata
from pathlib import Path from pathlib import Path
@@ -47,7 +53,34 @@ def parse_page_count(pdfinfo_output):
return int(match.group(1)) return int(match.group(1))
# Typographic substitutions the moderncv/cover.cls templates produce from plain
# source text, mapped back to what a user types into --contains. LaTeX ligatures
# ' into U+2019 and -- into U+2013, so "Master's degree" and "2016-2024" are
# absent from the text layer of a CV that plainly contains them (#385). Applied
# to both sides of the comparison; the extracted dump is never rewritten.
TYPOGRAPHIC_FOLDS = str.maketrans(
{
"\u2018": "'", # ` -> quoteleft
"\u2019": "'", # ' -> quoteright (the possessive apostrophe)
"\u201c": '"', # `` -> quotedblleft
"\u201d": '"', # '' -> quotedblright
"\u2013": "-", # -- -> endash (the \cventry date-range case)
"\u2014": "-", # --- -> emdash
"\u00a0": " ", # ~ -> no-break space
}
)
def normalize_text(text): def normalize_text(text):
"""Fold a string for comparison: NFC, typographic punctuation, whitespace.
NFC covers the pdflatex text layer, which without T1 font encoding stores
accented letters decomposed (`e` + U+0300) while a user types them
precomposed (U+00E8); both forms fold to the same string (#384). The fold
applies to what is compared, never to what is dumped: the date-range rule in
`05-cv-templates.md` still needs the raw en-dash visible in `--dump-text`.
"""
text = unicodedata.normalize("NFC", text).translate(TYPOGRAPHIC_FOLDS)
return " ".join(text.split()) return " ".join(text.split())
@@ -144,7 +177,11 @@ def build_parser():
"--contains", "--contains",
action="append", action="append",
default=[], default=[],
help="text that must appear after whitespace normalization; repeatable", help=(
"text that must appear in the text layer; both sides are folded for "
"whitespace, NFC, and LaTeX's typographic substitutions (curly "
"apostrophes/quotes, en/em dashes, no-break spaces); repeatable"
),
) )
parser.add_argument( parser.add_argument(
"--dump-text", "--dump-text",