Skip to content

Commit 1effbb0

Browse files
Implementation of rowspan in tables (#1088)
* table: Initial functional implementation of rowspan * table: Permit combination of colspan+rowspan * table: Introduced TableSpan enum placeholder for rowspan/colspan * table: WIP refactor of rowspan regularisation * Docs: Added `rowspan` to tables page * table: Fixed book-keeping for combined rowspan and colspan * table: Dropped cols_count and column_indices from Row No longer relevant after rowspan refactor * table: Fixed gutter accumulation at end of table * html: Added cellpadding and cellspacing table attribute handling * table: refactored rowspan processing Returns to frozen dataclasses, row-rendering mechanism * table: Check for rowspans across heading boundary * html: Fixed table border reset when no <th> present * test: Adding test cases for rowspan implementation * table: Fixed handling of RHS border in case of colspan * table: Fixed get_cell_border for rowspan * table: Dropped requirement that all rows have the same number of columns * html: Handle border=0 in table definition * table: Use single disable_writing call for consistency Previous behaviour allowed cascading of font style, in particular test case test_table_with_capitalized_font_family_and_emphasis * table: Fix calculation of row heights with images * table: Replaced assertion errors with FPDFException * test: Added test case for varying number of table columns * changelog: Updated for table changes * doc: Updated docstring of fpdf.table() * table: Fixed indexing for combined rowspan and colspan * table: Fix pagebreaks with overlapping spans and added test * table: minor refactor and blacked * table: Improved rowspan padding accumulation, added image test * table: Minor changes per pylint suggestions * table: Fixed regression in pagebreak calculation * html: Updated docs for new table attributes * table: Fixed outer border handling after pagebreak --------- Co-authored-by: Anderson Herzogenrath da Costa <[email protected]>
1 parent 6a8678b commit 1effbb0

19 files changed

+667
-129
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,21 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
2121
* support for `<path>` elements in SVG `<clipPath>` elements
2222
* documentation on how to combine `fpdf2` with [mistletoe](https://pypi.org/project/kaleido/) in order to [generate PDF documents from Markdown (link)](https://py-pdf.github.io/fpdf2/CombineWithMistletoeoToUseMarkdown.html)
2323
* tutorial in Dutch: [Handleiding](https://py-pdf.github.io/fpdf2/Tutorial-nl.md) - thanks to @Polderrider
24+
* support for `Table` cells that span multiple rows via the `rowspan` attribute, which can be combined with `colspan` - thanks to @mjasperse
25+
* `TableSpan.COL` and `TableSpan.ROW` enums that can be used as placeholder table entries to identify span extents - thanks to @mjasperse
2426
### Fixed
2527
* when adding a link on a table cell, an extra link was added erroneously on the left. Moreover, now `FPDF._disable_writing()` properly disable link writing.
2628
* [`FPDF.write_html()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html) now handles linking directly to other pages - thanks to @mjasperse
2729
* non-bold `TitleStyle` is now rendered as non-bold even when the current font is bold
2830
* calling `.table()` inside the `render_toc_function`
2931
* using `.set_text_shaping(True)` & `.offset_rendering()`
3032
* fixed gutter handing when a pagebreak occurs within a table with header rows - thanks to @mjasperse
33+
* fixed handling of `border=0` in HTML table - thanks to @mjasperse
3134
* [`FPDF.write_html()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html) now properly honors `align=` attributes in `<th>` tags
3235
### Changed
3336
* refactored [`FPDF.multi_cell()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.multi_cell) to generate fewer PDF component objects - thanks to @mjasperse
3437
* outer table borders are now drawn continuously for nonzero `gutter_width`/`gutter_height`, with spacing applied inside the border similar to HTML tables - thanks to @mjasperse - cf. [#1071](https://github.com/py-pdf/fpdf2/issues/1071)
38+
* removed the requirement that all rows in a `Table` have the same number of columns - thanks to @mjasperse
3539

3640
## [2.7.7] - 2023-12-10
3741
### Added

docs/HTML.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,13 @@ pdf.output("html.pdf")
8484
* `<ol>`, `<ul>`, `<li>`: ordered, unordered and list items (can be nested)
8585
* `<dl>`, `<dt>`, `<dd>`: description list, title, details (can be nested)
8686
* `<sup>`, `<sub>`: superscript and subscript text
87-
* `<table>`: (with `align`, `border`, `width` attributes)
87+
* `<table>`: (with `align`, `border`, `width`, `cellpadding`, `cellspacing` attributes)
8888
+ `<thead>`: optional tag, wraps the table header row
8989
+ `<tfoot>`: optional tag, wraps the table footer row
9090
+ `<tbody>`: optional tag, wraps the table rows with actual content
9191
+ `<tr>`: rows (with `align`, `bgcolor` attributes)
9292
+ `<th>`: heading cells (with `align`, `bgcolor`, `width` attributes)
93-
* `<td>`: cells (with `align`, `bgcolor`, `width` attributes)
93+
* `<td>`: cells (with `align`, `bgcolor`, `width`, `rowspan`, `colspan` attributes)
9494

9595

9696
## Known limitations

docs/Tables.md

+70-2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Result:
4141
* control table width & position
4242
* control over text alignment in cells, globally or per row
4343
* allow to embed images in cells
44+
* merge cells across columns and rows
4445

4546
## Setting table & column widths
4647

@@ -336,9 +337,9 @@ Result:
336337

337338
![](table_with_gutter.jpg)
338339

339-
## Column span
340+
## Column span and row span
340341

341-
Cells spanning multiple columns can be defined by passing a `colspan` argument to `.cell()`.
342+
Cells spanning multiple columns or rows can be defined by passing a `colspan` or `rowspan` argument to `.cell()`.
342343
Only the cells with data in them need to be defined. This means that the number of cells on each row can be different.
343344

344345
```python
@@ -366,6 +367,73 @@ result:
366367

367368
![](image-colspan.png)
368369

370+
371+
372+
```python
373+
...
374+
with pdf.table(text_align="CENTER") as table:
375+
row = table.row()
376+
row.cell("A1", colspan=2, rowspan=3)
377+
row.cell("C1", colspan=2)
378+
379+
row = table.row()
380+
row.cell("C2", colspan=2, rowspan=2)
381+
382+
row = table.row()
383+
# all columns of this row are spanned by previous rows
384+
385+
row = table.row()
386+
row.cell("A4", colspan=4)
387+
388+
row = table.row()
389+
row.cell("A5", colspan=2)
390+
row.cell("C5")
391+
row.cell("D5")
392+
393+
row = table.row()
394+
row.cell("A6")
395+
row.cell("B6", colspan=2, rowspan=2)
396+
row.cell("D6", rowspan=2)
397+
398+
row = table.row()
399+
row.cell("A7")
400+
...
401+
```
402+
403+
result:
404+
405+
![](image-rowspan.png)
406+
407+
Alternatively, the spans can be defined using the placeholder elements `TableSpan.COL` and `TableSpan.ROW`.
408+
These elements merge the current cell with the previous column or row respectively.
409+
410+
For example, the previous example table can be defined as follows:
411+
412+
```python
413+
...
414+
TABLE_DATA = [
415+
["A", "B", "C", "D"],
416+
["A1", TableSpan.COL, "C1", TableSpan.COL],
417+
[TableSpan.ROW, TableSpan.ROW, "C2", TableSpan.COL],
418+
[TableSpan.ROW, TableSpan.ROW, TableSpan.ROW, TableSpan.ROW],
419+
["A4", TableSpan.COL, TableSpan.COL, TableSpan.COL],
420+
["A5", TableSpan.COL, "C5", "D5"],
421+
["A6", "B6", TableSpan.COL, "D6"],
422+
["A7", TableSpan.ROW, TableSpan.ROW, TableSpan.ROW],
423+
]
424+
425+
with pdf.table(TABLE_DATA, text_align="CENTER"):
426+
pass
427+
...
428+
```
429+
430+
result:
431+
432+
![](image-rowspan.png)
433+
434+
435+
436+
369437
## Table with multiple heading rows
370438

371439
The number of heading rows is defined by passing the `num_heading_rows` argument to `Table()`. The default value is `1`. To guarantee backwards compatibility with the `first_row_as_headings` argument, the following applies:

docs/image-rowspan.png

13.5 KB
Loading

fpdf/enums.py

+8
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,14 @@ def should_fill_cell(self, i, j):
326326
raise NotImplementedError
327327

328328

329+
class TableSpan(CoerciveEnum):
330+
ROW = intern("ROW")
331+
"Mark this cell as a continuation of the previous row"
332+
333+
COL = intern("COL")
334+
"Mark this cell as a continuation of the previous column"
335+
336+
329337
class RenderStyle(CoerciveEnum):
330338
"Defines how to render shapes"
331339

fpdf/fpdf.py

+4
Original file line numberDiff line numberDiff line change
@@ -4914,6 +4914,7 @@ def table(self, *args, **kwargs):
49144914
line_height (number): optional. Defines how much vertical space a line of text will occupy.
49154915
markdown (bool): optional, default to False. Enable markdown interpretation of cells textual content.
49164916
text_align (str, fpdf.enums.Align): optional, default to JUSTIFY. Control text alignment inside cells.
4917+
v_align (str, fpdf.enums.AlignV): optional, default to CENTER. Control vertical alignment of cells content.
49174918
width (number): optional. Sets the table width.
49184919
wrapmode (fpdf.enums.WrapMode): "WORD" for word based line wrapping (default),
49194920
"CHAR" for character based line wrapping.
@@ -4922,6 +4923,9 @@ def table(self, *args, **kwargs):
49224923
If padding for left or right ends up being non-zero then the respective c_margin is ignored.
49234924
outer_border_width (number): optional. The outer_border_width will trigger rendering of the outer
49244925
border of the table with the given width regardless of any other defined border styles.
4926+
num_heading_rows (number): optional. Sets the number of heading rows, default value is 1. If this value is not 1,
4927+
first_row_as_headings needs to be True if num_heading_rows>1 and False if num_heading_rows=0. For backwards compatibility,
4928+
first_row_as_headings is used in case num_heading_rows is 1.
49254929
"""
49264930
table = Table(self, *args, **kwargs)
49274931
yield table

fpdf/html.py

+18-8
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from .errors import FPDFException
1414
from .deprecation import get_stack_level
1515
from .fonts import FontFace
16-
from .table import Table, TableBordersLayout
16+
from .table import Table
1717

1818
LOGGER = logging.getLogger(__name__)
1919
BULLET_WIN1252 = "\x95" # BULLET character in Windows-1252 encoding
@@ -375,6 +375,7 @@ def handle_data(self, data):
375375
self.td_th.get("bgcolor", self.tr.get("bgcolor", None))
376376
)
377377
colspan = int(self.td_th.get("colspan", "1"))
378+
rowspan = int(self.td_th.get("rowspan", "1"))
378379
emphasis = 0
379380
if self.td_th.get("b"):
380381
emphasis |= TextEmphasis.B
@@ -387,7 +388,9 @@ def handle_data(self, data):
387388
style = FontFace(
388389
emphasis=emphasis, fill_color=bgcolor, color=self.pdf.text_color
389390
)
390-
self.table_row.cell(text=data, align=align, style=style, colspan=colspan)
391+
self.table_row.cell(
392+
text=data, align=align, style=style, colspan=colspan, rowspan=rowspan
393+
)
391394
self.td_th["inserted"] = True
392395
elif self.table is not None:
393396
# ignore anything else than td inside a table
@@ -553,23 +556,30 @@ def handle_starttag(self, tag, attrs):
553556
width = self.pdf.epw * int(width[:-1]) / 100
554557
else:
555558
width = int(width) / self.pdf.k
556-
if "border" in attrs:
557-
borders_layout = (
558-
"ALL" if self.table_line_separators else "NO_HORIZONTAL_LINES"
559-
)
560-
else:
559+
if "border" not in attrs: # default borders
561560
borders_layout = (
562561
"HORIZONTAL_LINES"
563562
if self.table_line_separators
564563
else "SINGLE_TOP_LINE"
565564
)
565+
elif int(attrs["border"]): # explicitly enabled borders
566+
borders_layout = (
567+
"ALL" if self.table_line_separators else "NO_HORIZONTAL_LINES"
568+
)
569+
else: # explicitly disabled borders
570+
borders_layout = "NONE"
566571
align = attrs.get("align", "center").upper()
572+
padding = float(attrs["cellpadding"]) if "cellpadding" in attrs else None
573+
spacing = float(attrs.get("cellspacing", 0))
567574
self.table = Table(
568575
self.pdf,
569576
align=align,
570577
borders_layout=borders_layout,
571578
line_height=self.h * 1.30,
572579
width=width,
580+
padding=padding,
581+
gutter_width=spacing,
582+
gutter_height=spacing,
573583
)
574584
self._ln()
575585
if tag == "tr":
@@ -590,7 +600,7 @@ def handle_starttag(self, tag, attrs):
590600
# => we are in the 1st <tr>, and the 1st cell is a <td>
591601
# => we do not treat the first row as a header
592602
# pylint: disable=protected-access
593-
self.table._borders_layout = TableBordersLayout.NONE
603+
self.table._first_row_as_headings = False
594604
self.table._num_heading_rows = 0
595605
if "height" in attrs:
596606
LOGGER.warning(

0 commit comments

Comments
 (0)