Skip to content

fix: emit page break before a group whose children start on a new page - #708

Open
maxmilian wants to merge 5 commits into
docling-project:mainfrom
maxmilian:fix/705-page-break-group-position
Open

fix: emit page break before a group whose children start on a new page#708
maxmilian wants to merge 5 commits into
docling-project:mainfrom
maxmilian:fix/705-page-break-group-position

Conversation

@maxmilian

@maxmilian maxmilian commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #705

What was wrong

_iterate_items() in docling_core/transforms/serializer/common.py has two defects around groups, both visible with add_page_breaks=True:

  1. The group branch only matched ListGroup | InlineGroup. A plain GroupItemKEY_VALUE_AREA as reported, and the per-slide groups the PowerPoint backend produces — matched neither that branch nor the elif isinstance(item, DocItem) that follows, since GroupItem is not a DocItem. No break was emitted at the group, so the boundary was instead emitted by the group's first child, i.e. after the group, attributing the group's content to the previous page.

  2. The group branch yielded its break without advancing prev_page_nr, unlike the DocItem branch. The group's first child therefore re-emitted the same boundary, and only the self_ref dedup in get_parts() kept that duplicate out of the output.

The reported .pptx symptom follows from (1): with each slide in its own group, an N-slide deck still gets N-1 placeholders — the count looks right — but they sit before slides 3…N with one orphan after the last slide, and slides 1 and 2 share a section.

The fix

  • Broaden the group branch to GroupItem, which covers KEY_VALUE_AREA, the per-slide groups, and the deprecated OrderedList (all subclass GroupItem).
  • Advance prev_page_nr past the boundary the group branch emits, mirroring the DocItem branch.
  • Key _PageBreakNode.self_ref on the boundary (#/pb-{prev}-{next}) rather than on a running counter.

That last point is what makes the second change safe. get_parts() re-enters for every group and shares visited with the recursion, so a group spanning a boundary is seen by both the nested and the root scope. A counter is scope-local — the nested scope restarts at 0 — so once the group branch starts incrementing it, the two scopes disagree on the ref and the duplicate stops being deduplicated. Keying on the boundary makes the identity independent of traversal order. The ref still satisfies the ^#(?:/([\w-]+)(?:/(\d+))?)?$ validator, since [\w-]+ accepts dashes.

Tests

test/test_serialization.py gains a group of page-break tests that assert positions, not just placeholder counts — the reported symptom has the correct count throughout:

  • test_md_page_break_precedes_group_starting_on_new_page — the KEY_VALUE_AREA repro from the issue.
  • test_md_page_break_per_slide_groups — the 5-slide deck shape; each slide gets its own section and there is no trailing break.
  • test_md_page_break_positions_matrix — (no group / group) × (gap at start / middle / end / none), asserting each page's text lands in its own section.
  • test_md_page_break_group_straddling_empty_pages — a group whose children straddle one or two empty pages.
  • test_md_page_break_adjacent_groups_with_gap — two adjacent groups with an empty page between them.
  • test_md_page_break_nested_groups — a group inside a group, the one shape where the recursion and the shared my_visited actually interact.
  • test_page_break_boundary_emitted_once_per_transition — asserts at the iterator level that no boundary is emitted twice, and that distinct transitions stay distinguishable.
  • test_page_break_before_group_across_serializers — the same repro through DocTags, since _iterate_items is shared by markdown, LaTeX, DocTags and DocLang.

The iterator-level test earns its place because defect (2) is invisible from rendered output: reverting the prev_page_nr advance leaves every rendering test green, while the iterator emits ['#/pb-1-2', '#/pb-1-2'] and the dedup hides the duplicate. Verified fail-before / pass-after for each half of the fix independently, reverting one at a time against the final test set:

reverted result
GroupItem broadening 11 failed / 14 passed (every group case)
prev_page_nr advance 1 failed / 24 passed (the iterator-level test only)
nothing (this PR) 25 passed

Checks

uv run pytest test/          # 599 passed, 6 skipped (581 before this PR)
uv run ruff format --config=pyproject.toml   # clean
uv run ruff check --config=pyproject.toml    # clean
uv run mypy docling_core test                # Success: no issues found

Notes

  • Pre-existing, not introduced here: because refs are now keyed on (prev, next), two genuinely distinct breaks sharing a boundary would collapse into one. This is only reachable with non-monotone provenance (e.g. two sibling groups each crossing 1→2). The counter scheme lost the same case — and lost more besides, since a nested scope's first break always collided with the root's first break regardless of boundary — so this is not a regression. Flagging it rather than leaving it to be discovered.
  • Follow-up, deliberately out of scope: doclang.py still builds _PageBreakNode.self_ref from a counter in three places. Those nodes never reach get_parts()'s dedup, so there is no bug today, but they could adopt _page_break_ref for consistency. Worth noting doclang.py already dedups document-level breaks keyed on (prev_page, next_page), so boundary-as-identity is an established idiom here.
  • Scope: this does not touch fix: generate page_break for skipped pages in export functions #466 / Export functions fail to generate <page_break> tags for non-consecutive (skipped) pages #472 (expanding multi-page gaps, filling leading/trailing empty pages). Per the discussion on the issue, those are two separate rules — expanding a gap into consecutive boundaries has to happen in every scope so the refs match, while filling the document edges belongs only to the root scope — and merging them is what reintroduces this same shift. Keeping them out of this PR leaves that distinction intact.
  • No overlap with feat: support page numbers in Markdown page breaks #639, which changes markdown.py / test_serialization.py; this fix lives in common.py.
  • @serboor offered to run this branch against their 26-document corpus (2–287 pages, native + scanned + PPTX-exported) and report the delta — that would be a valuable check beyond the synthetic matrix here, since their corpus passed with the latent defect (2) still present.

The group branch of _iterate_items() only matched ListGroup | InlineGroup,
so a plain GroupItem (KEY_VALUE_AREA, and the per-slide groups the
PowerPoint backend produces) matched neither it nor the DocItem branch
that follows. The boundary was then emitted by the group's first child,
placing the break after the group and attributing the group's content to
the previous page.

The same branch also yielded its break without advancing prev_page_nr,
unlike the DocItem branch, so the group's first child re-emitted the same
boundary; only the self_ref dedup in get_parts() kept the duplicate out of
the output.

Broaden the branch to GroupItem, advance prev_page_nr past the emitted
boundary, and key _PageBreakNode.self_ref on the boundary rather than on a
running counter. The last part is what makes the second safe: get_parts()
re-enters for every group and shares visited with the recursion, so a group
spanning a boundary is seen by both the nested and the root scope, and a
scope-local counter would make the two disagree on the ref once the group
branch starts incrementing it.

Add position-asserting tests (the reported symptom has the correct
placeholder count throughout) plus an iterator-level test that no boundary
is emitted twice, which is the only level at which the duplicate is
visible.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

DCO Check Passed

Thanks @maxmilian, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 1 of 2 protections blocking · waiting on 👀 reviews

Protection Waiting on
🔴 Require two reviewer for test updates 👀 reviews
🟢 Enforce conventional commit

🔴 Require two reviewer for test updates

Waiting for

  • #approved-reviews-by >= 2
This rule is failing.

When test data is updated, we require two reviewers

  • #approved-reviews-by >= 2

Show 1 satisfied protection

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

Add the group-inside-a-group case, which is where the recursion in
_iterate_items and the my_visited set it shares with its caller actually
interact, and a DocTags assertion so the other consumers of the shared
iterator cannot regress silently. Both fail without the GroupItem
broadening.

Extend the iterator-level test to a document with three distinct
boundaries, so its uniqueness assertion is no longer trivially true for a
single-element list, and note in the matrix test that the gap expectations
pin current semantics that docling-project#466 / docling-project#472 would change.
I, Max Hsu <maxmilian@gmail.com>, hereby add my Signed-off-by to this commit: 7b00508
I, Max Hsu <maxmilian@gmail.com>, hereby add my Signed-off-by to this commit: 50827b7

Signed-off-by: Max Hsu <maxmilian@gmail.com>
Records the reasoning from docling-project#705: a corpus is a good net for regressions and
a bad net for this defect, because real documents rarely place a group across
an empty page, so a faulty stream never meets an input that would render
differently. The reporter's own latent gap-expansion defect passed both the
markdown-level assertions and a 25-document corpus; asserting the property is
what caught it.

Comment only — no test or source behaviour changes.

Signed-off-by: Max Hsu <maxmilian@gmail.com>
The previous wording said a faulty stream "never meets an input that would
render differently". Measurements from @serboor on docling-project#705 refute that: across a
25-document corpus, a 287-page document rendered 7 pairs of consecutive
placeholders on the faulty build where a page-by-page reference had none. The
misplacement is visible in rendered markdown; a corpus can catch it.

What a corpus cannot catch is the duplicate underneath it: 732 boundaries
emitted, 722 distinct, and exactly 722 placeholders in the output, because
get_parts()'s self_ref dedup collapses the extra one before it can render.
Two of the affected documents were byte-identical to the reference while
their stream was wrong.

Both assertions therefore stay, for two different reasons — which is what the
docstring now says instead of overstating the first one.

Comment only — no test or source behaviour changes.

Signed-off-by: Max Hsu <maxmilian@gmail.com>
@maxmilian

Copy link
Copy Markdown
Contributor Author

Pushed 8a46911 — docstring only, no test or source behaviour change.

@serboor's measurements on #705 refuted a claim I had made in the docstring of test_page_break_boundary_emitted_once_per_transition. It said a faulty stream "never meets an input that would render differently". Across his 25-document corpus, a 287-page document rendered 7 pairs of consecutive placeholders on the faulty build where the page-by-page reference had none — so the misplacement is visible in rendered markdown, and a corpus can catch it.

What a corpus cannot catch is the duplicate underneath: 732 boundaries emitted, 722 distinct, exactly 722 placeholders in the output, because get_parts()'s self_ref dedup collapses the extra one before it renders — including on two documents that were byte-identical to the reference while their stream was wrong.

So both assertions in the test stay, for two different reasons, and the docstring now says that instead of overstating the first one. Same reasoning quoted with attribution, as offered on the issue.

The rest of his run is on #705: 4 documents change on this branch and they are the four that motivated the issue; three become byte-identical to the reference and the fourth differs by 2 bytes of whitespace that reproduces identically on v2.90.0 (a MarkdownListSerializer join, in markdown.py, which this PR does not touch). The 32-page scan still disagrees on both builds — #472 / #466 territory, deliberately out of scope here.

Still waiting on a second approving review for the Mergify test-data protection.

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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.

Page break emitted after a group whose children start on a new page

1 participant