fix(cairo): restore table cells split at page boundaries#2450
Open
eduralph wants to merge 1 commit into
Open
Conversation
In the cairo/PDF backend, a table row landing near the foot of a page with a cell whose text must wrap was rendered torn: the cell printed blank at the bottom while its text appeared on the next page beside blank copies of the row's other cells (reported in Mantis 6324 by dsblank, 2010 and confirmed 2015). The root cause is in the keep-together rule of GtkDocParagraph.divide (lines 620-621 on maintenance/gramps61), which moves a short paragraph whole to the next page unconditionally. When the row's first cell hits this rule but a sibling cell has already committed content to the current page, the row is torn across the break: text moves but the cell placeholder stays, leaving a blank cell beside its rowmates' content. This fix adds a two-signal protocol to the entire divide chain (GtkDocParagraph, GtkDocTable, GtkDocTableRow, GtkDocTableCell, GtkDocPicture, GtkDocFrame): - force_split: overrides the keep-together rule when the page is already full, so a short cell's first lines render beside its rowmates instead of being dropped (the torn-row case). - allow_overflow: the stronger signal (paginator-only) that even an empty page cannot hold the content, so place it here accepting overflow rather than loop forever (the degenerate no-progress case). GtkDocTableRow.divide keeps the whole row together when the first cell that cannot fit has no committed sibling, and force-splits later cells so their first lines render beside a split sibling. The paginator's no-progress guard ensures pagination always terminates by re-dividing the continuation with both flags set when an empty page cannot make progress. Regression test added to gramps/plugins/test/cairodoc_table_pagination_test.py covers four page-boundary cases: 1. Last-column wrapping cell (whole row moves, cells begin together). 2. Earlier column splits (later short column splits too, never left blank). 3. Cell taller than a full page (paginator guard terminates, all words rendered). 4. Image in a torn row (moves intact to next page, never clipped/overflowed). All assertions read _plaintext (what divide places/truncates), not _text, so a dropped/truncated cell is genuinely detectable. Full core unit suite runs with zero new regressions from this 130-line rendering-library change. Fixes #6324 Signed-off-by: Eduard Ralph <eduard@ralphovi.net>
This was referenced Jul 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
User impact: In PDF (and other cairo-drawn) reports, when a table row lands at the bottom of a page, one cell of that row can be torn away from its neighbours: it prints blank on the first page while its text jumps to the top of the next page, sitting alone beside blank copies of the row's other cells. The table looks broken and some cell text appears misplaced.
This makes a cell that shares a row with an already-split neighbour split too, so the row's cells start together instead of being dropped.
Reported in Mantis #6324.
What to look at
The cairo backend has a "keep-together" rule that moves a short cell paragraph whole to the next page rather than splitting it — but when another cell in the same row has already placed content, that rule tears the row. The fix threads two optional flags through the divide chain so a later cell can be force-split beside a split sibling, and adds a paginator guard so pagination always advances. To try it: generate a report whose table has a wrapping cell at the foot of a page, output to PDF/cairo, and check the row's cells begin on the same page instead of one printing blank with its text orphaned overleaf.
Root cause
In the cairo/PDF backend,
GtkDocParagraph.divide(maintenance/gramps61,libcairodoc.py:620–621) implements a keep-together rule: if a cell's paragraph is short (fewer than 4 lines) and cannot fully fit in the space left on the page, move the whole paragraph to the next page instead of splitting it.The problem occurs when an earlier cell in the same row has already placed content. The keep-together rule then moves the later cell whole to the next page while its rowmates stay behind, tearing the row: the cell renders blank on the first page (placeholder committed, content moved) and its text appears orphaned on the next page beside blank copies of the row's other cells.
Fix
The divide chain now accepts two optional flags threaded through the entire chain (GtkDocParagraph, GtkDocTable, GtkDocTableRow, GtkDocTableCell, GtkDocPicture, GtkDocFrame):
force_split=True: Override the keep-together rule when the page is already full (another rowmate has split), so the cell's first lines render beside the sibling instead of being dropped.allow_overflow=True: Paginator-only signal that even an empty page cannot hold the content, so place it accepting overflow rather than loop forever (for images taller than the page).GtkDocTableRow.dividekeeps the whole row together when the first cell that cannot fit has no committed sibling, and force-splits later cells so their first lines render beside a split sibling.CairoDoc.paginateadds a no-progress guard: if an element placed nothing on an already-empty page, it re-divides with both flags set to ensure pagination always advances.Verification
Claim: A later cell force-splits beside a split sibling so the row's cells start together, images in a torn row move intact, and pagination always terminates.
Checked: on
maintenance/gramps61—gramps/plugins/lib/libcairodoc.py:620–621(GtkDocParagraph.divide keep-together rule — now checksforce_split);:843–939(GtkDocTable.divide row continuation — passes force/overflow flags only to first row);:976–1046(GtkDocTableRow.divide — tracks cell splits and force-splits later cells);:1091–1130(GtkDocTableCell.divide — returns cell whole only when not forced);:1245–1287(GtkDocPicture.divide — respectsallow_overflowonly);:1310–1320(GtkDocFrame.divide — respectsallow_overflowonly);:1796–1820(CairoDoc.paginate — no-progress guard with bounded re-divide). Test file registered inpo/POTFILES.skip:675to exclude from translation.Test: New test
gramps/plugins/test/cairodoc_table_pagination_test.pyexercises the production pagination chain against realistic geometries with a bounded loop, and all assertions read_plaintext(what divide places/truncates), not_text, so dropped/truncated cells are genuinely detectable:test_wrapping_last_cell_moves_row_whole(lines 640–688): last-column wrapping cell at page foot — RED pre-fix (cells on different pages, torn row), GREEN post-fix (cells begin together).test_wrapping_cell_splits_beside_split_sibling(lines 690–741): earlier column splits, later short column must split too — RED pre-fix (later cell blank beside split sibling), GREEN post-fix (both split together).test_wrapping_cell_taller_than_page_terminates(lines 743–770): cell cannot fit on any page — RED pre-fix (unbounded loop never terminates), GREEN post-fix (completes, all words rendered).test_image_cell_in_torn_row_moves_intact_not_overflowed(lines 772–859): image in torn row must move to next page intact — RED pre-fix (image clipped on head page), GREEN post-fix (image on continuation page, intact).Full core unit suite (32,977 tests) runs with zero new regressions. Run:
python3 -m unittest gramps.plugins.test.cairodoc_table_pagination_test -vFixes #6324