fix(verify-layout): find an orphaned entry header by where the text starts (#508)

find_orphans() compared each line's left edge with the document margin:
an un-indented last line followed by an indented first line was an entry
header orphaned from its bullets. A list marker moves a line's left edge
without moving its text, so the rule misread two templates:

  * one that merges each bullet's marker into its first line (#481): a
    bullet wrapping across the break was reported as an orphaned header,
    and a real orphaned header followed by such bullets was not reported;
  * the stock CV, when its outer marker extracts as a line of its own: a
    [Job Title] header left at the foot of a page, bullets overleaf, was
    not reported (its text at x70.4 counts as indented), and a one-line
    list item whose outer marker is set 1.3pt below it - so the marker
    sorts last on the page - was reported as a split bullet although the
    whole item is on that page.

Following discussion #481, the rule now reads text x:

  * parse_pdf records where a line's text starts after leading
    LIST_MARKERS words (Line.text_left, defaulted). The set is the agreed
    Unicode bullets without the dashes (`• ‣ ▪ ● ·`): a bullet's second
    line may open with an em dash, and the stock Honors item's second line
    opens with an en dash. Other glyphs are text, so a line that merges
    the stock outer marker (`○` on some setups, an emoji on others) keeps
    the marker's x, as the left-edge rule read it;
  * find_orphans compares the text x of a page's last printed line with
    that of the next page's first printed line, and reports an orphaned
    header when the latter is more than INDENT_PT further in. Bbox lines
    whose tops are within SAME_PRINTED_LINE_PT (3pt) are one printed line:
    pdftotext's rounded-yMin buckets split a line where a bold word sits
    0.2pt off, or where a marker sits 1.3pt below its text. A line whose
    text starts more than SET_IN_LIMIT_PT (60pt) past the margin is set
    right or centred (a running header) and is skipped as the opener;
  * a page ending on a marker with no text beside it keeps the lone-marker
    report, with its condition and message unchanged.

Stock cv/main_example.tex, \vspace* of 0-600pt in 8pt steps before four
entries, 304 lualatex builds (and the same 304 with xelatex): this adds
15 reports, each a [Job Title] header at the foot of a page with its
bullets overleaf; the other 289 builds give byte-identical output. The
reported.

A two-line outer item whose marker sorts last and that wraps across the
break is no longer reported as a split bullet: it is read like the #481
wrapped bullet, which is expected clean.

Tests: TestOrphansReadTextX (13; 7 fixes and the wrapped two-line item
fail on master's logic, 5 pin behaviour master already had) and
TestTextStart (4). On Python 3.10-3.14 `python -W error::SyntaxWarning
-m unittest discover -s tests -t .` passes 520 tests (master 503);
lint_skills.py, check_framework_version.py and security_guards.py OK.
This commit is contained in:
Yang authored and GitHub committed 2026-10-01 21:53:33 +02:00
1 parent a56b8d0414
commit e355eafe27
3 files changed
+267 -14

No files matched your search

+156 -1
View File
@@ -17,7 +17,7 @@ from pathlib import Path
from unittest.mock import patch
from tools import verify_layout
from tools.verify_layout import Line, Page, find_orphans, main, parse_pdf, report
from tools.verify_layout import Line, Page, find_orphans, main, parse_pdf, report, text_start
A4_HEIGHT = 842.0
@@ -111,6 +111,161 @@ class TestOrphans(unittest.TestCase):
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])))
def at(top, left, text, text_left=None, bottom=None):
"""A bbox line; text_left is where its text starts after a merged list marker."""
bottom = top + 14.0 if bottom is None else bottom
return Line(top=top, bottom=bottom, left=left, height=bottom - top, text=text,
text_left=text_left)
class TestOrphansReadTextX(unittest.TestCase):
"""The orphan rule compares where the TEXT starts, not where the line starts.
Geometry from the #481 repro: entry headers at x59.5; a bullet's marker merged into
its first line at x58.9, its text at x71.6-72.1, continuations at x72.1. From the
stock CV: header text at x70.4, an inner bullet's marker in its own line at x71.5
with the text at x81.3, and an outer marker at x59.0 that extracts either as a line
of its own or merged into the header line, depending on the font setup.
"""
def test_bullet_wrapping_across_the_break_is_not_an_orphaned_header(self):
p1 = Page(A4_HEIGHT, [
at(50, 59.5, "Plant Grower Example Garden 1981-1990"),
at(700, 58.9, "\u2022 Replanted the long border with plants raised", 72.1),
])
p2 = Page(A4_HEIGHT, [at(60, 72.1, "from seed, lifting the old clumps")])
self.assertEqual(find_orphans([p1, p2]), [])
def test_bullet_whose_text_opens_with_a_symbol_is_still_a_wrapped_bullet(self):
p1 = Page(A4_HEIGHT, [
at(50, 59.5, "Plant Grower Example Garden 1981-1990"),
at(700, 58.9, "\u2022 (in winter) relaid the gravel paths along", 72.1),
])
p2 = Page(A4_HEIGHT, [at(60, 72.1, "the old orchard wall and round the pond")])
self.assertEqual(find_orphans([p1, p2]), [])
def test_continuation_opening_with_a_dash_is_still_a_wrapped_bullet(self):
"""Dashes are not markers: an em dash can open a bullet's second line."""
p1 = Page(A4_HEIGHT, [at(700, 58.9, "\u2022 Laid a new cobbled path, so", 72.1)])
p2 = Page(A4_HEIGHT, [at(60, 72.1, "\u2014 that rain ran off into the borders")])
self.assertEqual(find_orphans([p1, p2]), [])
def test_header_level_with_merged_bullets_is_an_orphan(self):
"""The bullet line starts at x58.9, left of the header; its text is further in."""
p1 = Page(A4_HEIGHT, [at(700, 59.5, "Kitchen Gardener 1975-1980")])
p2 = Page(A4_HEIGHT, [at(60, 58.9, "\u2022 Worked in the kitchen garden", 71.6)])
self.assertTrue(any("orphaned from its bullets" in m for m in find_orphans([p1, p2])))
def test_stock_header_whose_outer_marker_is_its_own_line(self):
"""The header's text at x70.4 counts as indented, so the left-edge rule missed it."""
p1 = Page(A4_HEIGHT, [
at(323, 59.5, "Professional Experience"),
at(695.9, 59.0, "\u25cb", bottom=707.6),
at(700.5, 70.4, "[Job Title] [YYYY-YYYY]"),
])
p2 = Page(A4_HEIGHT, [
at(62.4, 71.5, "-", bottom=79.5),
at(64.4, 81.3, "[Achievement or responsibility 1]"),
])
self.assertTrue(any("orphaned from its bullets" in m for m in find_orphans([p1, p2])))
def test_stock_header_with_its_outer_marker_merged_in(self):
"""`○` is not a LIST_MARKERS glyph, so the header keeps x59.0, as the left-edge rule read it."""
p1 = Page(A4_HEIGHT, [at(700, 59.0, "\u25cb [Job Title] [YYYY-YYYY]")])
p2 = Page(A4_HEIGHT, [at(62.4, 71.5, "-", bottom=79.5), at(64.4, 81.3, "[Achievement 1]")])
self.assertTrue(any("orphaned from its bullets" in m for m in find_orphans([p1, p2])))
def test_stock_degree_and_description_stay_reported_with_the_marker_merged(self):
"""Both texts at x70.4, but the merged `○` keeps the header at x59.0."""
p1 = Page(A4_HEIGHT, [at(700, 59.0, "\u25cb [Degree] in [Field] [YYYY-YYYY]")])
p2 = Page(A4_HEIGHT, [at(60, 70.4, "[Brief description or key topics.]")])
self.assertTrue(any("orphaned from its bullets" in m for m in find_orphans([p1, p2])))
def test_outer_marker_set_below_a_whole_item_is_not_a_split_bullet(self):
"""Stock CV: the outer marker sits 1.3pt below its one-line item and sorts last.
The left-edge rule read it as a lone marker whose item was split, although the
whole item is on p1 and p2 opens with the next item.
"""
p1 = Page(A4_HEIGHT, [
at(50, 59.5, "Core Competencies", bottom=70.5),
at(728.81, 70.4, "[Skill Category 4]: [Domain expertise, methods.]", bottom=743.4),
at(730.09, 59.0, "\U0001f7e4", bottom=741.8),
])
p2 = Page(A4_HEIGHT, [
at(66.8, 70.4, "[Skill Category 5]: [Tools, platforms, software.]", bottom=81.4),
at(68.1, 59.0, "\U0001f7e4", bottom=79.8),
])
self.assertEqual(find_orphans([p1, p2]), [])
def test_two_line_item_wrapping_across_the_break_is_read_like_a_wrapped_bullet(self):
"""A behaviour change, not a fix: master called this a split bullet, and it is split.
The item's first line is on p1 with its marker 1.3pt below it, and its second line
opens p2 at the item's text x - the #481 wrapped bullet, which is expected clean.
"""
p1 = Page(A4_HEIGHT, [
at(696.4, 59.5, "Honors and Awards", bottom=716.9),
at(722.13, 70.4, "[Award Name] for the long-running community orchard", bottom=736.7),
at(723.41, 59.0, "\U0001f7e4", bottom=735.09),
])
p2 = Page(A4_HEIGHT, [at(66.8, 69.4, "\u2013 [Event/Organization] ([Year]).")])
self.assertEqual(find_orphans([p1, p2]), [])
def test_a_printed_line_split_by_font_is_read_as_one(self):
"""pdftotext's rounded-yMin buckets split a printed line when a bold word sits 0.2pt off."""
p1 = Page(A4_HEIGHT, [
at(721.44, 125.6, "operations for the data team and later modules.]"),
at(721.64, 70.4, "Kubernetes Terraform"),
])
p2 = Page(A4_HEIGHT, [at(66.8, 70.4, "[Skill Category 3]: [Specific skills]")])
self.assertEqual(find_orphans([p1, p2]), [])
def test_stock_bullet_wrapping_across_the_break(self):
p1 = Page(A4_HEIGHT, [at(700, 81.3, "[Achievement or responsibility 1 - be")])
p2 = Page(A4_HEIGHT, [at(60, 81.3, "specific, use numbers where possible]")])
self.assertEqual(find_orphans([p1, p2]), [])
def test_a_running_header_on_the_next_page_is_not_where_the_body_resumes(self):
"""A right-aligned header at x459.8 is not text 'set further in' than a bullet."""
p1 = Page(A4_HEIGHT, [
at(50, 59.5, "Professional Experience"),
at(700, 81.3, "[Achievement or responsibility 2]"),
])
p2 = Page(A4_HEIGHT, [
at(27, 459.8, "[First] [Last] \u2013 CV"),
at(62, 70.4, "[Company] [City, Country]"),
])
self.assertEqual(find_orphans([p1, p2]), [])
def test_an_orphan_behind_a_running_header_is_still_found(self):
p1 = Page(A4_HEIGHT, [at(50, 59.5, "Professional Experience"), at(700, 70.4, "[Job Title]")])
p2 = Page(A4_HEIGHT, [
at(27, 459.8, "[First] [Last] \u2013 CV"),
at(62.4, 71.5, "-", bottom=79.5),
at(64.4, 81.3, "[Achievement or responsibility 1]"),
])
self.assertTrue(any("orphaned from its bullets" in m for m in find_orphans([p1, p2])))
class TestTextStart(unittest.TestCase):
"""parse_pdf's text_left, from (xMin, yMin, yMax, text) words sorted left to right."""
def test_a_leading_list_marker_is_set_aside(self):
self.assertEqual(text_start([(58.9, 0, 1, "\u2022"), (72.1, 0, 1, "Laid")]), 72.1)
def test_no_marker_means_no_separate_text_start(self):
self.assertIsNone(text_start([(59.5, 0, 1, "Kitchen"), (94.9, 0, 1, "Gardener")]))
def test_ascii_dashes_and_other_glyphs_are_text(self):
for glyph in ("-", "*", "\u2013", "\u2014", "\u25cb"):
with self.subTest(glyph=glyph):
self.assertIsNone(text_start([(69.4, 0, 1, glyph), (77.7, 0, 1, "[Event]")]))
def test_a_bare_marker_has_no_text(self):
self.assertIsNone(text_start([(59.0, 0, 1, "\u2022")]))
class TestExtractorFailure(unittest.TestCase):
"""A broken extractor must not masquerade as a broken document.