Skip to content

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

Merged
MadsLorentzen merged 1 commit into
MadsLorentzen:masterfrom
yang2632:fix/orphan-text-x
Oct 1, 2026
Merged

MadsLorentzen merged 1 commit into
MadsLorentzen:masterfrom
yang2632:fix/orphan-text-x

Conversation

@yang2632

Copy link
Copy Markdown
Contributor

Implements the shape from discussion #481: the orphan rule reads where the text starts, not where the line starts.

Failing cases, on the real path

/apply Step 5b runs python tools/verify_layout.py <pdf> on whatever template is active. The rule compared line left edges against the document margin, and a list marker moves a line's left edge without moving its text.

document master this PR
cv_orphan_false_positive.pdf (#481; a bullet wraps across the break) p1 ends on the un-indented line '• Laid a new cobbled path...' ... an entry header is orphaned from its bullets, exit 1 clean
cv_orphan_false_negative.pdf (#481; header on p1, first bullet on p2) only p2 is the last page and 64% empty also p1 ends on 'Kitchen Gardener 1975-1980' while the text on p2 starts further in ('• Worked in the kitchen garden...'): an entry header is orphaned from its bullets
stock cv/main_example.tex as shipped the thin-final-page note identical

On the stock CV I swept a \vspace* of 0-600pt in 8pt steps before four entries (Previous Role, the third role, both Education entries). That is 304 builds with lualatex and the same 304 with xelatex. On my TeX Live setup the outer \item marker extracts as a line of its own, 4.6pt above the title, and as 🟤 rather than ○. So yes, the glyph depends on the font setup. There, master does not report a [Job Title] header at the foot of a page, because its text at x70.4 counts as indented. This PR reports 15 such builds, each a header at the foot of a page with its bullets overleaf. The other 289 builds give byte-identical output, and the xelatex sweep gives the same split.

On the second stock shape, "the outer marker on its own line, which master currently misreports as a split bullet", here is how I read it; correct me if you meant something else. The misreport I can reproduce on my build comes from the outer marker being set 1.3pt below its item's text, so the marker sorts last on the page. The stock CV unchanged shows it with \vspace*{440-470pt} before Core Competencies. Page 1 ends on a whole one-line item, page 2 opens with the next item, and master reports p1 ends on a lone list marker whose text continues on p2. This PR reports nothing there. If you meant the entry-header shape, that one is test_stock_header_whose_outer_marker_is_its_own_line.

Change

  • parse_pdf records where each line's text starts after leading LIST_MARKERS words (Line.text_left, defaulted, so a Line built from left alone behaves as before). The set is the agreed Unicode one without the dashes: • ‣ ▪ ● ·.
  • Every other glyph is text. A header line that merges the stock outer marker (○ on your build, 🟤 on mine) keeps the marker's x, 59.0, which is what the left-edge rule read. That is why a merged stock [Degree] line and its description stay reported, as they are today. This does depend on the marker extracting outside the set: a setup that mapped it to • would lose that case, as the separate-line case below already does.
  • find_orphans compares the text x of the page's last printed line with the text x of the next page's first printed line. It reports an orphaned header when the latter is more than INDENT_PT further in: 59.5 -> 71.6 in the verify_layout's orphan rule assumes the stock template's bbox geometry (and can go silent on a real orphan) #481 repro, 70.4 -> 81.3 on stock, and about 11-25pt in every orphan I found.
  • Bbox lines whose tops are within SAME_PRINTED_LINE_PT = 3.0 count as one printed line. pdftotext's rounded-yMin buckets split a printed line where a bold word sits 0.2pt off (721.44 and 721.64 fall on either side of a .5 boundary), or where a marker sits 1.3pt below its text. Consecutive lines are about 12pt apart.
  • A line whose text starts more than SET_IN_LIMIT_PT = 60 past the margin is set right or centred, for example a fancyhdr running header at x459.8. It is skipped as the next page's opener. Without that, a running header counted as "further in" on every break.
  • A page ending on a marker with no text beside it keeps the lone-marker report, with the same condition and message. What changed is which pages reach that branch: a marker within 3pt of a text line is read with that line.
  • The module docstring and find_orphans say that the rule reads text x, and why.
  • A CHANGELOG entry under [Unreleased] / ### Fixed.

Not solved

  • Where the outer marker extracts as its own line (as on my build), a [Degree] line and its description are level on text x (both 70.4), so neither master nor this PR reports them. As you said, telling that from a wrapped line needs font information. I tried a column-gap test (the header's dates column) and dropped it, because an \hfill year on a list item, or a \texttt word split into its own bbox line, set it off.
  • A template whose bullet label is an en dash gets none of the verify_layout's orphan rule assumes the stock template's bbox geometry (and can go silent on a real orphan) #481 fix, because dashes are text here. Its behaviour stays the same as on master.
  • A page whose only line is a section heading is reported with the orphaned-header wording rather than the stranded-heading one, because is_heading has no median to compare against. The verdict is right and master says nothing there, but the wording is off. I have only seen this with a \vspace* that leaves one line on a page.

Tests

  • TestOrphansReadTextX has 13 synthetic cases with the verify_layout's orphan rule assumes the stock template's bbox geometry (and can go silent on a real orphan) #481 and stock geometry.
    • Seven fail on master's logic and are the fixes: the wrapped bullet, the bullet whose text opens with a symbol, the em-dash continuation, the hidden orphan, the stock header whose outer marker is its own line, the outer marker set below a whole one-line item, and an orphan behind a running header.
    • One also fails on master and pins the behaviour change flagged above: the two-line item wrapping across the break.
    • Five pin behaviour master already had: the stock header with ○ merged in, the stock [Degree] / description with ○ merged in, a printed line split by a bold word, a wrapped stock bullet, and a running header over a clean break.
  • TestTextStart (4) checks that ASCII, the dashes and ○ are text.
  • The existing cases are unchanged.

Rebased on v1.7.2. python -W error::SyntaxWarning -m unittest discover -s tests -t . passes 520 tests (master 503) on Python 3.10, 3.11, 3.12, 3.13 and 3.14. lint_skills.py, check_framework_version.py and security_guards.py are OK.

Copilot AI balanced review requested due to automatic review settings September 29, 2026 20:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…tarts

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 (MadsLorentzen#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 MadsLorentzen#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 MadsLorentzen#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.
@MadsLorentzen
MadsLorentzen merged commit e355eaf into MadsLorentzen:master Oct 1, 2026
18 checks passed
@MadsLorentzen

Copy link
Copy Markdown
Owner

Merged, thank you. This is the shape, and it held up under measurement.

Reproduced on Windows (Poppler 24.04): both #481 fixtures behave exactly as stated. The false positive is now clean; the false negative now reports the orphan beside the thin-page note. 520 OK under -W error::SyntaxWarning, lint and guards green, CHANGELOG under [Unreleased] (I rebased it onto #502's entry and pushed to your branch; CI 18/18 on the rebased head).

My own sweep: \vspace* 0 to 600pt in 25pt steps before the stock "Previous Role" entry, 25 lualatex builds, master and branch run on identical PDFs. 21 builds byte-identical. 2 (275, 450) same verdict with the merged-○ wording change. 2 (200, 575) where master's "lone list marker, bullet split" misreport becomes the correct "[Job Title] orphaned from its bullets" report, which is the stock false negative you described, with the advice now pointing at \needspace instead of at the wrong item. No build where master reported an orphan and the branch went silent.

The behaviour change you flagged (a two-line outer item wrapping across the break, no longer reported) did not come up at my positions. It is pinned by your test and named in the CHANGELOG, which is the right way to carry it. #481 is resolved by this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants