Skip to content

Commit 9ae479c

Browse files
committed
fix(cairo): restore table cells split at page boundaries
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>
1 parent 0d9e148 commit 9ae479c

4 files changed

Lines changed: 715 additions & 13 deletions

File tree

$/dev/null

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit aef9f35ec64b67f5912c5d19543060d43f270a9a

gramps/plugins/lib/libcairodoc.py

Lines changed: 212 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,25 @@ def __parse_text(self):
549549
self._text, -1, "\000"
550550
)
551551

552-
def divide(self, layout, width, height, dpi_x, dpi_y):
552+
def divide(
553+
self,
554+
layout,
555+
width,
556+
height,
557+
dpi_x,
558+
dpi_y,
559+
force_split=False,
560+
allow_overflow=False,
561+
):
562+
# bug 6324: 'force_split' (set when moving this paragraph whole to the
563+
# next page would make no progress -- a wrapping cell on the last line of
564+
# a page, or the paginator's no-progress guard) overrides the "keep a
565+
# short cell paragraph together" rule below, so at least its first lines
566+
# render here instead of the cell being dropped. 'allow_overflow' is the
567+
# stronger signal the paginator's no-progress guard adds when even a
568+
# fresh empty page cannot hold the content: only then may a line that
569+
# does not fit be placed here (accepting overflow) rather than moved --
570+
# otherwise pagination would loop forever.
553571
self.__parse_text()
554572

555573
l_margin = self._style.get_left_margin() * dpi_x / 2.54
@@ -615,9 +633,22 @@ def divide(self, layout, width, height, dpi_x, dpi_y):
615633

616634
# we need to cut paragraph:
617635

636+
# bug 6324: place this paragraph as-is on the current page, accepting
637+
# overflow. Used only when the paginator's no-progress guard has
638+
# established the page is already as large as it will get (a single line
639+
# taller than a whole page); the alternative is dropping the line or
640+
# looping forever.
641+
def _place_whole():
642+
para_h = layout_height + spacing + t_margin + (2 * v_padding)
643+
if height - para_h > b_margin:
644+
para_h += b_margin
645+
return (self, None), para_h
646+
618647
# 1. if paragraph part of a cell, we do not divide if only small part,
619-
# of paragraph can be shown, instead move to next page
620-
if line_count < 4 and self._parent._type == "CELL":
648+
# of paragraph can be shown, instead move to next page -- UNLESS the
649+
# caller forced a split (bug 6324: the row/page is already as large as it
650+
# gets, so moving whole would drop the cell or loop).
651+
if not force_split and line_count < 4 and self._parent._type == "CELL":
621652
return (None, self), 0
622653

623654
lineiter = layout.get_iter()
@@ -627,14 +658,22 @@ def divide(self, layout, width, height, dpi_x, dpi_y):
627658
# 2. if nothing fits, move to next page without split
628659
# there is a spacing above and under the text
629660
if linerange[1] - linerange[0] + 2.0 * spacing > text_height * Pango.SCALE:
630-
return (None, self), 0
661+
if not force_split:
662+
return (None, self), 0
663+
# bug 6324: forced, but not even the first line fits here. If it is
664+
# the only line and moving makes no progress (allow_overflow), place
665+
# it here (overflow); otherwise move it to the next page.
666+
if lineiter.at_last_line():
667+
return _place_whole() if allow_overflow else ((None, self), 0)
631668

632669
# 3. split the paragraph
633670
startheight = linerange[0]
634671
endheight = linerange[1]
635672
splitline = -1
636673
if lineiter.at_last_line():
637674
# only one line of text that does not fit
675+
if force_split and allow_overflow:
676+
return _place_whole()
638677
return (None, self), 0
639678

640679
while not lineiter.at_last_line():
@@ -647,6 +686,8 @@ def divide(self, layout, width, height, dpi_x, dpi_y):
647686
break
648687
endheight = linerange[1]
649688
if splitline == -1:
689+
if force_split and allow_overflow:
690+
return _place_whole()
650691
print("CairoDoc STRANGE ")
651692
return (None, self), 0
652693
# we split at splitline
@@ -843,7 +884,16 @@ class GtkDocTable(GtkDocBaseElement):
843884
_type = "TABLE"
844885
_allowed_children = ["ROW"]
845886

846-
def divide(self, layout, width, height, dpi_x, dpi_y):
887+
def divide(
888+
self,
889+
layout,
890+
width,
891+
height,
892+
dpi_x,
893+
dpi_y,
894+
force_split=False,
895+
allow_overflow=False,
896+
):
847897
# calculate real table width
848898
table_width = width * self._style.get_width() / 100
849899

@@ -852,7 +902,33 @@ def divide(self, layout, width, height, dpi_x, dpi_y):
852902
row_index = 0
853903
while row_index < len(self._children):
854904
row = self._children[row_index]
855-
(r1, r2), row_height = row.divide(layout, table_width, height, dpi_x, dpi_y)
905+
# bug 6324: only the FIRST row can legitimately be forced (it is the
906+
# one sitting at the top of an already-full/empty page); later rows
907+
# have committed rows above them and may still be kept together.
908+
(r1, r2), row_height = row.divide(
909+
layout,
910+
table_width,
911+
height,
912+
dpi_x,
913+
dpi_y,
914+
force_split and row_index == 0,
915+
allow_overflow and row_index == 0,
916+
)
917+
if r1 is None:
918+
# bug 6324: the row could place none of its content here and asks
919+
# to move whole to the next page (keep-together). Hand it and
920+
# every following row to a continuation table so the row is not
921+
# duplicated across the break. If rows above it already fit that
922+
# is real progress; if NOTHING fits, return the continuation only
923+
# (first half None) so the paginator moves it to a fresh page --
924+
# where, if it still does not fit, allow_overflow guarantees it is
925+
# placed rather than looping.
926+
new_table = GtkDocTable(self._style)
927+
list(map(new_table.add_child, self._children[row_index:]))
928+
del self._children[row_index:]
929+
if not self._children:
930+
return (None, new_table), 0
931+
return (self, new_table), table_height
856932
if r2 is not None:
857933
# break the table in two parts
858934
break
@@ -900,10 +976,20 @@ class GtkDocTableRow(GtkDocBaseElement):
900976
_type = "ROW"
901977
_allowed_children = ["CELL"]
902978

903-
def divide(self, layout, width, height, dpi_x, dpi_y):
979+
def divide(
980+
self,
981+
layout,
982+
width,
983+
height,
984+
dpi_x,
985+
dpi_y,
986+
force_split=False,
987+
allow_overflow=False,
988+
):
904989
# the highest cell gives the height of the row
905990
cell_heights = []
906991
dividedrow = False
992+
cell_split = False
907993
cell_width_iter = self._style.__iter__()
908994
new_row = GtkDocTableRow(self._style)
909995
for cell in self._children:
@@ -914,12 +1000,41 @@ def divide(self, layout, width, height, dpi_x, dpi_y):
9141000
(c1, c2), cell_height = cell.divide(
9151001
layout, cell_width, height, dpi_x, dpi_y
9161002
)
1003+
if c1 is None and not force_split and not cell_split:
1004+
# bug 6324: this cell can place none of its content in the space
1005+
# left, no earlier cell in the row has committed content yet, and
1006+
# we are allowed to move the whole row to the next page (where it
1007+
# gets a full page of room). Keep the row together and move it
1008+
# whole rather than tearing this cell -- leaving it blank here
1009+
# while its text lands on a later page (the reported defect).
1010+
return (None, self), 0
1011+
if c1 is None:
1012+
# bug 6324: either an earlier cell already split across the page
1013+
# boundary (cell_split -- so the row spans the break and cannot
1014+
# move whole), or the paginator forced the split (force_split --
1015+
# the page is already as large as it will get). Force this cell
1016+
# to split too so its first lines render here beside its rowmates
1017+
# instead of a blank cell. Overflow of an UNSPLITTABLE cell (an
1018+
# image taller than the room left) is permitted only under
1019+
# allow_overflow -- the paginator's genuine no-progress signal --
1020+
# so a merely-torn row lets such a cell move to the next page
1021+
# intact rather than clipping it here.
1022+
(c1, c2), cell_height = cell.divide(
1023+
layout,
1024+
cell_width,
1025+
height,
1026+
dpi_x,
1027+
dpi_y,
1028+
force_split=True,
1029+
allow_overflow=allow_overflow,
1030+
)
9171031
cell_heights.append(cell_height)
9181032
if c2 is None:
9191033
emptycell = GtkDocTableCell(c1._style, c1.get_span())
9201034
new_row.add_child(emptycell)
9211035
else:
9221036
dividedrow = True
1037+
cell_split = True
9231038
new_row.add_child(c2)
9241039

9251040
# save height [inch] of the row to be able to draw exact cell border
@@ -976,7 +1091,16 @@ def __init__(self, style, span=1):
9761091
def get_span(self):
9771092
return self._span
9781093

979-
def divide(self, layout, width, height, dpi_x, dpi_y):
1094+
def divide(
1095+
self,
1096+
layout,
1097+
width,
1098+
height,
1099+
dpi_x,
1100+
dpi_y,
1101+
force_split=False,
1102+
allow_overflow=False,
1103+
):
9801104
h_padding = self._style.get_padding() * dpi_x / 2.54
9811105
v_padding = self._style.get_padding() * dpi_y / 2.54
9821106

@@ -993,8 +1117,28 @@ def divide(self, layout, width, height, dpi_x, dpi_y):
9931117
for child in self._children:
9941118
if new_cell is None:
9951119
(e1, e2), child_height = child.divide(
996-
layout, width, available_height, dpi_x, dpi_y
1120+
layout,
1121+
width,
1122+
available_height,
1123+
dpi_x,
1124+
dpi_y,
1125+
force_split,
1126+
allow_overflow,
9971127
)
1128+
# bug 6324: the cell's first child could place none of its
1129+
# content in the room left (a short paragraph hitting the
1130+
# keep-together rule). When we were NOT forced, report that by
1131+
# returning the cell INTACT (None, self) so the caller
1132+
# (GtkDocTableRow.divide) can move the whole row or force a
1133+
# split -- do not fall through to the truncation below, which
1134+
# empties the cell and leaves it blank while its content is
1135+
# carried away. When we WERE forced, fall through: a splittable
1136+
# child has split (e1 set) and an unsplittable one that still
1137+
# will not fit (e1 None, e2 the child) is carried to a
1138+
# continuation cell here -- blank on this page, intact on the
1139+
# next -- rather than dropped.
1140+
if e1 is None and e2 is not None and childnr == 0 and not force_split:
1141+
return (None, self), 0
9981142
cell_height += child_height
9991143
available_height -= child_height
10001144
if e2 is not None:
@@ -1084,16 +1228,32 @@ def __init__(self, style, filename, width, height, crop=None):
10841228
self._height = height
10851229
self._crop = crop
10861230

1087-
def divide(self, layout, width, height, dpi_x, dpi_y):
1231+
def divide(
1232+
self,
1233+
layout,
1234+
width,
1235+
height,
1236+
dpi_x,
1237+
dpi_y,
1238+
force_split=False,
1239+
allow_overflow=False,
1240+
):
10881241
img_width = self._width * dpi_x / 2.54
10891242
img_height = self._height * dpi_y / 2.54
10901243

10911244
# image can't be divided, a new page must begin
10921245
# if it can't fit on the current one
10931246
if img_height <= height:
10941247
return (self, None), img_height
1095-
else:
1096-
return (None, self), 0
1248+
# bug 6324: an image can never be split. Under allow_overflow the
1249+
# paginator has established the page is already as large as it will get
1250+
# (an image taller than a whole page -- moving again would loop forever),
1251+
# so place it here accepting overflow. A merely-forced split (force_split
1252+
# without allow_overflow, e.g. a torn row whose sibling cell split) must
1253+
# NOT clip a fitting image here -- it moves to the next page intact.
1254+
if allow_overflow:
1255+
return (self, None), img_height
1256+
return (None, self), 0
10971257

10981258
def draw(self, cr, layout, width, dpi_x, dpi_y):
10991259
from gi.repository import Gtk, Gdk
@@ -1150,7 +1310,16 @@ class GtkDocFrame(GtkDocBaseElement):
11501310
_type = "FRAME"
11511311
_allowed_children = ["LINE", "POLYGON", "BOX", "TEXT"]
11521312

1153-
def divide(self, layout, width, height, dpi_x, dpi_y):
1313+
def divide(
1314+
self,
1315+
layout,
1316+
width,
1317+
height,
1318+
dpi_x,
1319+
dpi_y,
1320+
force_split=False,
1321+
allow_overflow=False,
1322+
):
11541323
frame_width = round(self._style.width * dpi_x / 2.54)
11551324
frame_height = round(self._style.height * dpi_y / 2.54)
11561325
t_margin = self._style.spacing[2] * dpi_y / 2.54
@@ -1162,6 +1331,12 @@ def divide(self, layout, width, height, dpi_x, dpi_y):
11621331
return (self, None), frame_height + t_margin + b_margin
11631332
elif frame_height + t_margin <= height:
11641333
return (self, None), height
1334+
# bug 6324: a frame taller than a whole page cannot be split. Under
1335+
# allow_overflow the paginator has established the page is already as
1336+
# large as it will get, so place it here (overflow) rather than loop
1337+
# forever; a merely-forced split moves it to the next page intact.
1338+
elif allow_overflow:
1339+
return (self, None), height
11651340
else:
11661341
return (None, self), 0
11671342

@@ -1796,10 +1971,34 @@ def paginate(self, layout, page_width, page_height, dpi_x, dpi_y):
17961971
# this is a self._doc where nothing has been added. Empty page.
17971972
return True
17981973
elem = self._elements_to_paginate.pop(0)
1974+
# bug 6324: is the current page still empty? (an empty page always has
1975+
# the full page_height available). Captured BEFORE dividing so the
1976+
# no-progress guard below can tell "nothing fit on a fresh page" (a loop)
1977+
# from "nothing fit in the room left below other content" (normal).
1978+
page_is_empty = len(self._pages[len(self._pages) - 1].get_children()) == 0
17991979
(e1, e2), e1_h = elem.divide(
18001980
layout, page_width, self._available_height, dpi_x, dpi_y
18011981
)
18021982

1983+
# bug 6324: the element placed nothing (e1 is None) yet asked to be
1984+
# carried to the next page (e2) -- and the current page is ALREADY empty.
1985+
# Moving it to yet another empty page makes no progress, so
1986+
# paginate_document's `while not paginate()` loop would spin forever.
1987+
# Re-divide the CONTINUATION (e2, which holds the content -- a table hands
1988+
# back its rows there and empties itself when nothing fits) forcing the
1989+
# split AND allowing overflow of anything unsplittable, so pagination
1990+
# places what it can here and always advances.
1991+
if e1 is None and e2 is not None and page_is_empty:
1992+
(e1, e2), e1_h = e2.divide(
1993+
layout,
1994+
page_width,
1995+
self._available_height,
1996+
dpi_x,
1997+
dpi_y,
1998+
force_split=True,
1999+
allow_overflow=True,
2000+
)
2001+
18032002
# if (part of) it fits on current page add it
18042003
if e1 is not None:
18052004
self._pages[len(self._pages) - 1].add_child(e1)

0 commit comments

Comments
 (0)