mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
verify_layout.py's skipped: message blamed only the xpdf-based pdftotext that
Git for Windows puts ahead of Poppler in PATH. A real Poppler can abort too:
26.0x before 26.05 crashes -bbox/-bbox-layout/-htmlmeta on a PDF whose Info
dictionary carries an empty string in any field, and hyperref writes exactly
that for every field it does not set. A lualatex/pdflatex document built with
hyperref and no \hypersetup{pdftitle=...} - an ordinary /add-template CV
template, not a malformed one - hits this with a working Poppler installed,
and the old message sent the reader to check their PATH when nothing was
wrong with it.
Named both causes in the raised message, the code comment above it, and the
module docstring. Behavior is unchanged: either cause still degrades to
skipped: exit 2, not exit 1, since a broken extractor is still not a broken
document.
Added test_poppler_abort_on_empty_info_string_names_that_cause_too, mocking
subprocess.run to raise CalledProcessError the way a real Poppler 26.0x abort
does (exit 1, a libc++abi out_of_range trace on stderr) rather than the
xpdf case's exit 99 - the two failures are the same exception type with
different exit codes and stderr text, so the message has to actually
distinguish them, not just catch the one class. Negative control: this test
fails against the pre-fix message with "'Poppler aborted' not found in
'...a pdftotext without -bbox is usually the xpdf build...'".
Thanks to @main-sounds-audio for the report and the isolated repro (which
field, which Poppler modes, five runs of five) that pinned this to Poppler's
own upstream regression (issue #1699, fixed in 26.05.0) rather than an
extraction-library swap.
Fixes #451
169 lines
7.7 KiB
Python
169 lines
7.7 KiB
Python
"""Offline tests for tools/verify_layout.py.
|
|
|
|
Every case is built from synthetic Page/Line geometry rather than a compiled
|
|
PDF, so the suite needs neither Poppler nor a LaTeX toolchain - matching the
|
|
repo's CI policy of keeping the Python tool tests self-contained.
|
|
|
|
The cases marked SILENT FAILURE are the ones that motivated the tool: each
|
|
describes a document that compiles cleanly, reports the expected page count,
|
|
and passes tools/verify_pdf.py, while the rendered page is visibly broken.
|
|
"""
|
|
|
|
import io
|
|
import subprocess
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from tools.verify_layout import Line, Page, find_orphans, main, parse_pdf, report
|
|
|
|
A4_HEIGHT = 842.0
|
|
|
|
|
|
def line(top: float, left: float = 50.0, height: float = 10.0, text: str = "x") -> Line:
|
|
return Line(top=top, bottom=top + height, left=left, height=height, text=text)
|
|
|
|
|
|
class TestGapAndBottomSpace(unittest.TestCase):
|
|
def setUp(self):
|
|
# Three body lines, then a 322pt jump, then the page-number footer.
|
|
self.holed = Page(
|
|
A4_HEIGHT,
|
|
[line(50), line(64), line(78), line(400), line(770, text="1/2")],
|
|
)
|
|
|
|
def test_largest_gap_reports_size_and_position(self):
|
|
"""SILENT FAILURE: the hole an ejected \\cventry leaves behind."""
|
|
gap, y = self.holed.largest_gap()
|
|
self.assertEqual((round(gap), round(y)), (322, 78))
|
|
|
|
def test_bottom_space_ignores_the_footer_band(self):
|
|
"""Measured to the last body line (y410), not to the page number at y770."""
|
|
self.assertEqual(round(self.holed.bottom_space), 432)
|
|
|
|
def test_page_with_no_body_lines_is_empty(self):
|
|
self.assertTrue(Page(A4_HEIGHT, []).empty)
|
|
|
|
def test_report_flags_the_hole(self):
|
|
with redirect_stdout(io.StringIO()): # report() prints its per-page measurements
|
|
problems = report(Path("synthetic"), [self.holed])
|
|
self.assertTrue(any("hole" in m for m in problems), problems)
|
|
|
|
|
|
class TestFooterBand(unittest.TestCase):
|
|
def test_single_line_in_band_is_just_the_page_number(self):
|
|
self.assertFalse(Page(A4_HEIGHT, [line(50), line(800, text="2/2")]).footer_crowded)
|
|
|
|
def test_two_lines_in_band_means_body_text_spilled_in(self):
|
|
"""SILENT FAILURE: \\enlargethispage pushing body text over the footer."""
|
|
self.assertTrue(Page(A4_HEIGHT, [line(50), line(780), line(800)]).footer_crowded)
|
|
|
|
|
|
class TestHeadingAndIndentDetection(unittest.TestCase):
|
|
def setUp(self):
|
|
self.page = Page(
|
|
A4_HEIGHT,
|
|
[
|
|
line(50, height=16.0, text="Professional Experience"),
|
|
line(80, left=50.0),
|
|
line(94, left=70.0),
|
|
],
|
|
)
|
|
|
|
def test_taller_line_is_a_heading(self):
|
|
self.assertTrue(self.page.is_heading(self.page.body[0]))
|
|
self.assertFalse(self.page.is_heading(self.page.body[1]))
|
|
|
|
def test_left_edge_separates_bullets_from_headers(self):
|
|
self.assertTrue(self.page.is_indented(self.page.body[2]))
|
|
self.assertFalse(self.page.is_indented(self.page.body[1]))
|
|
|
|
|
|
class TestOrphans(unittest.TestCase):
|
|
def test_page_ending_on_a_section_heading(self):
|
|
"""SILENT FAILURE: a heading stranded at the bottom, content overleaf."""
|
|
p1 = Page(A4_HEIGHT, [line(50), line(64), line(700, height=16.0, text="Education")])
|
|
p2 = Page(A4_HEIGHT, [line(60, text="Example University"), line(74, left=70.0)])
|
|
self.assertTrue(any("ends on the section heading" in m for m in find_orphans([p1, p2])))
|
|
|
|
def test_entry_header_orphaned_from_its_bullets(self):
|
|
"""SILENT FAILURE: the \\cventry title on one page, its bullets on the next."""
|
|
q1 = Page(A4_HEIGHT, [line(50), line(700, left=50.0, text="Software Engineer")])
|
|
q2 = Page(A4_HEIGHT, [line(60, left=70.0, text="- built the thing")])
|
|
self.assertTrue(any("orphaned from its bullets" in m for m in find_orphans([q1, q2])))
|
|
|
|
def test_lone_list_marker_is_a_split_bullet_not_an_orphaned_header(self):
|
|
"""moderncv gives the itemize marker its own bbox line: different defect, different fix."""
|
|
m1 = Page(A4_HEIGHT, [line(50), line(700, left=50.0, text="●")])
|
|
m2 = Page(A4_HEIGHT, [line(60, left=70.0, text="continued item text here")])
|
|
self.assertTrue(any("lone list marker" in m for m in find_orphans([m1, m2])))
|
|
|
|
def test_clean_break_reports_nothing(self):
|
|
r1 = Page(A4_HEIGHT, [line(50), line(700, left=50.0)])
|
|
r2 = Page(A4_HEIGHT, [line(60, left=50.0)])
|
|
self.assertEqual(find_orphans([r1, r2]), [])
|
|
|
|
def test_indent_is_judged_against_the_document_margin(self):
|
|
"""A page that OPENS with bullets must not mistake their indent for its margin."""
|
|
s1 = Page(A4_HEIGHT, [line(50, left=50.0), line(700, left=50.0, text="Data Analyst")])
|
|
s2 = Page(A4_HEIGHT, [line(60, left=70.0, text="- first bullet")])
|
|
self.assertTrue(any("orphaned from its bullets" in m for m in find_orphans([s1, s2])))
|
|
|
|
class TestExtractorFailure(unittest.TestCase):
|
|
"""A broken extractor must not masquerade as a broken document.
|
|
|
|
Git for Windows ships an xpdf-based pdftotext with no -bbox flag; it shadows
|
|
Poppler in a default PATH and exits 99. Reported as a layout problem it would
|
|
send /apply chasing a phantom hole, so it has to land on the skip path.
|
|
"""
|
|
|
|
def test_pdftotext_without_bbox_raises_a_skippable_error(self):
|
|
failure = subprocess.CalledProcessError(99, "pdftotext", stderr="Error: unknown flag")
|
|
with patch("tools.verify_layout.shutil.which", return_value="/usr/bin/pdftotext"), patch(
|
|
"tools.verify_layout.subprocess.run", side_effect=failure
|
|
):
|
|
with self.assertRaisesRegex(RuntimeError, "bounding boxes"):
|
|
parse_pdf(Path("cv/main_example.pdf"))
|
|
|
|
def test_poppler_abort_on_empty_info_string_names_that_cause_too(self):
|
|
"""Poppler 26.0x before 26.05 aborts -bbox on an empty Info-dict string, e.g.
|
|
the empty /Title hyperref writes when pdftitle is unset (#451). That crash is
|
|
not the xpdf-shadowing case - it has no -bbox flag and exits 99 - so the
|
|
message must name both, not just the one the exit code happens to match.
|
|
"""
|
|
failure = subprocess.CalledProcessError(
|
|
1,
|
|
"pdftotext",
|
|
stderr="libc++abi: terminating due to uncaught exception of type "
|
|
"std::out_of_range: basic_string",
|
|
)
|
|
with patch("tools.verify_layout.shutil.which", return_value="/usr/bin/pdftotext"), patch(
|
|
"tools.verify_layout.subprocess.run", side_effect=failure
|
|
):
|
|
with self.assertRaisesRegex(RuntimeError, "bounding boxes") as ctx:
|
|
parse_pdf(Path("cv/main_example.pdf"))
|
|
message = str(ctx.exception)
|
|
self.assertIn("xpdf", message)
|
|
self.assertIn("Poppler aborted", message)
|
|
self.assertIn("hyperref", message)
|
|
|
|
def test_missing_poppler_raises_a_skippable_error(self):
|
|
with patch("tools.verify_layout.shutil.which", return_value=None):
|
|
with self.assertRaisesRegex(RuntimeError, "not found"):
|
|
parse_pdf(Path("cv/main_example.pdf"))
|
|
|
|
def test_extractor_failure_exits_2_not_1(self):
|
|
"""Exit 1 means "your document is broken"; a dead extractor must never claim that."""
|
|
with patch("tools.verify_layout.parse_pdf", side_effect=RuntimeError("no -bbox")), patch(
|
|
"sys.argv", ["verify_layout.py", __file__]
|
|
):
|
|
err = io.StringIO()
|
|
with redirect_stdout(io.StringIO()), patch("sys.stderr", err):
|
|
self.assertEqual(main(), 2)
|
|
self.assertIn("skipped:", err.getvalue())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|