2026-07-13 15:36:46 +01:00
|
|
|
#!/usr/bin/env python3
|
2026-08-26 23:37:03 +05:30
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-07-13 15:36:46 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-08-26 23:37:03 +05:30
|
|
|
encoding="utf-8",
|
|
|
|
|
errors="replace",
|
2026-07-13 15:36:46 +01:00
|
|
|
).stdout
|
|
|
|
|
except FileNotFoundError as exc:
|
|
|
|
|
raise VerificationError(
|
2026-07-29 22:50:40 +05:30
|
|
|
f"required command '{command[0]}' was not found. "
|
2026-08-26 23:37:03 +05:30
|
|
|
"Install pypdf (`pip install pypdf`) or poppler-utils "
|
|
|
|
|
"(macOS: brew install poppler, Debian/Ubuntu: apt install poppler-utils, "
|
|
|
|
|
"Windows: choco install poppler)"
|
2026-07-13 15:36:46 +01:00
|
|
|
) 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())
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 23:37:03 +05:30
|
|
|
def _extract_pypdf(pdf_path):
|
|
|
|
|
"""Return (text, pages) or None if pypdf is unavailable, raises, or yields no text."""
|
|
|
|
|
try:
|
|
|
|
|
from pypdf import PdfReader
|
|
|
|
|
except ImportError:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
reader = PdfReader(str(pdf_path))
|
|
|
|
|
pages = len(reader.pages)
|
|
|
|
|
text = "\n".join((page.extract_text() or "") for page in reader.pages)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
# Harden: treat empty/degraded extraction as failure so we fall back
|
|
|
|
|
if len(normalize_text(text)) == 0:
|
|
|
|
|
return None
|
|
|
|
|
return text, pages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_pdftotext(pdf_path):
|
|
|
|
|
text = run_tool(["pdftotext", "-layout", "-enc", "UTF-8", str(pdf_path), "-"])
|
|
|
|
|
# Always call pdfinfo here so the fallback path returns a page count
|
|
|
|
|
# even when the caller did not request --pages (same Poppler package).
|
|
|
|
|
pages = parse_page_count(run_tool(["pdfinfo", str(pdf_path)]))
|
|
|
|
|
return text, pages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_text_layer(pdf_path):
|
|
|
|
|
"""Extract ATS-readable text. Returns (text, pages, extractor_name)."""
|
|
|
|
|
pypdf_result = _extract_pypdf(pdf_path)
|
|
|
|
|
if pypdf_result is not None:
|
|
|
|
|
text, pages = pypdf_result
|
|
|
|
|
return text, pages, "pypdf"
|
|
|
|
|
text, pages = _extract_pdftotext(pdf_path)
|
|
|
|
|
return text, pages, "pdftotext"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_pdf(pdf_path, expected_pages=None, min_chars=1, required_text=(), dump_text=None):
|
2026-07-13 15:36:46 +01:00
|
|
|
pdf_path = Path(pdf_path)
|
|
|
|
|
if not pdf_path.is_file():
|
|
|
|
|
raise VerificationError(f"PDF does not exist: {pdf_path}")
|
|
|
|
|
|
2026-08-26 23:37:03 +05:30
|
|
|
extracted_text, actual_pages, extractor = extract_text_layer(pdf_path)
|
|
|
|
|
|
|
|
|
|
# Write dump *before* the checks so a failed verification still leaves a .txt
|
|
|
|
|
if dump_text is not None:
|
|
|
|
|
dump_path = Path(dump_text)
|
|
|
|
|
try:
|
|
|
|
|
dump_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
dump_path.write_text(
|
|
|
|
|
extracted_text if extracted_text.endswith("\n") else extracted_text + "\n",
|
|
|
|
|
encoding="utf-8",
|
2026-07-13 15:36:46 +01:00
|
|
|
)
|
2026-08-26 23:37:03 +05:30
|
|
|
except OSError as exc:
|
|
|
|
|
raise VerificationError(
|
|
|
|
|
f"could not write --dump-text to {dump_path}: {exc}"
|
|
|
|
|
) from exc
|
2026-07-13 15:36:46 +01:00
|
|
|
|
2026-08-26 23:37:03 +05:30
|
|
|
if expected_pages is not None and actual_pages != expected_pages:
|
2026-07-13 15:36:46 +01:00
|
|
|
raise VerificationError(
|
2026-08-26 23:37:03 +05:30
|
|
|
f"expected {expected_pages} page(s), found {actual_pages} (extractor: {extractor})"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
normalized = normalize_text(extracted_text)
|
|
|
|
|
if len(normalized) < min_chars:
|
|
|
|
|
raise VerificationError(
|
|
|
|
|
f"text layer has {len(normalized)} character(s); expected at least {min_chars} "
|
|
|
|
|
f"(extractor: {extractor})"
|
2026-07-13 15:36:46 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for required in required_text:
|
2026-08-26 23:37:03 +05:30
|
|
|
if normalize_text(required) not in normalized:
|
|
|
|
|
raise VerificationError(
|
|
|
|
|
f"text layer is missing required text: {required!r} (extractor: {extractor})"
|
|
|
|
|
)
|
|
|
|
|
return extractor, extracted_text, actual_pages
|
2026-07-13 15:36:46 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
)
|
2026-08-26 23:37:03 +05:30
|
|
|
parser.add_argument(
|
|
|
|
|
"--dump-text",
|
|
|
|
|
type=Path,
|
|
|
|
|
help="write the extracted text layer to this path (UTF-8)",
|
|
|
|
|
)
|
2026-07-13 15:36:46 +01:00
|
|
|
return parser
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv=None):
|
|
|
|
|
args = build_parser().parse_args(argv)
|
|
|
|
|
try:
|
2026-08-26 23:37:03 +05:30
|
|
|
extractor, text, pages = verify_pdf(
|
|
|
|
|
args.pdf,
|
|
|
|
|
args.pages,
|
|
|
|
|
args.min_chars,
|
|
|
|
|
args.contains,
|
|
|
|
|
dump_text=args.dump_text,
|
|
|
|
|
)
|
2026-07-13 15:36:46 +01:00
|
|
|
except VerificationError as exc:
|
|
|
|
|
print(f"Error: {args.pdf}: {exc}", file=sys.stderr)
|
|
|
|
|
return 1
|
2026-08-26 23:37:03 +05:30
|
|
|
print(f"Verified {args.pdf} (extractor: {extractor}, pages: {pages})")
|
2026-07-13 15:36:46 +01:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-08-26 23:37:03 +05:30
|
|
|
sys.exit(main())
|