diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b0d5310..4fc047d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default', * SVG importing now supports clipping paths, and `defs` tags anywhere in the SVG file * [`TextColumns()`](https://py-pdf.github.io/fpdf2/TextColumns.html) can now have images inserted (both raster and vector). * [`TextColumns()`](https://py-pdf.github.io/fpdf2/TextColumns.html) can now advance to the next column with the new `new_column()` method or a FORM_FEED character (`\u000c`) in the text. +* [`TableBordersLayout`](https://py-pdf.github.io/fpdf2/fpdf/table.html#fpdf.table.TableBordersLayout) is now a non-enum class that supports customizable layouts. The members of the old enum class are now static members of the class and thus can be used as standard options using the same syntax as previously. ### Fixed * Previously set dash patterns were not transferred correctly to new pages. * Inserted Vector images used to ignore the `keep_aspect_ratio` argument. diff --git a/docs/Tables.md b/docs/Tables.md index 38cd1fa67..d8fe30597 100644 --- a/docs/Tables.md +++ b/docs/Tables.md @@ -214,6 +214,37 @@ Result: ![](table_with_single_top_line_layout.jpg) +It is also possible to create a custom border layout, controlling thickness, color, and dash pattern: +```python +from fpdf.table import TableBordersLayout, TableBorderStyle, TableCellStyle + +gray = (150, 150, 150) +red = (255, 0, 0) +custom_layout = TableBordersLayout( + cell_style_getter=lambda row_num, col_num, num_heading_rows, num_rows, num_cols: TableCellStyle( + left=( + True if col_num == 0 + else TableBorderStyle(color=(150, 150, 150), dash=2) if col_num == 2 + else False + ), bottom=True if row_num == num_rows - 1 else False, + right=True if col_num == num_cols - 1 else False, + top=( + True if row_num == 0 + else TableBorderStyle(thickness=1) if row_num == num_heading_rows + else TableBorderStyle(color=red, dash=2) + ), + ) +) + +with pdf.table(borders_layout=custom_layout) as table: + ... +``` + +Result: + +![](table-with-custom-border-layout.jpg) + + All the possible layout values are described there: [`TableBordersLayout`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.TableBordersLayout). diff --git a/docs/table-with-custom-border-layout.jpg b/docs/table-with-custom-border-layout.jpg new file mode 100644 index 000000000..8b17e93e6 Binary files /dev/null and b/docs/table-with-custom-border-layout.jpg differ diff --git a/fpdf/enums.py b/fpdf/enums.py index 5fabce43c..0fc542f60 100644 --- a/fpdf/enums.py +++ b/fpdf/enums.py @@ -274,31 +274,6 @@ class MethodReturnValue(CoerciveIntFlag): "The method will return how much vertical space was used" -class TableBordersLayout(CoerciveEnum): - "Defines how to render table borders" - - ALL = intern("ALL") - "Draw all table cells borders" - - NONE = intern("NONE") - "Draw zero cells border" - - INTERNAL = intern("INTERNAL") - "Draw only internal horizontal & vertical borders" - - MINIMAL = intern("MINIMAL") - "Draw only the top horizontal border, below the headings, and internal vertical borders" - - HORIZONTAL_LINES = intern("HORIZONTAL_LINES") - "Draw only horizontal lines" - - NO_HORIZONTAL_LINES = intern("NO_HORIZONTAL_LINES") - "Draw all cells border except horizontal lines, after the headings" - - SINGLE_TOP_LINE = intern("SINGLE_TOP_LINE") - "Draw only the top horizontal border, below the headings" - - class TableCellFillMode(CoerciveEnum): "Defines which table cells to fill" diff --git a/fpdf/table.py b/fpdf/table.py index 36b8da1ac..0bbfb5e24 100644 --- a/fpdf/table.py +++ b/fpdf/table.py @@ -1,16 +1,385 @@ from dataclasses import dataclass from numbers import Number -from typing import Optional, Union +from typing import Optional, Union, Tuple, Sequence, Protocol -from .enums import Align, TableBordersLayout, TableCellFillMode, WrapMode, VAlign +from .enums import Align, TableCellFillMode, WrapMode, VAlign from .enums import MethodReturnValue from .errors import FPDFException from .fonts import CORE_FONTS, FontFace from .util import Padding +from .drawing import DeviceGray, DeviceRGB DEFAULT_HEADINGS_STYLE = FontFace(emphasis="BOLD") +def wrap_in_local_context(draw_commands): + return ["q"] + draw_commands + ["Q"] + + +def convert_to_drawing_color(color): + if isinstance(color, (DeviceGray, DeviceRGB)): + # Note: in this case, r is also a Sequence + return color + if isinstance(color, int): + return DeviceGray(color / 255) + if isinstance(color, Sequence): + return DeviceRGB(*(_x / 255 for _x in color)) + raise ValueError(f"Unsupported color type: {type(color)}") + + +@dataclass(slots=True) +class TableBorderStyle: + """ + A helper class for drawing one border of a table + + Attributes: + thickness: The thickness of the border. If None use default. If <= 0 don't draw the border. + color: The color of the border. If None use default. + """ + + thickness: Optional[float] = None + color: Union[int, Tuple[int, int, int]] = None + dash: Optional[float] = None + gap: float = 0.0 + phase: float = 0.0 + + @staticmethod + def from_bool(should_draw): + if isinstance(should_draw, TableBorderStyle): + return should_draw # don't change specified TableBorderStyle + if should_draw: + return TableBorderStyle() # keep default stroke + return TableBorderStyle(thickness=0.0) # don't draw the border + + def _changes_thickness(self, pdf): + return ( + self.thickness is not None + and self.thickness > 0.0 + and self.thickness != pdf.line_width + ) + + def _changes_color(self, pdf): + return self.color is not None and self.color != pdf.draw_color + + @property + def dash_dict(self): + return {"dash": self.dash, "gap": self.gap, "phase": self.phase} + + def _changes_dash(self, pdf): + return self.dash is not None and self.dash_dict != pdf.dash + + def changes_stroke(self, pdf): + return self.should_render() and ( + self._changes_color(pdf) + or self._changes_thickness(pdf) + or self._changes_dash(pdf) + ) + + def should_render(self): + return self.thickness is None or self.thickness > 0.0 + + def _get_change_thickness_command(self, scale): + return [] if self.thickness is None else [f"{self.thickness * scale:.2f} w"] + + def _get_change_line_color_command(self): + return ( + [] + if self.color is None + else [convert_to_drawing_color(self.color).serialize().upper()] + ) + + def _get_change_dash_command(self, scale): + return ( + [] + if self.dash is None + else [ + "[] 0 d" + if self.dash <= 0 + else f"[{self.dash * scale:.3f}] {self.phase * scale:.3f} d" + if self.gap <= 0 + else f"[{self.dash * scale:.3f} {self.gap * scale:.3f}] {self.phase * scale:.3f} d" + ] + ) + + def get_change_stroke_commands(self, scale): + return ( + self._get_change_dash_command(scale) + + self._get_change_line_color_command() + + self._get_change_thickness_command(scale) + ) + + @staticmethod + def get_line_command(x1, y1, x2, y2): + return [f"{x1:.2f} {y1:.2f} m " f"{x2:.2f} {y2:.2f} l S"] + + def get_draw_commands(self, pdf, x1, y1, x2, y2): + """ + Get draw commands for this section of a cell border. x and y are presumed to be already + shifted and scaled. + """ + if not self.should_render(): + return [] + + if self.changes_stroke(pdf): + draw_commands = self.get_change_stroke_commands( + scale=pdf.k + ) + self.get_line_command(x1, y1, x2, y2) + # wrap in local context to prevent stroke changes from affecting later rendering + return wrap_in_local_context(draw_commands) + return self.get_line_command(x1, y1, x2, y2) + + +@dataclass(slots=True) +class TableCellStyle: + left: Union[bool, TableBorderStyle] = False + bottom: Union[bool, TableBorderStyle] = False + right: Union[bool, TableBorderStyle] = False + top: Union[bool, TableBorderStyle] = False + + def _get_common_border_style(self): + if all( + isinstance(border, bool) + for border in [self.left, self.bottom, self.right, self.top] + ): + if all(border for border in [self.left, self.bottom, self.right, self.top]): + return True + if all( + not border for border in [self.left, self.bottom, self.right, self.top] + ): + return False + elif all( + isinstance(border, TableBorderStyle) + for border in [self.left, self.bottom, self.right, self.top] + ): + common = self.left + if all(border == common for border in [self.bottom, self.right, self.top]): + return common + return None + + @staticmethod + def get_change_fill_color_command(color): + return ( + [] + if color is None + else [convert_to_drawing_color(color).serialize().lower()] + ) + + def get_draw_commands(self, pdf, x1, y1, x2, y2, fill_color=None): + """ + Get list of primitive commands to draw the cell border for this cell, and fill it with the + given fill color. + """ + # y top to bottom instead of bottom to top + y1 = pdf.h - y1 + y2 = pdf.h - y2 + # scale coordinates and thickness + scale = pdf.k + x1 *= scale + y1 *= scale + x2 *= scale + y2 *= scale + + draw_commands = [] + needs_wrap = False + common_border_style = self._get_common_border_style() + if common_border_style is None: + # some borders are different from others, draw them individually + if fill_color is not None: + # draw fill with no box + if fill_color != pdf.fill_color: + needs_wrap = True + draw_commands.extend(self.get_change_fill_color_command(fill_color)) + draw_commands.append( + f"{x1:.2f} {y2:.2f} " f"{x2 - x1:.2f} {y1 - y2:.2f} re f" + ) + # draw the individual borders + draw_commands.extend( + TableBorderStyle.from_bool(self.left).get_draw_commands( + pdf, x1, y2, x1, y1 + ) + + TableBorderStyle.from_bool(self.bottom).get_draw_commands( + pdf, x1, y2, x2, y2 + ) + + TableBorderStyle.from_bool(self.right).get_draw_commands( + pdf, x2, y2, x2, y1 + ) + + TableBorderStyle.from_bool(self.top).get_draw_commands( + pdf, x1, y1, x2, y1 + ) + ) + elif common_border_style is False: + # don't draw border + if fill_color is not None: + # draw fill with no box + if fill_color != pdf.fill_color: + needs_wrap = True + draw_commands.extend(self.get_change_fill_color_command(fill_color)) + draw_commands.append( + f"{x1:.2f} {y2:.2f} " f"{x2 - x1:.2f} {y1 - y2:.2f} re f" + ) + else: + # all borders are the same + if isinstance( + common_border_style, TableBorderStyle + ) and common_border_style.changes_stroke(pdf): + # the border styles aren't default, so + draw_commands.extend( + common_border_style.get_change_stroke_commands(scale) + ) + needs_wrap = True + if fill_color is not None: + # draw filled rectangle + if fill_color != pdf.fill_color: + needs_wrap = True + draw_commands.extend(self.get_change_fill_color_command(fill_color)) + draw_commands.append( + f"{x1:.2f} {y2:.2f} " f"{x2 - x1:.2f} {y1 - y2:.2f} re B" + ) + else: + # draw empty rectangle + draw_commands.append( + f"{x1:.2f} {y2:.2f} " f"{x2 - x1:.2f} {y1 - y2:.2f} re S" + ) + + if needs_wrap: + draw_commands = wrap_in_local_context(draw_commands) + return draw_commands + + def draw_cell_border(self, pdf, x1, y1, x2, y2, fill_color=None): + """ + Draw the cell border for this cell, and fill it with the given fill color. + """ + pdf._out( # pylint: disable=protected-access + " ".join(self.get_draw_commands(pdf, x1, y1, x2, y2, fill_color=fill_color)) + ) + + +class CallStyleGetter(Protocol): + def __call__( + self, + row_num: int, + col_num: int, + num_heading_rows: int, + num_rows: int, + num_cols: int, + ) -> TableCellStyle: + ... + + +@dataclass(slots=True) +class TableBordersLayout: + """ + Customizable class for setting the drawing style of cell borders for the whole table. + Standard TableBordersLayouts are available as static members of this class + + Attributes: + cell_style_getter: a callable that takes row_num, column_num, + num_heading_rows, num_rows, num_columns; and returns the drawing style of + the cell border (as a TableCellStyle object) + ALL: static TableBordersLayout that draws all table cells borders + NONE: static TableBordersLayout that draws no table cells borders + INTERNAL: static TableBordersLayout that draws only internal horizontal & vertical borders + MINIMAL: static TableBordersLayout that draws only the top horizontal border, below the + headings, and internal vertical borders + HORIZONTAL_LINES: static TableBordersLayout that draws only horizontal lines + NO_HORIZONTAL_LINES: static TableBordersLayout that draws all cells border except interior + horizontal lines after the headings + SINGLE_TOP_LINE: static TableBordersLayout that draws only the top horizontal border, below + the headings + """ + + cell_style_getter: CallStyleGetter + + @classmethod + def coerce(cls, value): + """ + Attempt to coerce `value` into a member of this class. + + If value is already a member of this enumeration it is returned unchanged. + Otherwise, if it is a string, attempt to convert it as an enumeration value. If + that fails, attempt to convert it (case insensitively, by upcasing) as an + enumeration name. + + If all different conversion attempts fail, an exception is raised. + + Args: + value (Enum, str): the value to be coerced. + + Raises: + ValueError: if `value` is a string but neither a member by name nor value. + TypeError: if `value`'s type is neither a member of the enumeration nor a + string. + """ + + if isinstance(value, cls): + return value + + if isinstance(value, str): + try: + coerced_value = getattr(cls, value.upper()) + if isinstance(coerced_value, cls): + return coerced_value + except ValueError: + pass + + raise ValueError(f"{value} is not a valid {cls.__name__}") + + +# Draw all table cells borders +TableBordersLayout.ALL = TableBordersLayout( + cell_style_getter=lambda row_num, col_num, num_heading_rows, num_rows, num_cols: TableCellStyle( + left=True, bottom=True, right=True, top=True + ) +) +# Draw zero cells border +TableBordersLayout.NONE = TableBordersLayout( + cell_style_getter=lambda row_num, col_num, num_heading_rows, num_rows, num_cols: TableCellStyle( + left=False, bottom=False, right=False, top=False + ) +) +# Draw only internal horizontal & vertical borders +TableBordersLayout.INTERNAL = TableBordersLayout( + cell_style_getter=lambda row_num, col_num, num_heading_rows, num_rows, num_cols: TableCellStyle( + left=col_num > 0, + bottom=col_num < num_cols - 1, + right=row_num < num_rows - 1, + top=row_num > 0, + ) +) +# Draw only the top horizontal border, below the headings, and internal vertical borders +TableBordersLayout.MINIMAL = TableBordersLayout( + cell_style_getter=lambda row_num, col_num, num_heading_rows, num_rows, num_cols: TableCellStyle( + left=col_num > 0, + bottom=row_num < num_heading_rows, + right=col_num < num_cols - 1, # could remove (set False) + top=0 < row_num <= num_heading_rows, # could remove (set False) + ) +) +# Draw only horizontal lines +TableBordersLayout.HORIZONTAL_LINES = TableBordersLayout( + cell_style_getter=lambda row_num, col_num, num_heading_rows, num_rows, num_cols: TableCellStyle( + left=False, bottom=row_num < num_heading_rows - 1, right=False, top=row_num > 0 + ) +) +# Draw all cells border except interior horizontal lines after the headings +TableBordersLayout.NO_HORIZONTAL_LINES = TableBordersLayout( + cell_style_getter=lambda row_num, col_num, num_heading_rows, num_rows, num_cols: TableCellStyle( + left=True, + bottom=row_num == num_rows - 1, + right=True, + top=row_num <= num_heading_rows, + ) +) +TableBordersLayout.SINGLE_TOP_LINE = TableBordersLayout( + cell_style_getter=lambda row_num, col_num, num_heading_rows, num_rows, num_cols: TableCellStyle( + left=False, + bottom=row_num <= num_heading_rows - 1, + right=False, + top=False, + ) +) + + def draw_box_borders(pdf, x1, y1, x2, y2, border, fill_color=None): """Draws a box using the provided style - private helper used by table for drawing the cell and table borders. Difference between this and rect() is that border can be defined as "L,R,T,B" to draw only some of the four borders; @@ -440,33 +809,21 @@ def _render_table_cell( ) # already includes gutter for cells spanning multiple columns y2 = y1 + cell_height - draw_box_borders( + self._borders_layout.cell_style_getter( + row_num=i, + col_num=j, + num_heading_rows=self._num_heading_rows, + num_rows=len(self.rows), + num_cols=self.rows[i].column_indices[-1] + 1, + ).draw_cell_border( self._fpdf, x1, y1, x2, y2, - border=self.get_cell_border(i, j), fill_color=style.fill_color if fill else None, ) - # draw outer box if needed - - if self._outer_border_width: - _remember_linewidth = self._fpdf.line_width - self._fpdf.set_line_width(self._outer_border_width) - - if i == 0: - self._fpdf.line(x1, y1, x2, y1) - if i == len(self.rows) - 1: - self._fpdf.line(x1, y2, x2, y2) - if j == 0: - self._fpdf.line(x1, y1, x1, y2) - if j == len(row.cells) - 1: - self._fpdf.line(x2, y1, x2, y2) - - self._fpdf.set_line_width(_remember_linewidth) - # render image if cell.img: diff --git a/test/table/table_with_custom_layout.pdf b/test/table/table_with_custom_layout.pdf new file mode 100644 index 000000000..9c1c92d00 Binary files /dev/null and b/test/table/table_with_custom_layout.pdf differ diff --git a/test/table/test_table.py b/test/table/test_table.py index aa030c6b0..457b88edd 100644 --- a/test/table/test_table.py +++ b/test/table/test_table.py @@ -7,6 +7,7 @@ from fpdf.drawing import DeviceRGB from fpdf.fonts import FontFace from test.conftest import assert_pdf_equal, LOREM_IPSUM +from fpdf.table import TableBordersLayout, TableBorderStyle, TableCellStyle HERE = Path(__file__).resolve().parent @@ -329,6 +330,43 @@ def test_table_with_single_top_line_layout_and_page_break(tmp_path): # PR #912 ) +def test_table_with_custom_border_layout(tmp_path): + pdf = FPDF() + pdf.add_page() + pdf.set_font(family="helvetica", size=11) + # generate a custom layout with most of the available features: changes in thickness, color, + # and dash + custom_layout = TableBordersLayout( + cell_style_getter=( + lambda row_num, col_num, num_heading_rows, num_rows, num_cols: TableCellStyle( + left=( + TableBorderStyle(thickness=2) + if col_num == 0 + else TableBorderStyle(thickness=1.0, color=(0, 255, 125)) + ), + bottom=TableBorderStyle(thickness=2) + if row_num == num_rows - 1 + else False, + right=TableBorderStyle(thickness=2) + if col_num == num_cols - 1 + else False, + top=( + TableBorderStyle(thickness=2) + if row_num in (0, num_heading_rows) + else True + if (row_num - num_heading_rows) % 2 == 0 + else TableBorderStyle(color=(255, 0, 0), dash=2) + ), + ) + ) + ) + + with pdf.table(rows=TABLE_DATA, borders_layout=custom_layout): + pass + + assert_pdf_equal(pdf, HERE / "table_with_custom_layout.pdf", tmp_path) + + def test_table_align(tmp_path): pdf = FPDF() pdf.add_page()