feat(ats): extract PDF text with pypdf before Poppler (#369)

* feat(ats): extract PDF text with pypdf before Poppler

Lead the ATS text-layer check with pypdf (BSD, optional pip install). Fall back to pdftotext -layout -enc UTF-8. No cache directory, no installer, no AGPL pymupdf. Windows users without Poppler still get a mechanical parseability check; visual review remains the last resort.

* Update verify_pdf.py

* Update apply.md

* Update verify_pdf.py

* Update verify_pdf.py
This commit is contained in:
sdrarunvarshan
2026-08-26 20:07:03 +02:00
committed by GitHub
parent d1504d2388
commit dea8140db2
10 changed files with 157 additions and 41 deletions
+11 -3
View File
@@ -258,15 +258,19 @@ 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. 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. 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. **Availability check:** extract with `python tools/verify_pdf.py` (tries **pypdf** first — BSD, `pip install pypdf` — then Poppler `pdftotext`). If both are 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. If a documented fallback still shells out to `pdftotext -layout`, 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:** **1. Extract the text layer:**
```bash ```bash
cd cv && pdftotext -layout -enc UTF-8 main_<company>_<role>.pdf main_<company>_<role>.txt python tools/verify_pdf.py cv/main_<company>_<role>.pdf --dump-text cv/main_<company>_<role>.txt
``` ```
Read the `.txt` file. The command prints `extractor: pypdf` or `extractor: pdftotext`. Record that name in the Step 6 report. Read the `.txt` file. If that tool is unavailable, the Poppler fallback is:
```bash
cd cv && pdftotext -layout -enc UTF-8 main_<company>_<role>.pdf main_<company>_<role>.txt
```
**2. Parseability checks** on the extracted text: **2. Parseability checks** on the extracted text:
@@ -288,6 +292,10 @@ Failures here are template-level problems: fix them in the `<CV_EXT>` source (e.
- **missing (have it)** — the profile shows the candidate genuinely has this skill but the CV never says it: add it where it fits naturally, preferring experience bullets (concrete evidence) over the profile statement, then re-run 5a5c. - **missing (have it)** — the profile shows the candidate genuinely has this skill but the CV never says it: add it where it fits naturally, preferring experience bullets (concrete evidence) over the profile statement, then re-run 5a5c.
- **missing (gap)** — a genuine gap: leave it missing. **Never stuff keywords.** This is the same honesty rule the reviewer follows — a gap gets acknowledged in the cover letter's framing, not hidden in the CV. - **missing (gap)** — a genuine gap: leave it missing. **Never stuff keywords.** This is the same honesty rule the reviewer follows — a gap gets acknowledged in the cover letter's framing, not hidden in the CV.
> **Note:** A multi-word phrase reported missing may be a punctuation-spacing artifact between extractors (pypdf sometimes inserts spaces around punctuation that Poppler does not). Re-check against the other extractor before concluding the text is absent.
**4. Clean up:** delete the extracted `.txt` file. **4. Clean up:** delete the extracted `.txt` file.
### 5e. Clean up build artifacts ### 5e. Clean up build artifacts
+2
View File
@@ -5,6 +5,8 @@
"Bash(bun run:*)", "Bash(bun run:*)",
"Bash(python salary_lookup.py:*)", "Bash(python salary_lookup.py:*)",
"Bash(python3 salary_lookup.py:*)", "Bash(python3 salary_lookup.py:*)",
"Bash(python tools/verify_pdf.py:*)",
"Bash(python3 tools/verify_pdf.py:*)",
"Bash(pdftotext:*)" "Bash(pdftotext:*)"
] ]
} }
@@ -1,5 +1,5 @@
--- ---
framework_version: 1.4.2 framework_version: 1.4.3
--- ---
# CV Templates and Tailoring Guide # CV Templates and Tailoring Guide
@@ -267,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: 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 ```bash
cd cv && pdftotext -layout -enc UTF-8 main_<company>_<role>.pdf main_<company>_<role>.txt python tools/verify_pdf.py cv/main_<company>_<role>.pdf --dump-text cv/main_<company>_<role>.txt
``` ```
`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. Extraction tries **pypdf** first (`pip install pypdf`, BSD license), then Poppler `pdftotext`. If a fallback still uses `pdftotext -layout`, it must also pass `-enc UTF-8`: Xpdf-based builds default to Latin-1, which makes every non-ASCII character in a perfectly good CV read back as a replacement character. If neither extractor is available, skip the mechanical check with a warning and rely on the visual PDF read for keyword coverage.
What to check in the extraction: What to check in the extraction:
+1
View File
@@ -15,6 +15,7 @@ per-file diff commands.
### Added ### Added
- **pypdf ATS text-layer fallback** - `/apply` Step 5d and `tools/verify_pdf.py` extract the CV PDF text layer with **pypdf** first (BSD, `pip install pypdf`) so Windows machines without Poppler still get a mechanical parseability check. Poppler `pdftotext -layout -enc UTF-8` remains the fallback; if both are missing the check still degrades to a visual keyword review. No extra cache or installer. `05-cv-templates.md` `framework_version` 1.4.2 → 1.4.3.
- **CI now tests the full documented Python range** (#370) - the Python tool tests job - **CI now tests the full documented Python range** (#370) - the Python tool tests job
runs a 3.10-3.14 version matrix instead of pinning 3.12, so both the documented 3.10 runs a 3.10-3.14 version matrix instead of pinning 3.12, so both the documented 3.10
minimum and the newest Python are continuously verified. Grew out of an independent minimum and the newest Python are continuously verified. Grew out of an independent
+1 -1
View File
@@ -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}` - [ ] **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 & keyword verification (CV)
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. ATS parsers read the PDF's embedded text layer, not the rendered page. Extract it with `python tools/verify_pdf.py cv/main_<company>_<role>.pdf --dump-text cv/main_<company>_<role>.txt` (pypdf, then `pdftotext -layout -enc UTF-8`) and verify what a parser sees. If both extractors are 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 - [ ] 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) - [ ] 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) - [ ] Reading order of the extracted text matches the visual order (single-column stock template is safe; multi-column custom templates are where this breaks)
+1 -1
View File
@@ -65,7 +65,7 @@ The framework encodes career guidance best practices, including structured evalu
- Python 3.10+ - Python 3.10+
- [Bun](https://bun.sh) (for job search CLI tools) - [Bun](https://bun.sh) (for job search CLI tools)
- LaTeX distribution with `lualatex` and `xelatex`: [TeX Live](https://tug.org/texlive/), [MacTeX](https://tug.org/mactex/), [TinyTeX](https://yihui.org/tinytex/), or [MiKTeX](https://miktex.org/). The CV compiles with `lualatex` (pdflatex often fails on modern MiKTeX installs with `fontawesome5` font-expansion errors); the cover letter compiles with `xelatex` because `cover.cls` requires `fontspec`. If using a minimal TeX install such as TinyTeX or BasicTeX, install the extra packages listed in [SETUP.md](SETUP.md#minimal-tex-install-tinytexbasictex). - LaTeX distribution with `lualatex` and `xelatex`: [TeX Live](https://tug.org/texlive/), [MacTeX](https://tug.org/mactex/), [TinyTeX](https://yihui.org/tinytex/), or [MiKTeX](https://miktex.org/). The CV compiles with `lualatex` (pdflatex often fails on modern MiKTeX installs with `fontawesome5` font-expansion errors); the cover letter compiles with `xelatex` because `cover.cls` requires `fontspec`. If using a minimal TeX install such as TinyTeX or BasicTeX, install the extra packages listed in [SETUP.md](SETUP.md#minimal-tex-install-tinytexbasictex).
- Optional: `pdftotext` from [poppler](https://poppler.freedesktop.org/) (macOS: `brew install poppler`, Debian/Ubuntu: `apt install poppler-utils`, Windows: `choco install poppler`) — used by `/apply`'s ATS parseability check on the compiled CV. If missing, the check degrades gracefully to a visual keyword review. - Optional: `pip install pypdf` for `/apply`'s ATS parseability check (BSD; no Poppler required). Poppler `pdftotext` remains a fallback (macOS: `brew install poppler`, Debian/Ubuntu: `apt install poppler-utils`, Windows: `choco install poppler`). If both are missing, the check degrades to a visual keyword review.
## Quick start ## Quick start
+5 -3
View File
@@ -141,15 +141,17 @@ Copy-Item cover_letters\cover.cls, cover_letters\OpenFonts -Destination $SmokeDi
Push-Location $SmokeDir; xelatex -interaction=nonstopmode -halt-on-error cover_smoke.tex; Pop-Location Push-Location $SmokeDir; xelatex -interaction=nonstopmode -halt-on-error cover_smoke.tex; Pop-Location
``` ```
### Optional: pdftotext (for the ATS check) ### Optional: ATS text extraction (pypdf, then pdftotext)
`/apply` runs an ATS parseability check on the compiled CV: it extracts the PDF's text layer and verifies contact details, reading order, and keyword coverage the way an applicant-tracking system sees them. This uses `pdftotext` from [poppler](https://poppler.freedesktop.org/), which is not part of TeX distributions: `/apply` runs an ATS parseability check on the compiled CV: it extracts the PDF's text layer and verifies contact details, reading order, and keyword coverage the way an applicant-tracking system sees them.
The default extractor is **pypdf** (BSD, `pip install pypdf`). Poppler `pdftotext` remains an optional fallback:
- **macOS:** `brew install poppler` - **macOS:** `brew install poppler`
- **Debian/Ubuntu:** `sudo apt install poppler-utils` - **Debian/Ubuntu:** `sudo apt install poppler-utils`
- **Windows:** `choco install poppler` - **Windows:** `choco install poppler`
If `pdftotext` is missing, `/apply` skips the mechanical check with a warning and falls back to a visual keyword review — everything else works normally. If a command still uses `pdftotext -layout`, it must pass `-enc UTF-8` as well. If **neither** extractor is available, `/apply` skips the mechanical check with a warning and falls back to a visual keyword review — everything else works normally.
## 2. Fork and clone ## 2. Fork and clone
+40 -10
View File
@@ -4,7 +4,13 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from tools.verify_pdf import VerificationError, parse_page_count, run_tool, verify_pdf from tools.verify_pdf import (
VerificationError,
extract_text_layer,
parse_page_count,
run_tool,
verify_pdf,
)
class ParsePageCountTests(unittest.TestCase): class ParsePageCountTests(unittest.TestCase):
@@ -25,11 +31,12 @@ class VerifyPdfTests(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.temp_dir.cleanup() self.temp_dir.cleanup()
@patch("tools.verify_pdf._extract_pypdf", return_value=None)
@patch("tools.verify_pdf.run_tool") @patch("tools.verify_pdf.run_tool")
def test_accepts_expected_pages_and_text(self, mock_run_tool): def test_accepts_expected_pages_and_text(self, mock_run_tool, _pypdf):
mock_run_tool.side_effect = [ mock_run_tool.side_effect = [
"Pages: 2\n",
"Professional\nExperience [your.email@example.com]\n", "Professional\nExperience [your.email@example.com]\n",
"Pages: 2\n",
] ]
verify_pdf( verify_pdf(
@@ -39,23 +46,29 @@ class VerifyPdfTests(unittest.TestCase):
required_text=("Professional Experience", "[your.email@example.com]"), required_text=("Professional Experience", "[your.email@example.com]"),
) )
@patch("tools.verify_pdf._extract_pypdf", return_value=None)
@patch("tools.verify_pdf.run_tool") @patch("tools.verify_pdf.run_tool")
def test_rejects_wrong_page_count(self, mock_run_tool): def test_rejects_wrong_page_count(self, mock_run_tool, _pypdf):
mock_run_tool.return_value = "Pages: 3\n" mock_run_tool.side_effect = ["ok", "Pages: 3\n"]
with self.assertRaisesRegex(VerificationError, "expected 2 page.*found 3"): with self.assertRaisesRegex(VerificationError, "expected 2 page.*found 3"):
verify_pdf(self.pdf, expected_pages=2) verify_pdf(self.pdf, expected_pages=2)
@patch("tools.verify_pdf._extract_pypdf", return_value=None)
@patch("tools.verify_pdf.run_tool") @patch("tools.verify_pdf.run_tool")
def test_rejects_too_little_extractable_text(self, mock_run_tool): def test_rejects_too_little_extractable_text(self, mock_run_tool, _pypdf):
mock_run_tool.return_value = "short" mock_run_tool.side_effect = ["short", "Pages: 1\n"]
with self.assertRaisesRegex(VerificationError, "expected at least 20"): with self.assertRaisesRegex(VerificationError, "expected at least 20"):
verify_pdf(self.pdf, min_chars=20) verify_pdf(self.pdf, min_chars=20)
@patch("tools.verify_pdf._extract_pypdf", return_value=None)
@patch("tools.verify_pdf.run_tool") @patch("tools.verify_pdf.run_tool")
def test_rejects_missing_required_text(self, mock_run_tool): def test_rejects_missing_required_text(self, mock_run_tool, _pypdf):
mock_run_tool.return_value = "Readable text, but not the expected section." mock_run_tool.side_effect = [
"Readable text, but not the expected section.",
"Pages: 1\n",
]
with self.assertRaisesRegex(VerificationError, "Professional Experience"): with self.assertRaisesRegex(VerificationError, "Professional Experience"):
verify_pdf(self.pdf, required_text=("Professional Experience",)) verify_pdf(self.pdf, required_text=("Professional Experience",))
@@ -64,11 +77,28 @@ class VerifyPdfTests(unittest.TestCase):
with self.assertRaisesRegex(VerificationError, "PDF does not exist"): with self.assertRaisesRegex(VerificationError, "PDF does not exist"):
verify_pdf(Path(self.temp_dir.name) / "missing.pdf") verify_pdf(Path(self.temp_dir.name) / "missing.pdf")
@patch("tools.verify_pdf._extract_pypdf", return_value=("Hello ATS body", 1))
def test_pypdf_is_preferred_over_poppler(self, _pypdf):
text, pages, extractor = extract_text_layer(self.pdf)
self.assertEqual(extractor, "pypdf")
self.assertEqual(text, "Hello ATS body")
self.assertEqual(pages, 1)
@patch("tools.verify_pdf._extract_pypdf", return_value=None)
@patch("tools.verify_pdf.run_tool")
def test_falls_back_to_pdftotext(self, mock_run_tool, _pypdf):
mock_run_tool.side_effect = ["poppler text", "Pages: 2\n"]
text, pages, extractor = extract_text_layer(self.pdf)
self.assertEqual(extractor, "pdftotext")
self.assertEqual(text, "poppler text")
self.assertEqual(pages, 2)
self.assertEqual(mock_run_tool.call_args_list[0][0][0][:3], ["pdftotext", "-layout", "-enc"])
class RunToolTests(unittest.TestCase): class RunToolTests(unittest.TestCase):
@patch("tools.verify_pdf.subprocess.run", side_effect=FileNotFoundError) @patch("tools.verify_pdf.subprocess.run", side_effect=FileNotFoundError)
def test_reports_missing_poppler_command(self, _mock_run): def test_reports_missing_poppler_command(self, _mock_run):
with self.assertRaisesRegex(VerificationError, "install poppler-utils"): with self.assertRaisesRegex(VerificationError, "pip install pypdf"):
run_tool(["pdftotext", "example.pdf", "-"]) run_tool(["pdftotext", "example.pdf", "-"])
@patch("tools.verify_pdf.subprocess.run") @patch("tools.verify_pdf.subprocess.run")
+2
View File
@@ -41,6 +41,8 @@ ALLOWED_PERMISSIONS = {
"Bash(bun run:*)", "Bash(bun run:*)",
"Bash(python salary_lookup.py:*)", "Bash(python salary_lookup.py:*)",
"Bash(python3 salary_lookup.py:*)", "Bash(python3 salary_lookup.py:*)",
"Bash(python tools/verify_pdf.py:*)",
"Bash(python3 tools/verify_pdf.py:*)",
"Bash(pdftotext:*)", "Bash(pdftotext:*)",
} }
+88 -17
View File
@@ -1,5 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Verify that a generated PDF has the expected pages and extractable text.""" """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.
"""
import argparse import argparse
import re import re
@@ -19,12 +24,15 @@ def run_tool(command):
check=True, check=True,
capture_output=True, capture_output=True,
text=True, text=True,
encoding="utf-8",
errors="replace",
).stdout ).stdout
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise VerificationError( raise VerificationError(
f"required command '{command[0]}' was not found. " f"required command '{command[0]}' was not found. "
"Install poppler-utils (macOS: brew install poppler, " "Install pypdf (`pip install pypdf`) or poppler-utils "
"Debian/Ubuntu: apt install poppler-utils, Windows: choco install poppler)" "(macOS: brew install poppler, Debian/Ubuntu: apt install poppler-utils, "
"Windows: choco install poppler)"
) from exc ) from exc
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
detail = (exc.stderr or "").strip() or (exc.stdout or "").strip() detail = (exc.stderr or "").strip() or (exc.stdout or "").strip()
@@ -43,29 +51,81 @@ def normalize_text(text):
return " ".join(text.split()) return " ".join(text.split())
def verify_pdf(pdf_path, expected_pages=None, min_chars=1, required_text=()): 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):
pdf_path = Path(pdf_path) pdf_path = Path(pdf_path)
if not pdf_path.is_file(): if not pdf_path.is_file():
raise VerificationError(f"PDF does not exist: {pdf_path}") raise VerificationError(f"PDF does not exist: {pdf_path}")
if expected_pages is not None: extracted_text, actual_pages, extractor = extract_text_layer(pdf_path)
actual_pages = parse_page_count(run_tool(["pdfinfo", str(pdf_path)]))
if actual_pages != expected_pages: # 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",
)
except OSError as exc:
raise VerificationError( raise VerificationError(
f"expected {expected_pages} page(s), found {actual_pages}" f"could not write --dump-text to {dump_path}: {exc}"
) from exc
if expected_pages is not None and actual_pages != expected_pages:
raise VerificationError(
f"expected {expected_pages} page(s), found {actual_pages} (extractor: {extractor})"
) )
extracted_text = normalize_text( normalized = normalize_text(extracted_text)
run_tool(["pdftotext", "-layout", str(pdf_path), "-"]) if len(normalized) < min_chars:
)
if len(extracted_text) < min_chars:
raise VerificationError( raise VerificationError(
f"text layer has {len(extracted_text)} character(s); expected at least {min_chars}" f"text layer has {len(normalized)} character(s); expected at least {min_chars} "
f"(extractor: {extractor})"
) )
for required in required_text: for required in required_text:
if normalize_text(required) not in extracted_text: if normalize_text(required) not in normalized:
raise VerificationError(f"text layer is missing required text: {required!r}") raise VerificationError(
f"text layer is missing required text: {required!r} (extractor: {extractor})"
)
return extractor, extracted_text, actual_pages
def build_parser(): def build_parser():
@@ -86,17 +146,28 @@ def build_parser():
default=[], default=[],
help="text that must appear after whitespace normalization; repeatable", help="text that must appear after whitespace normalization; repeatable",
) )
parser.add_argument(
"--dump-text",
type=Path,
help="write the extracted text layer to this path (UTF-8)",
)
return parser return parser
def main(argv=None): def main(argv=None):
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
try: try:
verify_pdf(args.pdf, args.pages, args.min_chars, args.contains) extractor, text, pages = verify_pdf(
args.pdf,
args.pages,
args.min_chars,
args.contains,
dump_text=args.dump_text,
)
except VerificationError as exc: except VerificationError as exc:
print(f"Error: {args.pdf}: {exc}", file=sys.stderr) print(f"Error: {args.pdf}: {exc}", file=sys.stderr)
return 1 return 1
print(f"Verified {args.pdf}") print(f"Verified {args.pdf} (extractor: {extractor}, pages: {pages})")
return 0 return 0