From 1300adacc6c60ac7a5d3a3741147f4a4dabcb60b Mon Sep 17 00:00:00 2001 From: Georg Mischler Date: Thu, 13 Jun 2024 22:33:49 +0200 Subject: [PATCH] very raw text region table adaptation --- fpdf/table.py | 786 +++++++++++++++----------------------------- fpdf/text_region.py | 1 + 2 files changed, 269 insertions(+), 518 deletions(-) diff --git a/fpdf/table.py b/fpdf/table.py index 3d2528810..8127d4f96 100644 --- a/fpdf/table.py +++ b/fpdf/table.py @@ -14,6 +14,7 @@ ) from .errors import FPDFException from .fonts import CORE_FONTS, FontFace +from .text_region import TextColumns, Extents from .util import Padding DEFAULT_HEADINGS_STYLE = FontFace(emphasis="BOLD") @@ -84,8 +85,12 @@ def __init__( repeat_headings (fpdf.enums.TableHeadingsDisplay): optional, indicates whether to print table headings on every page, default to 1. """ self._fpdf = fpdf - self._align = align - self._v_align = VAlign.coerce(v_align) + self.align = Align.coerce(align) + if self.align not in (Align.L, Align.C, Align.R): + raise ValueError( + "FPDF.table() 'align' parameter must be 'LEFT', 'CENTER', or 'RIGHT', not '{self.align.value}'" + ) + self.v_align = VAlign.coerce(v_align) self._borders_layout = TableBordersLayout.coerce(borders_layout) self._outer_border_width = outer_border_width self._cell_fill_color = cell_fill_color @@ -97,7 +102,7 @@ def __init__( self._headings_style = headings_style self._line_height = 2 * fpdf.font_size if line_height is None else line_height self._markdown = markdown - self._text_align = text_align + self.text_align = text_align self._width = fpdf.epw if width is None else width self._wrapmode = wrapmode self._num_heading_rows = num_heading_rows @@ -106,9 +111,9 @@ def __init__( self.rows = [] if padding is None: - self._padding = Padding.new(0) + self.padding = Padding.new(0) else: - self._padding = Padding.new(padding) + self.padding = Padding.new(padding) # check table_border_layout and outer_border_width if self._borders_layout not in ( @@ -150,7 +155,12 @@ def row(self, cells=(), style=None): "Adds a row to the table. Returns a `Row` object." if self._initial_style is None: self._initial_style = self._fpdf.font_face() - row = Row(self, style=style) + cur_row_no = len(self.rows) + if cur_row_no < self._num_heading_rows: + style = self._headings_style + else: + style = self._fpdf.font_face() + row = TableRow(self, self._fpdf, cur_row_no, style=style) self.rows.append(row) for cell in cells: if isinstance(cell, dict): @@ -159,24 +169,50 @@ def row(self, cells=(), style=None): row.cell(cell) return row - def render(self): - "This is an internal method called by `fpdf.FPDF.table()` once the table is finished" - # Starting with some sanity checks: + def _data_sanity_checks(self): + if not self.rows: + return False if self._width > self._fpdf.epw: raise ValueError( f"Invalid value provided width={self._width}: effective page width is {self._fpdf.epw}" ) - table_align = Align.coerce(self._align) - if table_align == Align.J: - raise ValueError( - "JUSTIFY is an invalid value for FPDF.table() 'align' parameter" - ) if self._num_heading_rows > 0: if not self._headings_style: raise ValueError( "headings_style must be provided to FPDF.table() if num_heading_rows>1 or first_row_as_headings=True" ) emphasis = self._headings_style.emphasis + if emphasis is not None: + family = self._headings_style.family or self._fpdf.font_family + font_key = family.lower() + emphasis.style + if font_key not in CORE_FONTS and font_key not in self._fpdf.fonts: + # Raising a more explicit error than the one from set_font(): + raise FPDFException( + f"Using font emphasis '{emphasis.style}' in table headings require the corresponding font style to be added using add_font()" + ) + cols_count = self.rows[0].cols_count + if cols_count < 1: + return False + for i, row in enumerate(self.rows[1:], start=2): + if row.cols_count != cols_count: + raise FPDFException( + f"Inconsistent column count detected on row {i}:" + f" it has {row.cols_count} columns," + f" whereas the top row has {cols_count}." + ) + if (self._num_heading_rows > 0) and not self._headings_style: + raise ValueError( + "headings_style must be provided to FPDF.table() if num_heading_rows>1 or first_row_as_headings=True" + ) + return True + + def render(self): + "This is an internal method called by `fpdf.FPDF.table()` once the table is finished" + # Starting with some sanity checks: + if not self._data_sanity_checks(): + return + if self._num_heading_rows > 0: + emphasis = self._headings_style.emphasis if emphasis is not None: family = self._headings_style.family or self._fpdf.font_family font_key = family.lower() + emphasis.style @@ -189,69 +225,68 @@ def render(self): ) # Defining table global horizontal position: - prev_x, prev_y, prev_l_margin = self._fpdf.x, self._fpdf.y, self._fpdf.l_margin - if table_align == Align.C: - self._fpdf.l_margin = (self._fpdf.w - self._width) / 2 - self._fpdf.x = self._fpdf.l_margin - elif table_align == Align.R: - self._fpdf.l_margin = self._fpdf.w - self._fpdf.r_margin - self._width - self._fpdf.x = self._fpdf.l_margin + if self.align == Align.C: + self._left = (self._fpdf.w - self._width) / 2 + elif self.align == Align.R: + self._left = self._fpdf.w - self._fpdf.r_margin - self._width elif self._fpdf.x != self._fpdf.l_margin: - self._fpdf.l_margin = self._fpdf.x - - # Pre-Compute the relative x-positions of the individual columns: - xx = self._outer_border_margin[0] - cell_x_positions = [xx] - if self.rows: - self._cols_count = max(row.cols_count for row in self.rows) - for i in range(self._cols_count): - xx += self._get_col_width(0, i) - xx += self._gutter_width - cell_x_positions.append(xx) - else: - self._cols_count = 0 + self._left = self._fpdf.x - # Process any rowspans - row_info = list(self._process_rowpans_entries()) + # Define horozontal position of all columns. + self._determine_col_extents() - # actually render the cells - repeat_headings = ( - self._repeat_headings is TableHeadingsDisplay.ON_TOP_OF_EVERY_PAGE - ) - self._fpdf.y += self._outer_border_margin[1] + # Render the actual cells. for i, row in enumerate(self.rows): - pagebreak_height = row_info[i].pagebreak_height - # pylint: disable=protected-access - page_break = self._fpdf._perform_page_break_if_need_be(pagebreak_height) - if ( - page_break - and self._fpdf.y + pagebreak_height > self._fpdf.page_break_trigger - ): - # Restoring original position on page: - self._fpdf.x = prev_x - self._fpdf.y = prev_y - self._fpdf.l_margin = prev_l_margin - raise ValueError( - f"The row with index {i} is too high and cannot be rendered on a single page" - ) - if page_break and repeat_headings and i >= self._num_heading_rows: - # repeat headings on top: - self._fpdf.y += self._outer_border_margin[1] - for row_idx in range(self._num_heading_rows): - self._render_table_row( - row_idx, - row_info[row_idx], - cell_x_positions=cell_x_positions, + col_n = 0 + row_height = 0 + for j, cell in enumerate(row.cells): + if j >= len(self.col_extents): + raise ValueError( + f"Invalid .col_widths specified: missing width for table() column {j + 1} on row {i + 1}" ) - if i > 0: - self._fpdf.y += self._gutter_height - self._render_table_row(i, row_info[i], cell_x_positions) + # Preprocess the collected data and determine the required height for each cell. + if i < self._num_heading_rows: + style = self._headings_style + else: + style = cell.style or row.style + left = self.col_extents[col_n].left + right = self.col_extents[col_n + cell.colspan - 1].right + cell.set_cell_extents(Extents(left, right)) + cell_height = cell.collect_cell_lines() + if cell_height > row_height: + row_height = cell_height + col_n += cell.colspan + + # YYY - handle page breaks + + top = self._fpdf.y + bottom = self._fpdf.y + row_height + + for j, cell in enumerate(row.cells): + + fill = False + if style and style.fill_color: + fill = True + elif ( + not fill + and self._cell_fill_color + and self._cell_fill_mode != TableCellFillMode.NONE + ): + if self._cell_fill_mode == TableCellFillMode.ALL: + fill = True + elif self._cell_fill_mode == TableCellFillMode.ROWS: + fill = bool(i % 2) + elif self._cell_fill_mode == TableCellFillMode.COLUMNS: + fill = bool(j % 2) + + cell.render_cell(top, bottom, fill) + self._fpdf.y = bottom + self._gutter_height - # Restoring altered FPDF settings: - self._fpdf.l_margin = prev_l_margin self._fpdf.x = self._fpdf.l_margin + self._fpdf.y = bottom + - def get_cell_border(self, i, j, cell): + def get_cell_borders(self, i, j, cell): """ Defines which cell borders should be drawn. Returns a string containing some or all of the letters L/R/T/B, @@ -304,417 +339,49 @@ def get_cell_border(self, i, j, cell): return "B" if i < self._num_heading_rows else 0 return "".join(border) - def _render_table_row(self, i, row_layout_info, cell_x_positions, **kwargs): - row = self.rows[i] - y = self._fpdf.y # remember current y position, reset after each cell - - for j, cell in enumerate(row.cells): - if cell is None: - continue - self._render_table_cell( - i, - j, - cell, - row_height=self._line_height, - cell_height_info=row_layout_info, - cell_x_positions=cell_x_positions, - **kwargs, - ) - self._fpdf.set_y(y) # restore y position after each cell - - self._fpdf.ln(row_layout_info.height) - - def _render_table_cell( - self, - i, - j, - cell, - row_height, # height of a row of text including line spacing - cell_height_info=None, # full height of a cell, including padding, used to render borders and images - cell_x_positions=None, # x-positions of the individual columns, pre-calculated for speed. Only relevant when rendering - **kwargs, - ): - # If cell_height_info is provided then we are rendering a cell - # If cell_height_info is not provided then we are only here to figure out the height of the cell - # - # So this function is first called without cell_height_info to figure out the heights of all cells in a row - # and then called again with cell_height to actually render the cells - - if cell_height_info is None: - cell_height = None - height_query_only = True - elif cell.rowspan > 1: - cell_height = cell_height_info.merged_heights[cell.rowspan] - height_query_only = False - else: - cell_height = cell_height_info.height - height_query_only = False - - page_break_text = False - page_break_image = False - - # Get style and cell content: - - row = self.rows[i] - col_width = self._get_col_width(i, j, cell.colspan) - img_height = 0 - - text_align = cell.align or self._text_align - if not isinstance(text_align, (Align, str)): - text_align = text_align[j] - - style = self._initial_style - cell_mode_fill = self._cell_fill_mode.should_fill_cell(i, j) - if cell_mode_fill and self._cell_fill_color: - style = style.replace(fill_color=self._cell_fill_color) - if i < self._num_heading_rows: - style = FontFace.combine(style, self._headings_style) - style = FontFace.combine(style, row.style) - style = FontFace.combine(style, cell.style) - - padding = Padding.new(cell.padding) if cell.padding else self._padding - - v_align = cell.v_align if cell.v_align else self._v_align - - # We can not rely on the actual x position of the cell. Notably in case of - # empty cells or cells with an image only the actual x position is incorrect. - # Instead, we calculate the x position based on the column widths of the previous columns - - # place cursor (required for images after images) - - # not rendering, cell_x_positions is not relevant (and probably not provided): - if height_query_only: - cell_x = 0 - else: - cell_x = cell_x_positions[j] - - self._fpdf.set_x(self._fpdf.l_margin + cell_x) - - # render cell border and background - - # if cell_height is defined, that means that we already know the size at which the cell will be rendered - # so we can draw the borders now - # - # If cell_height is None then we're still in the phase of calculating the height of the cell meaning that - # we do not need to set fonts & draw borders yet. - - if not height_query_only: - x1 = self._fpdf.x - y1 = self._fpdf.y - x2 = ( - x1 + col_width - ) # already includes gutter for cells spanning multiple columns - y2 = y1 + cell_height - - draw_box_borders( - self._fpdf, - x1, - y1, - x2, - y2, - border=self.get_cell_border(i, j, cell), - fill_color=style.fill_color if style 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) - - # draw the outer box separated by the gutter dimensions - # the top and bottom borders are one continuous line - # whereas the left and right borders are segments beause of possible pagebreaks - x1 = self._fpdf.l_margin - x2 = x1 + self._width - y1 = y1 - self._outer_border_margin[1] - y2 = y2 + self._outer_border_margin[1] - - if j == 0: - # lhs border - self._fpdf.line(x1, y1, x1, y2) - if j + cell.colspan == self._cols_count: - # rhs border - self._fpdf.line(x2, y1, x2, y2) - # continuous top line border - if i == 0: - self._fpdf.line(x1, y1, x2, y1) - # continuous bottom line border - if i + cell.rowspan == len(self.rows): - self._fpdf.line(x1, y2, x2, y2) - - self._fpdf.set_line_width(_remember_linewidth) - - # render image - - if cell.img: - x, y = self._fpdf.x, self._fpdf.y - - # if cell_height is None or width is given then call image with h=0 - # calling with h=0 means that the image will be rendered with an auto determined height - auto_height = cell.img_fill_width or cell_height is None - cell_border_line_width = self._fpdf.line_width - - # apply padding - self._fpdf.x += padding.left + cell_border_line_width / 2 - self._fpdf.y += padding.top + cell_border_line_width / 2 - - image = self._fpdf.image( - cell.img, - w=col_width - padding.left - padding.right - cell_border_line_width, - h=( - 0 - if auto_height - else cell_height - - padding.top - - padding.bottom - - cell_border_line_width - ), - keep_aspect_ratio=True, - link=cell.link, - ) - - img_height = ( - image.rendered_height - + padding.top - + padding.bottom - + cell_border_line_width - ) - - if img_height + y > self._fpdf.page_break_trigger: - page_break_image = True - - self._fpdf.set_xy(x, y) - - # render text - - if cell.text: - dy = 0 - - if cell_height is not None: - actual_text_height = cell_height_info.rendered_heights[j] - - if v_align == VAlign.M: - dy = (cell_height - actual_text_height) / 2 - elif v_align == VAlign.B: - dy = cell_height - actual_text_height - - self._fpdf.y += dy - - with self._fpdf.use_font_face(style): - page_break_text, cell_height = self._fpdf.multi_cell( - w=col_width, - h=row_height, - text=cell.text, - max_line_height=self._line_height, - border=0, - align=text_align, - new_x="RIGHT", - new_y="TOP", - fill=False, # fill is already done above - markdown=self._markdown, - output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT, - wrapmode=self._wrapmode, - padding=padding, - link=cell.link, - **kwargs, - ) - - self._fpdf.y -= dy - else: - cell_height = 0 - - do_pagebreak = page_break_text or page_break_image - - return do_pagebreak, img_height, cell_height - - def _get_col_width(self, i, j, colspan=1): - """Gets width of a column in a table, this excludes the outer gutter (outside the table) but includes the inner gutter - between columns if the cell spans multiple columns.""" - - cols_count = self._cols_count - width = ( - self._width - - (cols_count - 1) * self._gutter_width - - 2 * self._outer_border_margin[0] - ) - gutter_within_cell = max((colspan - 1) * self._gutter_width, 0) + def _determine_col_extents(self): + "Precalculate the horizontal extents of each column, including border and margins, excluding gutters." + cols_count = self.rows[0].cols_count if not self._col_widths: - return colspan * (width / cols_count) + gutter_within_cell - if isinstance(self._col_widths, Number): - return colspan * self._col_widths + gutter_within_cell - if j >= len(self._col_widths): - raise ValueError( - f"Invalid .col_widths specified: missing width for table() column {j + 1} on row {i + 1}" - ) - col_width = 0 - for k in range(j, j + colspan): - col_ratio = self._col_widths[k] / sum(self._col_widths) - col_width += col_ratio * width - if k != j: - col_width += self._gutter_width - return col_width - - def _process_rowpans_entries(self): - # First pass: Regularise the table by processing the rowspan and colspan entries - active_rowspans = {} - prev_row_in_col = {} - for i, row in enumerate(self.rows): - # Link up rowspans - active_rowspans, prior_rowspans = row.convert_spans(active_rowspans) - for col_idx in prior_rowspans: - # This cell is TableSpan.ROW, so accumulate to the previous row - prev_row = prev_row_in_col[col_idx] - if prev_row is not None: - # Since Cell objects are frozen, we need to recreate them to update the rowspan - cell = prev_row.cells[col_idx] - prev_row.cells[col_idx] = replace(cell, rowspan=cell.rowspan + 1) - for j, cell in enumerate(row.cells): - if isinstance(cell, Cell): - # Keep track of the non-span cells - prev_row_in_col[j] = row - for k in range(j + 1, j + cell.colspan): - prev_row_in_col[k] = None - if len(active_rowspans) != 0: - raise FPDFException("Rowspan extends beyond end of table") - - # Second pass: Estimate the cell sizes - rowspan_list = [] - row_min_heights = [] - row_span_max = [] - rendered_heights = [] - # pylint: disable=protected-access - with self._fpdf._disable_writing(): - for i, row in enumerate(self.rows): - dictated_heights = [] - img_heights = [] - rendered_heights.append({}) - - for j, cell in enumerate(row.cells): - if cell is None: # placeholder cell - continue - - # NB: ignore page_break since we might need to assign rowspan padding - _, img_height, text_height = self._render_table_cell( - i, - j, - cell, - row_height=self._line_height, - ) - if cell.img_fill_width: - dictated_height = img_height - else: - dictated_height = text_height - - # Store the dictated heights in a dict (not list) because of span elements - rendered_heights[i][j] = dictated_height - - if cell.rowspan > 1: - # For spanned rows, use img_height if dictated_height is zero - rowspan_list.append( - RowSpanLayoutInfo( - j, i, cell.rowspan, dictated_height or img_height - ) - ) - # Often we want rowspans in headings, but issues arise if the span crosses outside the heading - is_heading = i < self._num_heading_rows - span_outside_heading = i + cell.rowspan > self._num_heading_rows - if is_heading and span_outside_heading: - raise FPDFException( - "Heading includes rowspan beyond the number of heading rows" - ) - else: - dictated_heights.append(dictated_height) - img_heights.append(img_height) - - # The height of the rows is chosen as follows: - # The "dictated height" is the space required for text/image, so pick the largest in the row - # If this is zero, we will fill the space with images, so pick the largest image height - # If this is still zero (e.g. empty/fully spanned row), use a sensible default - min_height = 0 - if dictated_heights: - min_height = max(dictated_heights) - if min_height == 0: - min_height = max(img_heights) - if min_height == 0: - min_height = self._line_height - - row_min_heights.append(min_height) - row_span_max.append(row.max_rowspan) - - # Sort the spans so we allocate padding to the smallest spans first - rowspan_list = sorted(rowspan_list, key=lambda span: span.length) - - # Third pass: allocate space required for the rowspans - row_span_padding = [0 for row in self.rows] - for span in rowspan_list: - # accumulate already assigned properties - max_padding = 0 - assigned_height = self._gutter_height * (span.length - 1) - assigned_padding = 0 - for i in span.row_range(): - max_padding = max(max_padding, row_span_padding[i]) - assigned_height += row_min_heights[i] - assigned_padding += row_span_padding[i] - - # does additional padding need to be distributed? - if assigned_height + assigned_padding < span.contents_height: - # when there are overlapping rowspans, can we stretch the cells to be evenly padded? - if span.contents_height > assigned_height + span.length * max_padding: - # stretch all cells to have the same padding, for asthetic reasons - padding = (span.contents_height - assigned_height) / span.length - for i in span.row_range(): - row_span_padding[i] = padding - else: - # add proportional padding to the rows - extra = span.contents_height - assigned_height - assigned_padding - for i in span.row_range(): - row_span_padding[i] += extra / span.length - - # Fourth pass: compute the final element sizes - for i, row in enumerate(self.rows): - row_height = row_min_heights[i] + row_span_padding[i] - # Compute the size of merged cells - merged_sizes = [0, row_height] - for j in range(i + 1, i + row_span_max[i]): - merged_sizes.append( - merged_sizes[-1] - + self._gutter_height - + row_min_heights[j] - + row_span_padding[j] - ) - # Pagebreak should not occur within ANY rowspan, so validate ACCUMULATED rowspans - # This gets complicated because of overlapping rowspans (see `test_table_with_rowspan_and_pgbreak()`) - # Eventually, this should be refactored to rearrange cells to permit breaks within spans - pagebreak_height = row_height - pagebreak_row = i + row_span_max[i] - j = i + 1 - while j < pagebreak_row: - # NB: this can't be a for loop because the upper limit might keep changing - pagebreak_row = max(pagebreak_row, j + row_span_max[j]) - pagebreak_height += ( - self._gutter_height + row_min_heights[j] + row_span_padding[j] - ) - j += 1 - - yield RowLayoutInfo( - merged_sizes[1], pagebreak_height, rendered_heights[i], merged_sizes - ) + widths = ((self._width - (cols_count - 1) * self._gutter_width) / cols_count,) * cols_count + elif isinstance(self._col_widths, Number): + widths = (self._col_widths,) * cols_count + else: + sum_width = sum(self._col_widths) + w_mult = self._width / sum_width + widths = [w * w_mult for w in self._col_widths] + x = self._left + self.col_extents = [] + for w in widths: + self.col_extents.append(Extents(left=x, right=x + w)) + x += w + self._gutter_width -class Row: +class TableRow: "Object that `Table.row()` yields, used to build a row in a table" - def __init__(self, table, style=None): + def __init__(self, table: Table, fpdf, row_no, style=None): self._table = table - self.cells = [] + self._fpdf = fpdf + self.row_no = row_no self.style = style + self.cells = [] @property def cols_count(self): return sum(getattr(cell, "colspan", cell is not None) for cell in self.cells) + @property + def column_indices(self): + columns_count = len(self.cells) + colidx = 0 + indices = [colidx] + for jj in range(columns_count - 1): + colidx += self.cells[jj].colspan + indices.append(colidx) + return indices + @property def max_rowspan(self): spans = {cell.rowspan for cell in self.cells if cell is not None} @@ -763,38 +430,47 @@ def cell( text="", align=None, v_align=None, + line_height=None, style=None, img=None, img_fill_width=False, + link=None, colspan=1, rowspan=1, padding=None, - link=None, + print_sh: bool = False, + wrapmode: WrapMode = WrapMode.WORD, + skip_leading_spaces: bool = False, ): """ 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. - v_align (str, fpdf.enums.AlignV): optional vertical text alignment. - style (fpdf.fonts.FontFace): 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 - using the full width of the current table column. - colspan (int): optional number of columns this cell should span. - rowspan (int): optional number of rows this cell should span. - padding (tuple): optional padding (left, top, right, bottom) for the cell. - link (str, int): optional link, either an URL or an integer returned by `FPDF.add_link`, defining an internal link to a page - + text (str; optional): string content, can contain several lines. Text with varying formatting can be added + with the `.write()` method of the instance. + align (str, fpdf.enums.Align; optional): Text alignment within the cell. + v_align (str, fpdf.enums.AlignV; optional): Vertical text alignment within the cell. + style (fpdf.fonts.FontFace; optional): Default text style for the cell. Any properties defined in the + supplied FontFace will override those defined for the whole row. + img (str, bytes, io.BytesIO, PIL.Image.Image; optional): + a path-like object or a the actual data of the image to be inserted into the cell. + If both `text` and `img` arguments are given, the text will be inserted first. + Images can also be added with the `.image()` method of the instance. + img_fill_width (bool; optional): Indicates to render the image using the full width of the current + table cell. (Default: False) + colspan (int; optional): Number of columns this cell should span. (Default: 1) + rowspan (int; optional): Number of rows this cell should span. (Default: 1) + padding (tuple; optional): padding (left, top, right, bottom) for the cell. + link (str, int): optional link, either an URL or an integer returned by `FPDF.add_link`, defining + an internal link to a page. This link will apply only to any `text` or `img` arguments supplied, + and not to content added with `.write()` or `.image()`. + print_sh (bool; optional): Treat a soft-hyphen (\\u00ad) as a printable character, + instead of a line breaking opportunity. (Default: False) + wrapmode (fpdf.enums.WrapMode; optional): "WORD" for word based line wrapping, + "CHAR" for character based line wrapping. (Default: "WORD") + skip_leading_spaces (bool; optional): On each line, any space characters at the + beginning will be skipped if True. (Default: False) """ - if text and img: - raise NotImplementedError( - "fpdf2 currently does not support inserting text with an image in the same table cell." - " Pull Requests are welcome to implement this 😊" - ) if isinstance(text, TableSpan): # Special placeholder object, converted to colspan/rowspan during processing @@ -807,38 +483,34 @@ def cell( font_face = self._table._fpdf.font_face() if font_face not in (self.style, self._table._initial_style): style = font_face - - cell = Cell( - text, - align, - v_align, - style, - img, - img_fill_width, - colspan, - rowspan, - padding, - link, + else: + borders = None + + cell = TableCell( + self, + self._table, + len(self.cells), + self._fpdf, + text=text, + text_align=align if align else self._table.text_align, + v_align=v_align if v_align else self._table.v_align, + style=style or self.style, + img=img, + img_fill_width=img_fill_width, + link=link, + colspan=colspan, + rowspan=rowspan, + padding=padding, + print_sh=print_sh, + wrapmode=wrapmode, + skip_leading_spaces=skip_leading_spaces, ) self.cells.append(cell) return cell - -@dataclass(frozen=True) -class Cell: +class TableCell(TextColumns): "Internal representation of a table cell" - __slots__ = ( # RAM usage optimization - "text", - "align", - "v_align", - "style", - "img", - "img_fill_width", - "colspan", - "rowspan", - "padding", - "link", - ) + """ text: str align: Optional[Union[str, Align]] v_align: Optional[Union[str, VAlign]] @@ -846,12 +518,90 @@ class Cell: img: Optional[str] img_fill_width: bool colspan: int - rowspan: int padding: Optional[Union[int, tuple, type(None)]] link: Optional[Union[str, int]] - - def write(self, text, align=None): - raise NotImplementedError("Not implemented yet") + """ + def __init__(self, + row, + table, + cell_no, + *args, + text=None, + v_align=None, + style=None, + link=None, + colspan=None, + rowspan=None, + padding=None, + **kwargs): + self._row = row + self._table = table + self.cell_no = cell_no + self.v_align = v_align + self.style = style + self.colspan = colspan + self.rowspan = rowspan + self.borders = self._table.get_cell_borders(row.row_no, cell_no, self) + if padding: + self.padding = padding + else: + self.padding = table.padding + kwargs["ncols"] = 1 + super().__init__(*args, **kwargs) + if text: + cur_page = self.pdf.page + self.pdf.page = 0 + with self.pdf.use_font_face(row.style if row.style else style): + self.write(text, link=link) + self.pdf.page = cur_page + + def set_cell_extents(self, extents): + self._cols = [extents] + + def get_margins(self): + left = self.padding.left + right = self.padding.right + if not left: + left = self.pdf.c_margin + if not right: + right = self.pdf.c_margin + return left, right + + def collect_cell_lines(self): + self._text_lines = self.collect_lines() + self.text_height = sum(l.line.height for l in self._text_lines) + tb_border_w = 0 + for c in "TB": + if self.borders and (self.borders == 1 or c in self.borders): + tb_border_w += self.pdf.line_width + return self.text_height + self.padding.top + self.padding.bottom + tb_border_w / 2 + + def render_cell(self, cell_top, cell_bottom, fill): + top = cell_top + self.padding.top + bottom = cell_bottom - self.padding.bottom + extents = self._cols[0] + #print(self.style.fill_color, fill) + draw_box_borders( + self.pdf, + extents.left, + top, + extents.right, + bottom, + border=self.borders, + fill_color=self.style.fill_color if fill else None, + ) + fillable_height = bottom - top + if fillable_height > self.text_height: + if self.v_align == VAlign.B: + top += fillable_height - self.text_height + elif self.v_align == VAlign.M: + top += (fillable_height - self.text_height) / 2.0 + # YYY - Images tend to slightly overlap with the border lines. + # YYY It looks like images are not placed quite exactly where we expect them to be. + # YYY Are we taking the size of the image pixels into account correctly? + #if self.borders and self.borders == 1 or "T" in self.borders: + # top += self.pdf.line_width / 2 + self._render_column_lines(self._text_lines, top, bottom) @dataclass(frozen=True) diff --git a/fpdf/text_region.py b/fpdf/text_region.py index 90a1ccea3..24fab34d2 100644 --- a/fpdf/text_region.py +++ b/fpdf/text_region.py @@ -499,6 +499,7 @@ class TextColumnarMixin: """Enable a TextRegion to perform page breaks""" def __init__(self, pdf, *args, l_margin=None, r_margin=None, **kwargs): + print('args:', args, kwargs) super().__init__(*args, **kwargs) self.l_margin = pdf.l_margin if l_margin is None else l_margin left = self.l_margin