#!/usr/bin/env python3 """Verify that a generated PDF has the expected pages and extractable text. Text-layer extraction tries pypdf (BSD, optional `pip install pypdf`) first, then Poppler `pdftotext` if pypdf is missing, raises, or returns zero 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. `--ascii-dates` is the one check that deliberately does NOT fold: it scans the raw layer for a year joined to a Unicode dash - the en-dash LaTeX makes from `--`, which a Workday import dropped along with the date (`05-cv-templates.md`, "Date fields must be ASCII ranges") - and fails naming each hit with its code point. """ import argparse import re import subprocess import sys import unicodedata from pathlib import Path class VerificationError(Exception): """Raised when a generated PDF does not satisfy its checks.""" def run_tool(command): try: return subprocess.run( command, check=True, capture_output=True, text=True, encoding="utf-8", errors="replace", ).stdout except FileNotFoundError as exc: raise VerificationError( f"required command '{command[0]}' was not found. " "Install pypdf (`pip install pypdf`) or poppler-utils " "(macOS: brew install poppler, Debian/Ubuntu: apt install poppler-utils, " "Windows: choco install poppler)" ) from exc except subprocess.CalledProcessError as exc: detail = (exc.stderr or "").strip() or (exc.stdout or "").strip() detail = detail or "command failed" raise VerificationError(f"{command[0]} could not read the PDF: {detail}") from exc def parse_page_count(pdfinfo_output): match = re.search(r"^Pages:\s+(\d+)\s*$", pdfinfo_output, re.MULTILINE) if not match: raise VerificationError("pdfinfo output did not contain a page count") 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): """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()) # Dash-like code points a year must never be joined to in a date range. U+2013 is # what LaTeX makes from `--` and what the documented Workday import dropped; the # rest are the other Unicode dashes and the minus sign, which a parser that splits # a range only on U+002D treats the same way. NON_ASCII_DASHES = "\u2010\u2011\u2012\u2013\u2014\u2015\u2212" # A year is four digits standing alone: without the digit boundaries, `2000` # inside `120000` or `12000` made a budget or throughput range read as a date. _YEAR = r"(? None: """Write UTF-8 whatever the host's default encoding is. A piped stdout on Windows defaults to the ANSI code page (cp1252 on most Western installs), so printing a company, title or file name outside it raised UnicodeEncodeError before the workflow saw any output. """ for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) # absent on a StringIO under test if reconfigure: reconfigure(encoding="utf-8") def main(argv=None): _force_utf8_output() args = build_parser().parse_args(argv) try: extractor, text, pages = verify_pdf( args.pdf, args.pages, args.min_chars, args.contains, dump_text=args.dump_text, ascii_dates=args.ascii_dates, ) except VerificationError as exc: print(f"Error: {args.pdf}: {exc}", file=sys.stderr) return 1 print(f"Verified {args.pdf} (extractor: {extractor}, pages: {pages})") return 0 if __name__ == "__main__": sys.exit(main())