diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2881fc6..826022e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,9 @@ # # Fork-friendly by design: forks personalize CLAUDE.md, the skill files, and # cv/main_example.tex via /setup, so the placeholder-integrity job and the -# exact page-count assertions run only on the upstream template repo. Compile -# success and lint correctness are asserted everywhere. +# exact page-count/content assertions run only on the upstream template repo. +# Compile success, lint correctness, and an extractable PDF text layer are +# asserted everywhere. # # Deliberately NOT here: live smoke tests of the job-portal CLIs. They hit # real portals (network-flaky, and the linkedin-search skill is personal-use @@ -102,10 +103,16 @@ jobs: container: texlive/texlive:latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Install PDF inspection tools + run: | + if ! command -v pdfinfo >/dev/null || ! command -v pdftotext >/dev/null; then + apt-get update + apt-get install -y --no-install-recommends poppler-utils + fi - name: Compile CV example (lualatex) run: | cd cv - lualatex -interaction=nonstopmode main_example.tex + lualatex -interaction=nonstopmode -halt-on-error main_example.tex test -f main_example.pdf if grep -q '^!' main_example.log; then echo '::error::lualatex reported errors compiling cv/main_example.tex' @@ -115,20 +122,28 @@ jobs: - name: Compile cover letter example (xelatex) run: | cd cover_letters - xelatex -interaction=nonstopmode cover_example.tex + xelatex -interaction=nonstopmode -halt-on-error cover_example.tex test -f cover_example.pdf if grep -q '^!' cover_example.log; then echo '::error::xelatex reported errors compiling cover_letters/cover_example.tex' grep -A3 '^!' cover_example.log exit 1 fi - - name: Assert exact page counts (upstream template only) + - name: Verify extractable PDF text + run: | + python3 tools/verify_pdf.py cv/main_example.pdf --min-chars 100 + python3 tools/verify_pdf.py cover_letters/cover_example.pdf --min-chars 100 + - name: Assert stock PDF structure (upstream template only) if: github.repository == 'MadsLorentzen/ai-job-search' run: | - grep -q 'Output written on main_example.pdf (2 pages' cv/main_example.log \ - || { echo '::error::cv/main_example.tex no longer compiles to exactly 2 pages'; exit 1; } - grep -q 'Output written on cover_example.pdf (1 page' cover_letters/cover_example.log \ - || { echo '::error::cover_letters/cover_example.tex no longer compiles to exactly 1 page'; exit 1; } + python3 tools/verify_pdf.py cv/main_example.pdf \ + --pages 2 \ + --contains '[your.email@example.com]' \ + --contains 'Professional Experience' + python3 tools/verify_pdf.py cover_letters/cover_example.pdf \ + --pages 1 \ + --contains 'your.email@example.com' \ + --contains 'Dear [Hiring Manager / Team]' cli-checks: name: CLI checks ${{ matrix.tool }} diff --git a/tests/test_verify_pdf.py b/tests/test_verify_pdf.py new file mode 100644 index 0000000..c9a938c --- /dev/null +++ b/tests/test_verify_pdf.py @@ -0,0 +1,85 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tools.verify_pdf import VerificationError, parse_page_count, run_tool, verify_pdf + + +class ParsePageCountTests(unittest.TestCase): + def test_parses_pdfinfo_page_count(self): + self.assertEqual(parse_page_count("Title: Example\nPages: 2\n"), 2) + + def test_rejects_output_without_page_count(self): + with self.assertRaisesRegex(VerificationError, "did not contain a page count"): + parse_page_count("Title: Example\n") + + +class VerifyPdfTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.pdf = Path(self.temp_dir.name) / "example.pdf" + self.pdf.touch() + + def tearDown(self): + self.temp_dir.cleanup() + + @patch("tools.verify_pdf.run_tool") + def test_accepts_expected_pages_and_text(self, mock_run_tool): + mock_run_tool.side_effect = [ + "Pages: 2\n", + "Professional\nExperience [your.email@example.com]\n", + ] + + verify_pdf( + self.pdf, + expected_pages=2, + min_chars=20, + required_text=("Professional Experience", "[your.email@example.com]"), + ) + + @patch("tools.verify_pdf.run_tool") + def test_rejects_wrong_page_count(self, mock_run_tool): + mock_run_tool.return_value = "Pages: 3\n" + + with self.assertRaisesRegex(VerificationError, "expected 2 page.*found 3"): + verify_pdf(self.pdf, expected_pages=2) + + @patch("tools.verify_pdf.run_tool") + def test_rejects_too_little_extractable_text(self, mock_run_tool): + mock_run_tool.return_value = "short" + + with self.assertRaisesRegex(VerificationError, "expected at least 20"): + verify_pdf(self.pdf, min_chars=20) + + @patch("tools.verify_pdf.run_tool") + def test_rejects_missing_required_text(self, mock_run_tool): + mock_run_tool.return_value = "Readable text, but not the expected section." + + with self.assertRaisesRegex(VerificationError, "Professional Experience"): + verify_pdf(self.pdf, required_text=("Professional Experience",)) + + def test_rejects_missing_pdf(self): + with self.assertRaisesRegex(VerificationError, "PDF does not exist"): + verify_pdf(Path(self.temp_dir.name) / "missing.pdf") + + +class RunToolTests(unittest.TestCase): + @patch("tools.verify_pdf.subprocess.run", side_effect=FileNotFoundError) + def test_reports_missing_poppler_command(self, _mock_run): + with self.assertRaisesRegex(VerificationError, "install poppler-utils"): + run_tool(["pdftotext", "example.pdf", "-"]) + + @patch("tools.verify_pdf.subprocess.run") + def test_reports_unreadable_pdf(self, mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + 1, ["pdfinfo", "example.pdf"], stderr="invalid PDF" + ) + + with self.assertRaisesRegex(VerificationError, "invalid PDF"): + run_tool(["pdfinfo", "example.pdf"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/verify_pdf.py b/tools/verify_pdf.py new file mode 100644 index 0000000..f31c486 --- /dev/null +++ b/tools/verify_pdf.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Verify that a generated PDF has the expected pages and extractable text.""" + +import argparse +import re +import subprocess +import sys +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, + ).stdout + except FileNotFoundError as exc: + raise VerificationError( + f"required command '{command[0]}' was not found; install poppler-utils" + ) 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)) + + +def normalize_text(text): + return " ".join(text.split()) + + +def verify_pdf(pdf_path, expected_pages=None, min_chars=1, required_text=()): + pdf_path = Path(pdf_path) + if not pdf_path.is_file(): + raise VerificationError(f"PDF does not exist: {pdf_path}") + + if expected_pages is not None: + actual_pages = parse_page_count(run_tool(["pdfinfo", str(pdf_path)])) + if actual_pages != expected_pages: + raise VerificationError( + f"expected {expected_pages} page(s), found {actual_pages}" + ) + + extracted_text = normalize_text( + run_tool(["pdftotext", "-layout", str(pdf_path), "-"]) + ) + if len(extracted_text) < min_chars: + raise VerificationError( + f"text layer has {len(extracted_text)} character(s); expected at least {min_chars}" + ) + + for required in required_text: + if normalize_text(required) not in extracted_text: + raise VerificationError(f"text layer is missing required text: {required!r}") + + +def build_parser(): + parser = argparse.ArgumentParser( + description="Verify a PDF's page count and ATS-readable text layer." + ) + parser.add_argument("pdf", type=Path, help="PDF file to verify") + parser.add_argument("--pages", type=int, help="required exact page count") + parser.add_argument( + "--min-chars", + type=int, + default=1, + help="minimum non-whitespace text-layer characters (default: 1)", + ) + parser.add_argument( + "--contains", + action="append", + default=[], + help="text that must appear after whitespace normalization; repeatable", + ) + return parser + + +def main(argv=None): + args = build_parser().parse_args(argv) + try: + verify_pdf(args.pdf, args.pages, args.min_chars, args.contains) + except VerificationError as exc: + print(f"Error: {args.pdf}: {exc}", file=sys.stderr) + return 1 + print(f"Verified {args.pdf}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())