From b2545d51212c3ca6278304ddb0c0504611f2a7f1 Mon Sep 17 00:00:00 2001 From: Mads Lorentzen Date: Wed, 19 Aug 2026 19:44:36 +0200 Subject: [PATCH] fix(latex): brace bracket-leading bullets, document escapes, pin pdftotext encoding Three findings from the 2026-08-19 review (F9, F31, F34): - F9: every placeholder bullet written as \item [text] let LaTeX parse the bracketed text as the item's optional label, rendering it clipped off the left page edge and absent from the PDF text layer ("Achievement" appeared 9 times in cv/main_example.tex and 0 times in the extraction, with a clean compile and green CI). Bullets are now braced as \item {[text]} in the example CV and in the template 06-cover-letter-templates.md teaches, and CI's stock PDF assertions additionally require "Achievement" to survive pdftotext. - F31: 05-cv-templates.md gains a "LaTeX Special Characters" section and 06's is completed beyond \_ and \&. The load-bearing case is an unescaped % in a quantified achievement bullet: it starts a LaTeX comment and silently deletes the rest of the line from the PDF. - F34: the documented ATS extraction commands (apply.md, 05-cv-templates.md, CLAUDE.md) now carry -enc UTF-8. Xpdf-based pdftotext builds default to Latin-1 output, so a correct non-ASCII CV failed the replacement-character parseability check. framework_version: 05-cv-templates.md 1.4.1 -> 1.4.2, 06-cover-letter-templates.md 1.0.1 -> 1.0.2. All three pinned by the new tests/test_latex_guidance.py (9 tests; suite now 261). Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/apply.md | 4 +- .../05-cv-templates.md | 27 +++- .../06-cover-letter-templates.md | 16 +- .github/workflows/ci.yml | 3 +- CHANGELOG.md | 27 ++++ CLAUDE.md | 2 +- cv/main_example.tex | 22 +-- tests/test_latex_guidance.py | 140 ++++++++++++++++++ 8 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 tests/test_latex_guidance.py diff --git a/.claude/commands/apply.md b/.claude/commands/apply.md index 893da9a..f9f5b0e 100644 --- a/.claude/commands/apply.md +++ b/.claude/commands/apply.md @@ -254,12 +254,12 @@ Do not proceed to Step 6 until both PDFs pass inspection. An ATS parser reads the PDF's embedded **text layer**, not the rendered page — a CV that passed visual inspection can still extract as garbage (icon glyphs where the contact details should be, scrambled reading order in multi-column layouts). This step verifies what a parser actually sees. It applies to the **CV only**; cover letters rarely go through keyword screening. -**Availability check:** run `pdftotext -v`. `pdftotext` (poppler) is an optional dependency, not part of TeX distributions. If it is missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup. +**Availability check:** run `pdftotext -v`. `pdftotext` (poppler) is an optional dependency, not part of TeX distributions. If it is missing, print a one-line warning that the mechanical parse check is skipped, do the keyword-coverage check (item 3 below) against your visual Read of the PDF instead, and note the degraded mode in the Step 6 report. Same graceful-skip pattern as the salary lookup. Keep the `-enc UTF-8` flag: Xpdf-based builds default to Latin-1 output, and without it a correct non-ASCII CV fails the replacement-character check below. **1. Extract the text layer:** ```bash -cd cv && pdftotext -layout main__.pdf main__.txt +cd cv && pdftotext -layout -enc UTF-8 main__.pdf main__.txt ``` Read the `.txt` file. diff --git a/.claude/skills/job-application-assistant/05-cv-templates.md b/.claude/skills/job-application-assistant/05-cv-templates.md index c046aa4..220c6ca 100644 --- a/.claude/skills/job-application-assistant/05-cv-templates.md +++ b/.claude/skills/job-application-assistant/05-cv-templates.md @@ -1,5 +1,5 @@ --- -framework_version: 1.4.1 +framework_version: 1.4.2 --- # CV Templates and Tailoring Guide @@ -211,6 +211,27 @@ Wherever the CV names a verifiable artifact - a public project, a hackathon entr - End with: "More references are available upon request." - **Do not attach reference letters** - employers typically contact references directly +### LaTeX Special Characters (important) + +Postings and profile data arrive as plain text; the CV is LaTeX. Escape these wherever they land in body text - company names, achievement bullets, skill lists: + +| Character | Write | Typical trigger | +|---|---|---| +| `&` | `\&` | company names: Bang \& Olufsen, Brüel \& Kjær, H\&M | +| `%` | `\%` | quantified achievements: "cut latency by 40\%" | +| `$` | `\$` | salary and cost figures | +| `#` | `\#` | "ranked \#1", C\# | +| `_` | `\_` | file names, code identifiers | +| `~` | `\textasciitilde{}` | URLs, "approx. 5 years" tildes | +| `^` | `\textasciicircum{}` | version strings, math | + +Two failure modes deserve special care: + +- **`%` fails silently.** An unescaped `%` starts a LaTeX comment: the compile succeeds with zero errors, and everything after the `%` on that line vanishes from the PDF. `Cut inference latency by 40% and saved DKK 2M annually` renders as "Cut inference latency by 40" - the bullet keeps its impressive-looking fragment and loses the actual result. Quantified achievement bullets are exactly where the guidance steers you ("use numbers where possible"), so check every `%` in every bullet before compiling. +- **`&` fails loudly** inside `\cventry` (alignment-tab errors, `Missing } inserted`). The compile loop catches it, but escape employer names up front rather than debugging the compile. + +Related trap: a bullet whose text begins with a literal `[` must be braced - `\item {[text]}` - or LaTeX parses the bracketed text as `\item`'s optional label and renders it clipped off the left page edge with a clean compile. The example CV's placeholder bullets are braced for exactly this reason. + ## Compile-and-Inspect Loop (MANDATORY) After writing the CV and before presenting to the user, always compile and visually inspect the PDF. Iterate until the layout is clean. Workflow: @@ -246,10 +267,10 @@ Restore the highest-relevance item that was previously cut — a CV that ends mi Most employers run CVs through an ATS before a human sees them, and the ATS reads the PDF's embedded **text layer**, not the rendered page. A CV can pass visual inspection and still extract as garbage. After the layout passes the compile-and-inspect loop, verify the text layer: ```bash -cd cv && pdftotext -layout main__.pdf main__.txt +cd cv && pdftotext -layout -enc UTF-8 main__.pdf main__.txt ``` -`pdftotext` comes from [poppler](https://poppler.freedesktop.org/), not the TeX distribution - it is an **optional** dependency. If it is not installed, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage. +`pdftotext` comes from [poppler](https://poppler.freedesktop.org/), not the TeX distribution - it is an **optional** dependency. The `-enc UTF-8` flag is not optional: Xpdf-based `pdftotext` builds default to Latin-1 output, which makes every non-ASCII character in a perfectly good CV read back as a replacement character and fail the parseability check below for no real reason. If it is not installed, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage. What to check in the extraction: diff --git a/.claude/skills/job-application-assistant/06-cover-letter-templates.md b/.claude/skills/job-application-assistant/06-cover-letter-templates.md index 1d1f8c2..c5e4a12 100644 --- a/.claude/skills/job-application-assistant/06-cover-letter-templates.md +++ b/.claude/skills/job-application-assistant/06-cover-letter-templates.md @@ -1,5 +1,5 @@ --- -framework_version: 1.0.1 +framework_version: 1.0.2 --- # Cover Letter Templates and Tailoring Guide @@ -92,9 +92,9 @@ The font wrapper is mandatory — if you just move `\begin{itemize}` outside `\l {\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont \begin{itemize} - \item [Concrete achievement/skill 1] - \item [Concrete achievement/skill 2] - \item [Concrete achievement/skill 3] + \item {[Concrete achievement/skill 1]} + \item {[Concrete achievement/skill 2]} + \item {[Concrete achievement/skill 3]} \end{itemize}\par} \lettercontent{[Connection to company - why this role, why this company specifically]} @@ -146,10 +146,14 @@ The font wrapper is mandatory — if you just move `\begin{itemize}` outside `\l - 3-5 bullets is ideal - Start each bullet with bold label or action verb - Use `\textbf{Label:}` for category-style bullets +- A bullet whose text begins with a literal `[` must be braced: `\item {[text]}`. Unbraced, LaTeX parses `[text]` as `\item`'s optional label and renders it off the left page edge, missing from the PDF text layer entirely ### LaTeX Special Characters -- Underscore: `\_` -- Ampersand: `\&` +Escape these wherever they appear in body text: +- Ampersand: `\&` (company names: Brüel \& Kjær, H\&M) - unescaped, the compile fails loudly +- Percent: `\%` ("grew revenue 30\%") - unescaped, it does **not** fail: everything after the `%` on that line is silently eaten as a LaTeX comment +- Dollar: `\$`, hash: `\#`, underscore: `\_` +- Tilde: `\textasciitilde{}`, caret: `\textasciicircum{}`, backslash: `\textbackslash{}` ### Non-English Cover Letters - Same template structure, just write content in the posting's language diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d496020..7156572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,8 @@ jobs: python3 tools/verify_pdf.py cv/main_example.pdf \ --pages 2 \ --contains '[your.email@example.com]' \ - --contains 'Professional Experience' + --contains 'Professional Experience' \ + --contains 'Achievement' python3 tools/verify_pdf.py cover_letters/cover_example.pdf \ --pages 1 \ --contains 'your.email@example.com' \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8449f29..20901ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ per-file diff commands. ### Added +- **LaTeX special-character guidance for CVs** (`framework_version` 1.4.1 -> 1.4.2 in + `05-cv-templates.md`, 1.0.1 -> 1.0.2 in `06-cover-letter-templates.md`) - `05` gains a + "LaTeX Special Characters" section and `06`'s existing one is completed beyond `\_`/`\&`. + The load-bearing case is an unescaped `%` in a quantified achievement bullet: it starts a + LaTeX comment, so "cut latency by 40% and saved DKK 2M" compiles with zero errors and + renders as "cut latency by 40" - silent content loss in the deliverable, on exactly the + content the guidance steers users to write. `&` in employer names (Bang & Olufsen, H&M) + fails loudly at compile time and is now documented alongside. Pinned by + `tests/test_latex_guidance.py`. + - **`seen_jobs.json` entries record which mechanism produced them** - a new additive `source` field (`cli` for Step 1b portal-CLI output, `websearch` for the Step 1c fallback), a Step 1c rule tagging fallback results at collection time, and a `fallback (websearch):` line in the @@ -46,6 +56,23 @@ per-file diff commands. ### Fixed +- **Example-CV bullets no longer swallowed as LaTeX optional labels** - every placeholder + bullet written as `\item [text]` (11 in `cv/main_example.tex`, 3 in + `06-cover-letter-templates.md`'s taught template) let LaTeX parse the bracketed text as + `\item`'s optional argument: the shipped example CV rendered all Professional Experience + bullets clipped off the left page edge, with the word "Achievement" appearing 9 times in + the source and 0 times in the PDF text layer - a clean compile, green CI. Bullets are now + braced (`\item {[text]}`), the cover-letter guide teaches the braced form, and CI's stock + PDF assertions additionally require `Achievement` to survive `pdftotext`. Pinned by + `tests/test_latex_guidance.py`. +- **Documented ATS extraction commands pin `-enc UTF-8`** - `pdftotext -layout` without an + encoding flag emits Latin-1 on Xpdf builds, so every non-ASCII character in a correct CV + (Rambøll, Ingeniør, København) read back as a replacement character and failed the + parseability checklist, steering the agent to "fix" a healthy document. The commands in + `apply.md`, `05-cv-templates.md`, and `CLAUDE.md`'s verification checklist now carry + `-enc UTF-8`, which is deterministic on both poppler and Xpdf. Pinned by + `tests/test_latex_guidance.py`. + - **`jobbank-search` search output now carries the `/scrape` contract's `date` field** (#342) - the CLI emitted `posted` (full ISO 8601) but not the cross-portal `date` key, the one Step 2 contract field it was missing. Search results now additively emit `date` as `YYYY-MM-DD` diff --git a/CLAUDE.md b/CLAUDE.md index c1c20b7..608cd1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ Both documents MUST be compiled and visually inspected via the Read tool on the - [ ] **Cover letter bullet font matches body font** - `\lettercontent{}` must not wrap `\begin{itemize}...\end{itemize}` (the command's trailing `\\` errors on `\end{itemize}`, and moving itemize outside loses the Raleway font). Standard pattern: close `\lettercontent{}`, then wrap the list in `{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont \begin{itemize}...\end{itemize}\par}` ### ATS & keyword verification (CV) -ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `pdftotext -layout` and verify what a parser sees. `pdftotext` (poppler) is optional - if missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead. +ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `pdftotext -layout -enc UTF-8` and verify what a parser sees. `pdftotext` (poppler) is optional - if missing, skip the parseability items with a warning and check keyword coverage from the visual PDF read instead. - [ ] CV text layer extracts cleanly - no `(cid:*)` markers, `�` replacement characters, or text visible in the PDF but absent from the extraction - [ ] Email and phone appear as **literal text** in the extraction (icon-glyph noise like `MOBILE-ALT`/`Envelope` is harmless, but a contact detail carried only by an icon or hyperlink is invisible to ATS) - [ ] Reading order of the extracted text matches the visual order (single-column stock template is safe; multi-column custom templates are where this breaks) diff --git a/cv/main_example.tex b/cv/main_example.tex index 7070979..59bb7f3 100644 --- a/cv/main_example.tex +++ b/cv/main_example.tex @@ -93,10 +93,10 @@ % --- Most Recent Role --- \item{\cventry{[YYYY-Present]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt} \begin{itemize} - \item [Achievement or responsibility 1 - be specific, use numbers where possible] - \item [Achievement or responsibility 2] - \item [Achievement or responsibility 3] - \item [Achievement or responsibility 4] + \item {[Achievement or responsibility 1 - be specific, use numbers where possible]} + \item {[Achievement or responsibility 2]} + \item {[Achievement or responsibility 3]} + \item {[Achievement or responsibility 4]} \end{itemize}}} \vspace{3pt} @@ -104,9 +104,9 @@ % --- Previous Role --- \item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt} \begin{itemize} - \item [Achievement or responsibility 1] - \item [Achievement or responsibility 2] - \item [Achievement or responsibility 3] + \item {[Achievement or responsibility 1]} + \item {[Achievement or responsibility 2]} + \item {[Achievement or responsibility 3]} \end{itemize}}} \vspace{3pt} @@ -114,8 +114,8 @@ % --- Earlier Role --- \item{\cventry{[YYYY-YYYY]}{[Job Title]}{[Company]}{[City, Country]}{}{\vspace{1pt} \begin{itemize} - \item [Achievement or responsibility 1] - \item [Achievement or responsibility 2] + \item {[Achievement or responsibility 1]} + \item {[Achievement or responsibility 2]} \end{itemize}}} \end{itemize} @@ -147,7 +147,7 @@ Thesis: ``[Thesis Title].'' [Brief description of research focus.] \section{Languages} \vspace{1pt} \begin{itemize} -\item [Language 1] (native), [Language 2] (fluent), [Language 3] (intermediate). +\item {[Language 1] (native), [Language 2] (fluent), [Language 3] (intermediate).} \end{itemize} % ============================================================ @@ -157,7 +157,7 @@ Thesis: ``[Thesis Title].'' [Brief description of research focus.] \section{Publications} \vspace{1pt} \begin{itemize} -\item [Author(s)] ([Year]). [Title]. [Journal/Conference]. \href{[DOI_URL]}{DOI link} +\item {[Author(s)] ([Year]). [Title]. [Journal/Conference]. \href{[DOI_URL]}{DOI link}} \end{itemize} % ============================================================ diff --git a/tests/test_latex_guidance.py b/tests/test_latex_guidance.py new file mode 100644 index 0000000..ca8539a --- /dev/null +++ b/tests/test_latex_guidance.py @@ -0,0 +1,140 @@ +"""Guards for the LaTeX authoring guidance and the example documents. + +Three silent-failure modes live here, all found by the 2026-08-19 review +(F9, F31, F34). Each one produces a clean compile and a green CI run +while the rendered document or its ATS extraction is wrong, so the spec +files and the example sources are the only place a test can catch them: + +- F9: a bullet written as `\\item [text]` is parsed as moderncv's + optional label, rendered off the left page edge, and dropped from the + PDF text layer. The example CV shipped that way for months. +- F31: an unescaped `%` in body text silently truncates the rest of the + line (`&` at least fails loudly). The guidance must name the escapes. +- F34: `pdftotext` without `-enc UTF-8` emits Latin-1 on Xpdf builds, + so a correct Danish CV fails the documented "no replacement + characters" check and the agent is sent to "fix" a healthy document. +""" +import re +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SKILL_DIR = REPO / ".claude" / "skills" / "job-application-assistant" +CV_TEMPLATES = SKILL_DIR / "05-cv-templates.md" +COVER_TEMPLATES = SKILL_DIR / "06-cover-letter-templates.md" +APPLY = REPO / ".claude" / "commands" / "apply.md" +EXAMPLE_CV = REPO / "cv" / "main_example.tex" +EXAMPLE_COVER = REPO / "cover_letters" / "cover_example.tex" + +# \item whose body starts with [ - with or without whitespace between. +# LaTeX skips spaces while scanning for the optional argument, so +# `\item [text]` and `\item[text]` both swallow the text as a label. +# The safe spelling `\item {[text]}` does not match. +UNBRACED_BRACKET_ITEM = re.compile(r"\\item\s*\[") + +# The escapes both guidance files must document. `%` is the load-bearing +# one: it truncates silently. The others fail loudly or corrupt spacing. +REQUIRED_ESCAPES = ["\\&", "\\%", "\\$", "\\#", "\\_"] + + +def section(text, heading): + """Return the body of a markdown section up to the next heading.""" + pattern = re.compile( + rf"^#+ {re.escape(heading)}[^\n]*\n(.*?)(?=^#+ |\Z)", + re.MULTILINE | re.DOTALL, + ) + match = pattern.search(text) + return match.group(1) if match else None + + +class TestBulletBracketTrap(unittest.TestCase): + """F9: no document or template doc may teach `\\item [text]`.""" + + def assert_no_unbraced_bracket_items(self, path): + offending = [ + f"{path.name}:{lineno}: {line.strip()}" + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) + if UNBRACED_BRACKET_ITEM.search(line) + ] + self.assertEqual( + offending, + [], + "\\item followed by [ is parsed as an optional label and the " + "text is clipped off the page; write \\item {[...]} instead:\n" + + "\n".join(offending), + ) + + def test_example_cv_has_no_bracket_labelled_bullets(self): + self.assert_no_unbraced_bracket_items(EXAMPLE_CV) + + def test_example_cover_letter_has_no_bracket_labelled_bullets(self): + self.assert_no_unbraced_bracket_items(EXAMPLE_COVER) + + def test_cover_letter_guide_does_not_teach_the_broken_pattern(self): + self.assert_no_unbraced_bracket_items(COVER_TEMPLATES) + + def test_cv_guide_does_not_teach_the_broken_pattern(self): + self.assert_no_unbraced_bracket_items(CV_TEMPLATES) + + +class TestSpecialCharacterGuidance(unittest.TestCase): + """F31: both template guides must document the LaTeX escapes.""" + + def assert_escapes_documented(self, path): + body = section(path.read_text(encoding="utf-8"), "LaTeX Special Characters") + self.assertIsNotNone( + body, f"{path.name} has no 'LaTeX Special Characters' section" + ) + missing = [esc for esc in REQUIRED_ESCAPES if esc not in body] + self.assertEqual( + missing, + [], + f"{path.name}'s special-characters section is missing: {missing}", + ) + + def test_cv_guide_documents_the_escapes(self): + self.assert_escapes_documented(CV_TEMPLATES) + + def test_cover_letter_guide_documents_the_escapes(self): + self.assert_escapes_documented(COVER_TEMPLATES) + + def test_cv_guide_warns_that_percent_truncates_silently(self): + body = section( + CV_TEMPLATES.read_text(encoding="utf-8"), "LaTeX Special Characters" + ) + self.assertIsNotNone(body) + self.assertRegex( + body, + re.compile(r"silent", re.IGNORECASE), + "the % failure mode must be called out as silent - it is the " + "reason this section exists (a clean compile with the rest of " + "the bullet gone)", + ) + + +class TestAtsExtractionEncoding(unittest.TestCase): + """F34: every documented extraction command must pin the encoding.""" + + def assert_pdftotext_commands_pin_utf8(self, path): + offending = [ + f"{path.name}:{lineno}: {line.strip()}" + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) + if "pdftotext" in line and "-layout" in line and "-enc UTF-8" not in line + ] + self.assertEqual( + offending, + [], + "pdftotext without -enc UTF-8 emits Latin-1 on Xpdf builds, so " + "the ATS check reports phantom replacement characters on any " + "non-ASCII CV; add -enc UTF-8:\n" + "\n".join(offending), + ) + + def test_apply_extraction_command_pins_utf8(self): + self.assert_pdftotext_commands_pin_utf8(APPLY) + + def test_cv_guide_extraction_command_pins_utf8(self): + self.assert_pdftotext_commands_pin_utf8(CV_TEMPLATES) + + +if __name__ == "__main__": + unittest.main()