Skip to content

fix(cairo): restore table cells split at page boundaries#2450

Open
eduralph wants to merge 1 commit into
gramps-project:maintenance/gramps61from
eduralph:fix/bug-6324-pdf-table-cell-wrap-page-break
Open

fix(cairo): restore table cells split at page boundaries#2450
eduralph wants to merge 1 commit into
gramps-project:maintenance/gramps61from
eduralph:fix/bug-6324-pdf-table-cell-wrap-page-break

Conversation

@eduralph

@eduralph eduralph commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

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):

  1. 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.

  2. 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.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. CairoDoc.paginate adds 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/gramps61gramps/plugins/lib/libcairodoc.py:620–621 (GtkDocParagraph.divide keep-together rule — now checks force_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 — respects allow_overflow only); :1310–1320 (GtkDocFrame.divide — respects allow_overflow only); :1796–1820 (CairoDoc.paginate — no-progress guard with bounded re-divide). Test file registered in po/POTFILES.skip:675 to exclude from translation.

  • Test: New test gramps/plugins/test/cairodoc_table_pagination_test.py exercises 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:

    1. 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).
    2. 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).
    3. 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).
    4. 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 -v

Fixes #6324

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants