mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
ci: verify PDF page counts and text layers via poppler (#145)
This commit is contained in:
@@ -3,8 +3,9 @@
|
|||||||
#
|
#
|
||||||
# Fork-friendly by design: forks personalize CLAUDE.md, the skill files, and
|
# 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
|
# 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
|
# exact page-count/content assertions run only on the upstream template repo.
|
||||||
# success and lint correctness are asserted everywhere.
|
# 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
|
# 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
|
# real portals (network-flaky, and the linkedin-search skill is personal-use
|
||||||
@@ -102,10 +103,16 @@ jobs:
|
|||||||
container: texlive/texlive:latest
|
container: texlive/texlive:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- 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)
|
- name: Compile CV example (lualatex)
|
||||||
run: |
|
run: |
|
||||||
cd cv
|
cd cv
|
||||||
lualatex -interaction=nonstopmode main_example.tex
|
lualatex -interaction=nonstopmode -halt-on-error main_example.tex
|
||||||
test -f main_example.pdf
|
test -f main_example.pdf
|
||||||
if grep -q '^!' main_example.log; then
|
if grep -q '^!' main_example.log; then
|
||||||
echo '::error::lualatex reported errors compiling cv/main_example.tex'
|
echo '::error::lualatex reported errors compiling cv/main_example.tex'
|
||||||
@@ -115,20 +122,28 @@ jobs:
|
|||||||
- name: Compile cover letter example (xelatex)
|
- name: Compile cover letter example (xelatex)
|
||||||
run: |
|
run: |
|
||||||
cd cover_letters
|
cd cover_letters
|
||||||
xelatex -interaction=nonstopmode cover_example.tex
|
xelatex -interaction=nonstopmode -halt-on-error cover_example.tex
|
||||||
test -f cover_example.pdf
|
test -f cover_example.pdf
|
||||||
if grep -q '^!' cover_example.log; then
|
if grep -q '^!' cover_example.log; then
|
||||||
echo '::error::xelatex reported errors compiling cover_letters/cover_example.tex'
|
echo '::error::xelatex reported errors compiling cover_letters/cover_example.tex'
|
||||||
grep -A3 '^!' cover_example.log
|
grep -A3 '^!' cover_example.log
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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'
|
if: github.repository == 'MadsLorentzen/ai-job-search'
|
||||||
run: |
|
run: |
|
||||||
grep -q 'Output written on main_example.pdf (2 pages' cv/main_example.log \
|
python3 tools/verify_pdf.py cv/main_example.pdf \
|
||||||
|| { echo '::error::cv/main_example.tex no longer compiles to exactly 2 pages'; exit 1; }
|
--pages 2 \
|
||||||
grep -q 'Output written on cover_example.pdf (1 page' cover_letters/cover_example.log \
|
--contains '[your.email@example.com]' \
|
||||||
|| { echo '::error::cover_letters/cover_example.tex no longer compiles to exactly 1 page'; exit 1; }
|
--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:
|
cli-checks:
|
||||||
name: CLI checks ${{ matrix.tool }}
|
name: CLI checks ${{ matrix.tool }}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user