diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index b5453c0f2..1ce307422 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -74,8 +74,7 @@ jobs: sed -i "s/author:.*/author: v$(python setup.py -V 2>/dev/null)/" mkdocs.yml cp tutorial/notebook.ipynb docs/ mkdocs build - pdoc --html -o public/ fpdf --config "git_link_template='https://github -.com/PyFPDF/fpdf2/blob/{commit}/{path}#L{start_line}-L{end_line}'" + pdoc --html -o public/ fpdf --config "git_link_template='https://github.com/PyFPDF/fpdf2/blob/{commit}/{path}#L{start_line}-L{end_line}'" cd contributors/ && PYTHONUNBUFFERED=1 ./build_contributors_html_page.py PyFPDF/fpdf2 cp -t ../public/ contributors.html contributors-map-small.png - name: Deploy documentation 🚀 diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e0bd6e6..07d78bd70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,12 @@ This can also be enabled programmatically with `warnings.simplefilter('default', - unicode (non limited to ASCII) text can now be provided as metadata [#685](https://github.com/PyFPDF/fpdf2/issues/685) - all `TitleStyle` constructor parameters are now effectively optional ### Changed +* [`FPDF.write_html()`](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html) now uses the new [`FPDF.table()`](https://pyfpdf.github.io/fpdf2/Tables.html) method to render `` tags. As a consequence, vertical space before `
` tags has sometimes been reduced. - vector images parsing is now more robust: `fpdf2` can now embed SVG files without `viewPort` or no `height` / `width` - bitonal images are now encoded using `CCITTFaxDecode`, reducing their size in the PDF document - thanks to @eroux - when possible, JPG and group4 encoded TIFFs are now embedded directly without recompression - thanks to @eroux +### Removed +* [`FPDF.write_html()`](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html) now uses the new [`FPDF.table()`](https://pyfpdf.github.io/fpdf2/Tables.html) method to render `
` tags. As a consequence, it does not support the `height` attribute defined on `` tags. ## [2.6.1] - 2023-01-13 ### Added diff --git a/README.md b/README.md index 2632ef744..141629424 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ pip install git+https://github.com/PyFPDF/fpdf2.git@master * Embedding images, including transparency and alpha channel * Arbitrary path drawing and basic [SVG](https://pyfpdf.github.io/fpdf2/SVG.html) import * Embedding [barcodes](https://pyfpdf.github.io/fpdf2/Barcodes.html), [charts & graphs](https://pyfpdf.github.io/fpdf2/Maths.html), [emojis, symbols & dingbats](https://pyfpdf.github.io/fpdf2/EmojisSymbolsDingbats.html) - * [Cell / multi-cell / plaintext writing](https://pyfpdf.github.io/fpdf2/Text.html), with [automatic page breaks](https://pyfpdf.github.io/fpdf2/PageBreaks.html), line break and text justification + * [Tables](https://pyfpdf.github.io/fpdf2/Tables.html) and also [cell / multi-cell / plaintext writing](https://pyfpdf.github.io/fpdf2/Text.html), with [automatic page breaks](https://pyfpdf.github.io/fpdf2/PageBreaks.html), line break and text justification * Choice of measurement unit, page format & margins. Optional page header and footer * Basic [conversion from HTML to PDF](https://pyfpdf.github.io/fpdf2/HTML.html) * A [templating system](https://pyfpdf.github.io/fpdf2/Templates.html) to render PDFs in batchs diff --git a/docs/index.md b/docs/index.md index 551f74935..5cf985596 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ Go try it **now** online in a Jupyter notebook: [![Open In Colab](https://colab. * Embedding images, including transparency and alpha channel, using [Pillow (Python Imaging Library)](https://pillow.readthedocs.io/en/stable/) * Arbitrary path drawing and basic [SVG](SVG.md) import * Embedding [barcodes](Barcodes.md), [charts & graphs](Maths.md), [emojis, symbols & dingbats](EmojisSymbolsDingbats.md) -* [Cell / multi-cell / plaintext writing](Text.md), with [automatic page breaks](PageBreaks.md), line break and text justification +* [Tables](Tables.md), and also [cell / multi-cell / plaintext writing](Text.md), with [automatic page breaks](PageBreaks.md), line break and text justification * Choice of measurement unit, page format & margins. Optional page header and footer * Basic [conversion from HTML to PDF](HTML.md) * A [templating system](Templates.md) to render PDFs in batchs diff --git a/fpdf/enums.py b/fpdf/enums.py index 8082d269f..2e58c812b 100644 --- a/fpdf/enums.py +++ b/fpdf/enums.py @@ -212,12 +212,13 @@ def style(self): @classmethod def coerce(cls, value): - if value.upper() == "BOLD": - return cls.B - if value.upper() == "ITALICS": - return cls.I - if value.upper() == "UNDERLINE": - return cls.U + if isinstance(value, str): + if value.upper() == "BOLD": + return cls.B + if value.upper() == "ITALICS": + return cls.I + if value.upper() == "UNDERLINE": + return cls.U return super(cls, cls).coerce(value) @@ -236,6 +237,9 @@ class TableBordersLayout(CoerciveEnum): 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" diff --git a/fpdf/fonts.py b/fpdf/fonts.py index 78005bf19..4056c94ec 100644 --- a/fpdf/fonts.py +++ b/fpdf/fonts.py @@ -1,6 +1,7 @@ """ Definition of the character widths of all PDF standard fonts. """ +from dataclasses import dataclass, replace from typing import Optional, Union from .drawing import DeviceGray, DeviceRGB @@ -2605,21 +2606,22 @@ } +@dataclass class FontStyle: + family: Optional[str] + emphasis: Optional[TextEmphasis] + size_pt: Optional[int] + # Colors are single number grey scales or (red, green, blue) tuples: + color: Optional[Union[int, tuple, DeviceGray, DeviceRGB]] + fill_color: Optional[Union[int, tuple, DeviceGray, DeviceRGB]] + def __init__( - self, - family: Optional[str] = None, - emphasis: Optional[Union[str, TextEmphasis]] = None, - size_pt: Optional[int] = None, - color: Optional[ - Union[int, tuple, DeviceGray, DeviceRGB] - ] = None, # grey scale or (red, green, blue) - fill_color: Optional[ - Union[int, tuple, DeviceGray, DeviceRGB] - ] = None, # grey scale or (red, green, blue) + self, family=None, emphasis=None, size_pt=None, color=None, fill_color=None ): self.family = family self.emphasis = TextEmphasis.coerce(emphasis) if emphasis else None self.size_pt = size_pt self.color = color self.fill_color = fill_color + + replace = replace diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index 5dce32e15..d3de3559c 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -4512,6 +4512,9 @@ def _use_title_style(self, title_style: TitleStyle): @contextmanager def use_font_style(self, font_style: FontStyle): + if not font_style: + yield + return prev_font = (self.font_family, self.font_style, self.font_size_pt) self.set_font( font_style.family or self.font_family, diff --git a/fpdf/html.py b/fpdf/html.py index acb774217..d60a3caa8 100644 --- a/fpdf/html.py +++ b/fpdf/html.py @@ -9,7 +9,9 @@ import logging, warnings from html.parser import HTMLParser -from .enums import XPos, YPos +from .enums import TextEmphasis, XPos, YPos +from .fonts import FontStyle +from .table import Table import re @@ -227,7 +229,6 @@ def __init__( self.image_map = image_map or (lambda src: src) self.li_tag_indent = li_tag_indent self.dd_tag_indent = dd_tag_indent - self.table_line_separators = table_line_separators self.ul_bullet_char = ul_bullet_char self.style = dict(b=False, i=False, u=False) self.pre_formatted = False @@ -242,42 +243,46 @@ def __init__( self.font_size = pdf.font_size_pt self.set_font(pdf.font_family or "times", size=self.font_size) self.font_color = 0, 0, 0 # initialize font color, r,g,b format - self.table = None # table attributes - self.table_col_width = None # column (header) widths - self.table_col_index = None # current column index - self.td = None # inside a , attributes dict - self.thead = None # inside a , attributes dict - self.tfoot = None # inside a , attributes dict - self.tr_index = None # row index - self.theader = None # table header cells - self.tfooter = None # table footer cells - self.theader_out = self.tfooter_out = False - self.table_row_height = 0 self.heading_level = None self.heading_sizes = dict(**DEFAULT_HEADING_SIZES) self.heading_above = 0.2 # extra space above heading, relative to font size self.heading_below = 0.2 # extra space below heading, relative to font size if heading_sizes: self.heading_sizes.update(heading_sizes) - self._only_imgs_in_td = False self.warn_on_tags_not_matching = warn_on_tags_not_matching self._tags_stack = [] - - def width2unit(self, length): - "Handle conversion of % measures into the measurement unit used" - if length[-1] == "%": - total = self.pdf.w - self.pdf.r_margin - self.pdf.l_margin - if self.table["width"][-1] == "%": - total *= int(self.table["width"][:-1]) / 100 - return int(length[:-1]) * total / 100 - return int(length) + #
` / `` tags anymore, nor `height` / `width` attributes defined on `` tags inside cells, nor `width` attributes defined on `` / `
, attributes dict - self.th = None # inside a , attributes dict - self.tr = None # inside a
-related properties: + self.table_line_separators = table_line_separators + self.table = None + self.table_row = None + self.tr = None + self.td_th = None def handle_data(self, data): trailing_space_flag = TRAILING_SPACE.search(data) - if self.td is not None: # drawing a table? - self._insert_td(data) + if self.td_th is not None: + data = data.strip() + if not data: + return + align = self.td_th.get("align", self.tr.get("align")) + if align: + align = align.upper() + bgcolor = color_as_decimal( + self.td_th.get("bgcolor", self.tr.get("bgcolor", None)) + ) + colspan = int(self.td_th.get("colspan", "1")) + emphasis = 0 + if self.td_th.get("b"): + emphasis |= TextEmphasis.B + if self.td_th.get("i"): + emphasis |= TextEmphasis.I + if self.td_th.get("U"): + emphasis |= TextEmphasis.U + style = None + if bgcolor or emphasis: + style = FontStyle(emphasis=emphasis, fill_color=bgcolor) + self.table_row.cell(text=data, align=align, style=style, colspan=colspan) + self.td_th["rendered"] = True elif self.table is not None: # ignore anything else than td inside a table pass @@ -331,154 +336,6 @@ def handle_data(self, data): self.pdf.write(self.h, data) self.follows_fmt_tag = False - def _insert_td(self, data=""): - self._only_imgs_in_td = False - width = self._td_width() - height = int(self.td.get("height", 0)) // 4 or self.h * 1.30 - if not self.table_row_height: - self.table_row_height = height - elif self.table_row_height > height: - height = self.table_row_height - border = int(self.table.get("border", 0)) - if self.th: - self.set_style("B", True) - border = border or "B" - align = self.td.get("align", "C")[0].upper() - else: - align = self.td.get("align", "L")[0].upper() - border = border and "LR" - bgcolor = color_as_decimal(self.td.get("bgcolor", self.tr.get("bgcolor", ""))) - # parsing table header/footer (drawn later): - if self.thead is not None: - self.theader.append( - ( - dict( - w=width, - h=height, - txt=data, - border=border, - new_x=XPos.RIGHT, - new_y=YPos.TOP, - align=align, - ), - bgcolor, - ) - ) - if self.tfoot is not None: - self.tfooter.append( - ( - dict( - w=width, - h=height, - txt=data, - border=border, - new_x=XPos.RIGHT, - new_y=YPos.TOP, - align=align, - ), - bgcolor, - ) - ) - # check if reached end of page, add table footer and header: - if self.tfooter: - height += self.tfooter[0][0]["h"] - if self.pdf.y + height > self.pdf.page_break_trigger and not self.th: - self.output_table_footer() - self.pdf.add_page(same=True) - self.theader_out = self.tfooter_out = False - if self.tfoot is None and self.thead is None: - if not self.theader_out: - self.output_table_header() - self.box_shadow(width, height, bgcolor) - # self.pdf.x may have shifted due to inside ', + width, + tag, + ) if tag == "img" and "src" in attrs: width = px2mm(int(attrs.get("width", 0))) height = px2mm(int(attrs.get("height", 0))) + if self.table_row: # => in a
: - self.pdf.set_x(self._td_x()) - LOGGER.debug( - "td cell x=%d width=%d height=%d border=%s align=%s '%s'", - self.pdf.x, - width, - height, - border, - align, - data.replace("\n", "\\n"), - ) - self.pdf.cell( - width, - height, - data, - border=border, - align=align, - new_x=XPos.RIGHT, - new_y=YPos.TOP, - ) - - def _td_x(self): - "Return the current table cell left side horizontal position" - prev_cells_total_width = sum( - self.width2unit(width) - for width in self.table_col_width[: self.table_col_index] - ) - return self.table_offset + prev_cells_total_width - - def _td_width(self): - "Return the current table cell width" - # pylint: disable=raise-missing-from - if "width" in self.td: - column_widths = [self.td["width"]] - elif "colspan" in self.td: - i = self.table_col_index - colspan = int(self.td["colspan"]) - column_widths = self.table_col_width[i : i + colspan] - else: - try: - column_widths = [self.table_col_width[self.table_col_index]] - except IndexError: - raise ValueError( - f"Width not specified for table column {self.table_col_index}," - " unable to continue" - ) - return sum(self.width2unit(width) for width in column_widths) - - def box_shadow(self, w, h, bgcolor): - LOGGER.debug("box_shadow w=%d h=%d bgcolor=%s", w, h, bgcolor) - if bgcolor: - fill_color = self.pdf.fill_color - self.pdf.set_fill_color(*bgcolor) - self.pdf.rect(self.pdf.x, self.pdf.y, w, h, "F") - self.pdf.set_fill_color(*fill_color.colors) - - def output_table_header(self): - if self.theader: - b = self.style.get("b") - self.pdf.set_x(self.table_offset) - self.set_style("b", True) - for celldict, bgcolor in self.theader: - self.box_shadow(celldict["w"], celldict["h"], bgcolor) - self.pdf.cell(**celldict) # includes the border - self.set_style("b", b) - self.pdf.ln(self.theader[0][0]["h"]) - self.pdf.set_x(self.table_offset) - # self.pdf.set_x(prev_x) - self.theader_out = True - - def output_table_footer(self): - if self.tfooter: - x = self.pdf.x - self.pdf.set_x(self.table_offset) - for celldict, bgcolor in self.tfooter: - self.box_shadow(celldict["w"], celldict["h"], bgcolor) - self.pdf.cell(**celldict) - self.pdf.ln(self.tfooter[0][0]["h"]) - self.pdf.set_x(x) - if self.table.get("border"): - self.output_table_sep() - self.tfooter_out = True - - def output_table_sep(self): - x1 = self.pdf.x - y1 = self.pdf.y - width = sum(self.width2unit(length) for length in self.table_col_width) - self.pdf.line(x1, y1, x1 + width, y1) - def handle_starttag(self, tag, attrs): attrs = dict(attrs) LOGGER.debug("STARTTAG %s %s", tag, attrs) @@ -494,7 +351,10 @@ def handle_starttag(self, tag, attrs): if tag == "em": tag = "i" if tag in ("b", "i", "u"): - self.set_style(tag, True) + if self.td_th is not None: + self.td_th[tag] = True + else: + self.set_style(tag, True) if tag == "a": self.href = attrs["href"] if tag == "br": @@ -562,72 +422,73 @@ def handle_starttag(self, tag, attrs): self.set_font() self.set_text_color(*self.font_color) if tag == "table": - self.table = {k.lower(): v for k, v in attrs.items()} - if "width" not in self.table: - self.table["width"] = "100%" - if self.table["width"][-1] == "%": - w = self.pdf.w - self.pdf.r_margin - self.pdf.l_margin - w *= int(self.table["width"][:-1]) / 100 - self.table_offset = (self.pdf.w - w) / 2 - self.table_col_width = [] - self.theader_out = self.tfooter_out = False - self.theader = [] - self.tfooter = [] - self.thead = None - self.tfoot = None + self.table = Table(self.pdf) + self.table.line_height = self.h * 1.30 + width = attrs.get("width") + if width: + if width[-1] == "%": + width = self.pdf.epw * int(width[:-1]) / 100 + else: + width = px2mm(int(width)) + self.table.width = width + if "border" in attrs: + self.table.borders_layout = ( + "ALL" if self.table_line_separators else "NO_HORIZONTAL_LINES" + ) + else: + self.table.borders_layout = ( + "HORIZONTAL_LINES" + if self.table_line_separators + else "SINGLE_TOP_LINE" + ) self.pdf.ln() if tag == "tr": - self.tr_index = 0 if self.tr_index is None else (self.tr_index + 1) self.tr = {k.lower(): v for k, v in attrs.items()} - self.table_col_index = 0 - self.table_row_height = 0 - self.pdf.set_x(self.table_offset) - # Adding an horizontal line separator between rows: - if self.table_line_separators and self.tr_index > 0: - self.output_table_sep() - if tag == "td": - self.td = {k.lower(): v for k, v in attrs.items()} - if "width" in self.td and self.table_col_index >= len(self.table_col_width): - assert self.table_col_index == len( - self.table_col_width - ), f"table_col_index={self.table_col_index} #table_col_width={len(self.table_col_width)}" - self.table_col_width.append(self.td["width"]) - if attrs: - self.align = attrs.get("align") - self._only_imgs_in_td = False - if tag == "th": - self.td = {k.lower(): v for k, v in attrs.items()} - self.th = True - if "width" in self.td and self.table_col_index >= len(self.table_col_width): - assert self.table_col_index == len( - self.table_col_width - ), f"table_col_index={self.table_col_index} #table_col_width={len(self.table_col_width)}" - self.table_col_width.append(self.td["width"]) - if tag == "thead": - self.thead = {} - if tag == "tfoot": - self.tfoot = {} + with self.table.row() as row: + self.table_row = row + if tag in ("td", "th"): + self.td_th = {k.lower(): v for k, v in attrs.items()} + if tag == "th": + self.td_th["align"] = "CENTER" + self.td_th["b"] = True + if "height" in attrs: + LOGGER.warning( + 'Ignoring unsupported height="%s" specified on a <%s>', + attrs["height"], + tag, + ) + if "width" in attrs: + width = attrs["width"] + if len(self.table.rows) == 1: # => first table row + if width[-1] == "%": + width = width[:-1] + if not self.table.col_widths: + self.table.col_widths = [] + self.table.col_widths.append(int(width)) + else: + LOGGER.warning( + 'Ignoring width="%s" specified on a <%s> that is not on the first
+ if width or height: + LOGGER.warning( + 'Ignoring unsupported "width" / "height" set on element' + ) + if self.align: + LOGGER.warning("Ignoring unsupported alignment") + self.table_row.cell(img=attrs["src"], img_fill_width=True) + self.td_th["rendered"] = True + return if self.pdf.y + height > self.pdf.page_break_trigger: self.pdf.add_page(same=True) - y = self.pdf.get_y() - if self.table_col_index is not None: - self._only_imgs_in_td = True - # in a " " " " " - ' ' - ' ' + " " + " " " " " " "
: its width must not exceed the cell width: - td_width = self._td_width() - if not width or width > td_width: - if width: # Preserving image aspect ratio: - height *= td_width / width - width = td_width - x = self._td_x() - if self.align and self.align[0].upper() == "C": - x += (td_width - width) / 2 - else: - x = self.pdf.get_x() - if self.align and self.align[0].upper() == "C": - x = self.pdf.w / 2 - width / 2 + x, y = self.pdf.get_x(), self.pdf.get_y() + if self.align and self.align[0].upper() == "C": + x = self.pdf.w / 2 - width / 2 LOGGER.debug( 'image "%s" x=%d y=%d width=%d height=%d', attrs["src"], @@ -636,20 +497,10 @@ def handle_starttag(self, tag, attrs): width, height, ) - image_info = self.pdf.image( + info = self.pdf.image( self.image_map(attrs["src"]), x, y, width, height, link=self.href ) - width = image_info["rendered_width"] - height = image_info["rendered_height"] - self.pdf.set_x(x + width) - if self.table_col_index is not None: - # in a : we grow the cell height according to the image height: - if height > self.table_row_height: - self.table_row_height = height - else: - self.pdf.set_y(y + height) - if tag in ("b", "i", "u"): - self.set_style(tag, True) + self.pdf.set_y(y + info.rendered_height) if tag == "center": self.align = "Center" if tag == "toc": @@ -708,7 +559,8 @@ def handle_endtag(self, tag): if tag == "em": tag = "i" if tag in ("b", "i", "u"): - self.set_style(tag, False) + if not self.td_th is not None: + self.set_style(tag, False) self.follows_fmt_tag = True if tag == "a": self.href = "" @@ -720,37 +572,22 @@ def handle_endtag(self, tag): self.indent -= 1 self.bullet.pop() if tag == "table": - if not self.tfooter_out: - self.output_table_footer() + with self.pdf.local_context(line_width=0.2): + self.table.render() self.table = None - self.th = False - self.theader = None - self.tfooter = None self.pdf.ln(self.h) - self.tr_index = None - if tag == "thead": - self.thead = None - self.tr_index = None - if tag == "tfoot": - self.tfoot = None - self.tr_index = None - if tag == "tbody": - self.tbody = None - self.tr_index = None if tag == "tr": - if self.tfoot is None: - self.pdf.ln(self.table_row_height) - self.table_col_index = None self.tr = None + self.table_row = None if tag in ("td", "th"): - if self.th: - LOGGER.debug("revert style") - self.set_style("b", False) # revert style - elif self._only_imgs_in_td: - self._insert_td() - self.table_col_index += int(self.td.get("colspan", "1")) - self.td = None - self.th = False + if "rendered" not in self.td_th: + # handle_data() was not called => we call it to produce an empty cell: + bgcolor = color_as_decimal( + self.td_th.get("bgcolor", self.tr.get("bgcolor", None)) + ) + style = FontStyle(fill_color=bgcolor) if bgcolor else None + self.table_row.cell(text="", style=style) + self.td_th = None if tag == "font": # recover last font state face, size, color = self.font_stack.pop() diff --git a/fpdf/table.py b/fpdf/table.py index 8676b7315..3ad25c150 100644 --- a/fpdf/table.py +++ b/fpdf/table.py @@ -1,6 +1,7 @@ from contextlib import contextmanager +from dataclasses import dataclass from numbers import Number -from typing import List +from typing import List, Union from .enums import Align, TableBordersLayout from .fonts import FontStyle @@ -17,7 +18,7 @@ class Table: def __init__(self, fpdf): self._fpdf = fpdf - self._rows = [] + self.rows = [] self.align = "CENTER" """ Sets the table horizontal position relative to the page, @@ -46,7 +47,7 @@ def __init__(self, fpdf): def row(self): "Adds a row to the table. Yields a `Row` object." row = Row() - self._rows.append(row) + self.rows.append(row) yield row def render(self): @@ -67,22 +68,15 @@ def render(self): self._fpdf.x = self._fpdf.l_margin elif self._fpdf.x != self._fpdf.l_margin: self._fpdf.l_margin = self._fpdf.x - for i in range(len(self._rows)): + for i in range(len(self.rows)): with self._fpdf.offset_rendering() as test: - self._render_table_row_styled(i) + self._render_table_row(i) if test.page_break_triggered: # pylint: disable=protected-access self._fpdf._perform_page_break() if self.first_row_as_headings: # repeat headings on top: - self._render_table_row_styled(0) - if self.cell_fill_color: - prev_fill_color = self._fpdf.fill_color - self._fpdf.set_fill_color(self.cell_fill_color) - else: - prev_fill_color = None - self._render_table_row_styled(i) - if prev_fill_color: - self._fpdf.set_fill_color(prev_fill_color) + self._render_table_row(0) + self._render_table_row(i) self._fpdf.l_margin = prev_l_margin self._fpdf.x = self._fpdf.l_margin @@ -97,8 +91,8 @@ def get_cell_border(self, i, j): return 1 if self.borders_layout == TableBordersLayout.NONE.value: return 0 - columns_count = max(len(row.cells) for row in self._rows) - rows_count = len(self._rows) + columns_count = max(row.cols_count for row in self.rows) + rows_count = len(self.rows) border = list("LRTB") if self.borders_layout == TableBordersLayout.INTERNAL.value: if i == 0 and "T" in border: @@ -110,39 +104,43 @@ def get_cell_border(self, i, j): if j == columns_count - 1 and "R" in border: border.remove("R") if self.borders_layout == TableBordersLayout.MINIMAL.value: + if (i != 1 or rows_count == 1) and "T" in border: + border.remove("T") if i != 0 and "B" in border: border.remove("B") - if rows_count > 1 and i != 1 and "T" in border: - border.remove("T") if j == 0 and "L" in border: border.remove("L") if j == columns_count - 1 and "R" in border: border.remove("R") if self.borders_layout == TableBordersLayout.NO_HORIZONTAL_LINES.value: + if i not in (0, 1) and "T" in border: + border.remove("T") if i not in (0, rows_count - 1) and "B" in border: border.remove("B") - if rows_count > 1 and i not in (0, 1) and "T" in border: + if self.borders_layout == TableBordersLayout.HORIZONTAL_LINES.value: + if rows_count == 1: + return 0 + border = list("TB") + if i == 0 and "T" in border: border.remove("T") + if i == rows_count - 1 and "B" in border: + border.remove("B") if self.borders_layout == TableBordersLayout.SINGLE_TOP_LINE.value: + if rows_count == 1: + return 0 border = list("TB") + if i != 1 and "T" in border: + border.remove("T") if i != 0 and "B" in border: border.remove("B") - if rows_count > 1 and i != 1 and "T" in border: - border.remove("T") return "".join(border) - def _render_table_row_styled(self, i): - if i == 0 and self.first_row_as_headings: - with self._fpdf.use_font_style(self.headings_style): - self._render_table_row(i, fill=bool(self.headings_style.fill_color)) - else: - self._render_table_row(i) - def _render_table_row(self, i, fill=False, **kwargs): - row = self._rows[i] + row = self.rows[i] lines_heights_per_cell = self._get_lines_heights_per_cell(i) row_height = max(sum(lines_heights) for lines_heights in lines_heights_per_cell) - for j in range(len(row.cells)): + j = 0 + while j < len(row.cells): cell_line_height = row_height / len(lines_heights_per_cell[j]) self._render_table_cell( i, @@ -152,6 +150,7 @@ def _render_table_row(self, i, fill=False, **kwargs): fill=fill, **kwargs, ) + j += row.cells[j].colspan self._fpdf.ln(row_height) # pylint: disable=inconsistent-return-statements @@ -168,9 +167,9 @@ def _render_table_cell( """ If `lines_heights_only` is True, returns a list of lines (subcells) heights. """ - row = self._rows[i] - col_width = self._get_col_width(i, j) + row = self.rows[i] cell = row.cells[j] + col_width = self._get_col_width(i, j, cell.colspan) lines_heights = [] if cell.img: if lines_heights_only: @@ -190,47 +189,62 @@ def _render_table_cell( keep_aspect_ratio=True, ) self._fpdf.set_xy(x, y) - if not fill: + text_align = cell.align or self.text_align + if not isinstance(text_align, (Align, str)): + text_align = text_align[j] + style = cell.style + if not style and i == 0 and self.first_row_as_headings: + style = self.headings_style + if lines_heights_only and style: + style = style.replace(emphasis=None) + if style and style.fill_color: + fill = True + elif not fill: fill = self.cell_fill_color and self.cell_fill_logic(i, j) - text_align = ( - self.text_align - if isinstance(self.text_align, (Align, str)) - else self.text_align[j] - ) - lines = self._fpdf.multi_cell( - w=col_width, - h=row_height, - txt=cell.text, - max_line_height=cell_line_height, - border=self.get_cell_border(i, j), - align=text_align, - new_x="RIGHT", - new_y="TOP", - fill=fill, - split_only=lines_heights_only, - **kwargs, - ) - if lines_heights_only and cell.text: - lines_heights += len(lines) * [self.line_height] + if fill and self.cell_fill_color and not (style and style.fill_color): + style = ( + style.replace(fill_color=self.cell_fill_color) + if style + else FontStyle(fill_color=self.cell_fill_color) + ) + with self._fpdf.use_font_style(style): + lines = self._fpdf.multi_cell( + w=col_width, + h=row_height, + txt=cell.text, + max_line_height=cell_line_height, + border=self.get_cell_border(i, j), + align=text_align, + new_x="RIGHT", + new_y="TOP", + fill=fill, + split_only=lines_heights_only, + **kwargs, + ) + if lines_heights_only and not cell.img: + lines_heights += (len(lines) or 1) * [self.line_height] if lines_heights_only: return lines_heights - def _get_col_width(self, i, j): + def _get_col_width(self, i, j, colspan=1): if not self.col_widths: - cols_count = len(self._rows[i].cells) - return self.width / cols_count + cols_count = self.rows[i].cols_count + return colspan * (self.width / cols_count) if isinstance(self.col_widths, Number): - return self.col_widths + return colspan * self.col_widths if j >= len(self.col_widths): raise ValueError( f"Invalid .col_widths specified: missing width for table() column {j + 1} on row {i + 1}" ) # pylint: disable=unsubscriptable-object - col_ratio = self.col_widths[j] / sum(self.col_widths) - return col_ratio * self.width + col_width = 0 + for k in range(j, j + colspan): + col_ratio = self.col_widths[k] / sum(self.col_widths) + col_width += col_ratio * self.width + return col_width def _get_lines_heights_per_cell(self, i) -> List[List[int]]: - row = self._rows[i] + row = self.rows[i] lines_heights = [] for j in range(len(row.cells)): lines_heights.append( @@ -251,13 +265,21 @@ class Row: def __init__(self): self.cells = [] - def cell(self, text="", img=None, img_fill_width=False): + @property + def cols_count(self): + return sum(cell.colspan for cell in self.cells) + + def cell( + self, text="", align=None, style=None, img=None, img_fill_width=False, colspan=1 + ): """ Adds a cell to the row. Args: text (str): string content, can contain several lines. In that case, the row height will grow proportionally. + align (str, fpdf.enums.Align): optional text alignment + style (fpdf.fonts.FontStyle): optional text style img: optional. Either a string representing a file path to an image, an URL to an image, an io.BytesIO, or a instance of `PIL.Image.Image`. img_fill_width (bool): optional, defaults to False. Indicates to render the image @@ -268,13 +290,15 @@ def cell(self, text="", img=None, img_fill_width=False): "fpdf2 currently does not support inserting text with an image in the same table cell." "Pull Requests are welcome to implement this 😊" ) - self.cells.append(Cell(text, img, img_fill_width)) + self.cells.append(Cell(text, align, style, img, img_fill_width, colspan)) +@dataclass class Cell: "Internal representation of a table cell" - - def __init__(self, text, img, img_fill_width): - self.text = text - self.img = img - self.img_fill_width = img_fill_width + text: str + align: Union[str, Align] + style: FontStyle + img: str + img_fill_width: bool + colspan: int diff --git a/test/html/test_customize_ul.pdf b/test/html/html_customize_ul.pdf similarity index 100% rename from test/html/test_customize_ul.pdf rename to test/html/html_customize_ul.pdf diff --git a/test/html/html_features.pdf b/test/html/html_features.pdf index b31838a39..b0d3c5fba 100644 Binary files a/test/html/html_features.pdf and b/test/html/html_features.pdf differ diff --git a/test/html/test_img_not_overlapping.pdf b/test/html/html_img_not_overlapping.pdf similarity index 100% rename from test/html/test_img_not_overlapping.pdf rename to test/html/html_img_not_overlapping.pdf diff --git a/test/html/html_table_line_separators.pdf b/test/html/html_table_line_separators.pdf index 6dd04631d..37d328506 100644 Binary files a/test/html/html_table_line_separators.pdf and b/test/html/html_table_line_separators.pdf differ diff --git a/test/html/html_table_line_separators_issue_137.pdf b/test/html/html_table_line_separators_issue_137.pdf index d278ad893..c6d470aa4 100644 Binary files a/test/html/html_table_line_separators_issue_137.pdf and b/test/html/html_table_line_separators_issue_137.pdf differ diff --git a/test/html/html_simple_table.pdf b/test/html/html_table_simple.pdf similarity index 69% rename from test/html/html_simple_table.pdf rename to test/html/html_table_simple.pdf index f5ef356c9..b5aa7478e 100644 Binary files a/test/html/html_simple_table.pdf and b/test/html/html_table_simple.pdf differ diff --git a/test/html/bgcolor_in_table.pdf b/test/html/html_table_with_bgcolor.pdf similarity index 58% rename from test/html/bgcolor_in_table.pdf rename to test/html/html_table_with_bgcolor.pdf index 2c061fbea..c6050e45d 100644 Binary files a/test/html/bgcolor_in_table.pdf and b/test/html/html_table_with_bgcolor.pdf differ diff --git a/test/html/html_table_with_border.pdf b/test/html/html_table_with_border.pdf index 89a8d12f4..6c3f372f3 100644 Binary files a/test/html/html_table_with_border.pdf and b/test/html/html_table_with_border.pdf differ diff --git a/test/html/html_table_with_empty_cell_contents.pdf b/test/html/html_table_with_empty_cell_contents.pdf index efe61273c..ff8f4a293 100644 Binary files a/test/html/html_table_with_empty_cell_contents.pdf and b/test/html/html_table_with_empty_cell_contents.pdf differ diff --git a/test/html/test_img_inside_html_table_centered_with_align.pdf b/test/html/html_table_with_img.pdf similarity index 95% rename from test/html/test_img_inside_html_table_centered_with_align.pdf rename to test/html/html_table_with_img.pdf index 22db3f56c..a10439485 100644 Binary files a/test/html/test_img_inside_html_table_centered_with_align.pdf and b/test/html/html_table_with_img.pdf differ diff --git a/test/html/test_img_inside_html_table_without_explicit_dimensions.pdf b/test/html/html_table_with_img_without_explicit_dimensions.pdf similarity index 95% rename from test/html/test_img_inside_html_table_without_explicit_dimensions.pdf rename to test/html/html_table_with_img_without_explicit_dimensions.pdf index 78983577f..a10439485 100644 Binary files a/test/html/test_img_inside_html_table_without_explicit_dimensions.pdf and b/test/html/html_table_with_img_without_explicit_dimensions.pdf differ diff --git a/test/html/test_img_inside_html_table_centered_with_caption.pdf b/test/html/html_table_with_imgs_captions_and_colspan.pdf similarity index 97% rename from test/html/test_img_inside_html_table_centered_with_caption.pdf rename to test/html/html_table_with_imgs_captions_and_colspan.pdf index 813841803..1b53a4e87 100644 Binary files a/test/html/test_img_inside_html_table_centered_with_caption.pdf and b/test/html/html_table_with_imgs_captions_and_colspan.pdf differ diff --git a/test/html/test_html_whitespace_handling.pdf b/test/html/html_whitespace_handling.pdf similarity index 100% rename from test/html/test_html_whitespace_handling.pdf rename to test/html/html_whitespace_handling.pdf diff --git a/test/html/test_html.py b/test/html/test_html.py index fb4fac153..062897613 100644 --- a/test/html/test_html.py +++ b/test/html/test_html.py @@ -26,7 +26,7 @@ def test_html_images(tmp_path): f"
" ) # Unable to text position of the image as write html moves to a new line after - # adding the image but it can be seen in the produce test.pdf file. + # adding the image but it can be seen in the resulting html_images.pdf file. assert round(pdf.get_x()) == 10 assert pdf.get_y() == pytest.approx(mm_after_image, abs=0.01) @@ -83,8 +83,8 @@ def test_html_features(tmp_path): "
idnameidname
" @@ -110,8 +110,8 @@ def test_html_features(tmp_path): " " " " " " - ' id' - ' name' + " id" + " name" " " " " "" @@ -160,7 +160,7 @@ def getrow(i): " " " " " " - ' Alice' + ' Alice' " " ) + "".join(getrow(i) for i in range(26)) @@ -175,73 +175,6 @@ def getrow(i): assert_pdf_equal(pdf, HERE / "html_features.pdf", tmp_path) -def test_html_simple_table(tmp_path): - pdf = FPDF() - pdf.set_font_size(30) - pdf.add_page() - pdf.write_html( - """ - - - - - -
leftcenterright
123
456
""" - ) - assert_pdf_equal(pdf, HERE / "html_simple_table.pdf", tmp_path) - - -def test_html_table_line_separators(tmp_path): - pdf = FPDF() - pdf.set_font_size(30) - pdf.add_page() - pdf.write_html( - """ - - - - - -
leftcenterright
123
456
""", - table_line_separators=True, - ) - assert_pdf_equal(pdf, HERE / "html_table_line_separators.pdf", tmp_path) - - -def test_html_table_th_inside_tr_issue_137(tmp_path): - pdf = FPDF() - pdf.add_page() - pdf.write_html( - """ - - - - - - - - -
header1header2
value1value2
""" - ) - assert_pdf_equal(pdf, HERE / "html_table_line_separators_issue_137.pdf", tmp_path) - - -def test_html_table_with_border(tmp_path): - pdf = FPDF() - pdf.set_font_size(30) - pdf.add_page() - pdf.write_html( - """ - - - - - -
leftcenterright
123
456
""" - ) - assert_pdf_equal(pdf, HERE / "html_table_with_border.pdf", tmp_path) - - def test_html_bold_italic_underline(tmp_path): pdf = FPDF() pdf.set_font_size(30) @@ -255,7 +188,7 @@ def test_html_bold_italic_underline(tmp_path): assert_pdf_equal(pdf, HERE / "html_bold_italic_underline.pdf", tmp_path) -def test_customize_ul(tmp_path): +def test_html_customize_ul(tmp_path): html = """