py-pdf / fpdf2

Simple PDF generation for Python
https://py-pdf.github.io/fpdf2/
GNU Lesser General Public License v3.0
1.12k stars 254 forks source link

Vertically align Text in a Cell or Multicell #210

Closed johann-su closed 1 year ago

johann-su commented 3 years ago

Is there an option to change the vertical alignment of text inside a multicell from center to top or bottom?

I am using them to create a table and it would be very helpful to have the ability to change the vertical alignment of the text inside the table rows.

Bildschirmfoto 2021-08-25 um 20 33 55
Lucas-C commented 3 years ago

Hi @johann-su

Interesting question: no, there is currently no way to control vertical alignement in cells.

There are currently several methods to render tables with fpdf2: https://pyfpdf.github.io/fpdf2/Tables.html

Could you share a code snippet of what you are using, in order to see what can be done?

johann-su commented 3 years ago

I used this as a starting point and added some functionality to it, the most important of them being to compute the height of each row. I've done this similar to how the height of a multicell is calculated within the library.

Here is the complete code though it is quite long.

from fpdf import FPDF
# https://github.com/bvalgard/create-pdf-with-python-fpdf2/blob/main/create_table_fpdf2.py

class PdfTable(FPDF):
    def create_table(self, table_data, title='', data_size = 10, header_size=10, title_size=12, align_data='L', align_header='L', cell_width='even', x_start='x_default',emphasize_data=[], emphasize_style=None,emphasize_color=(0,0,0), border=False, footer=[], align_footer="R", footer_style="B"):
        """
        table_data: 
                    list of lists with first element being list of headers
        title: 
                    (Optional) title of table (optional)
        data_size: 
                    the font size of table data
        title_size: 
                    the font size fo the title of the table
        align_data: 
                    align table data
                    L = left align
                    C = center align
                    R = right align
        align_header: 
                    align table data
                    L = left align
                    C = center align
                    R = right align
        cell_width: 
                    even: evenly distribute cell/column width
                    uneven: base cell size on lenght of cell/column items
                    int: int value for width of each cell/column
                    list of ints: list equal to number of columns with the widht of each cell / column
        x_start: 
                    where the left edge of table should start
        emphasize_data:  
                    which data elements are to be emphasized - pass as list 
                    emphasize_style: the font style you want emphaized data to take
                    emphasize_color: emphasize color (if other than black) 

        """
        default_style = self.font_style
        if emphasize_style == None:
            emphasize_style = default_style
        line_height = self.font_size*1.2
        header = table_data[0]
        data = table_data[1:]

        self.set_font(size=title_size)

        def _char_width(font, char):
            cw = font["cw"]
            try:
                width = cw[char]
            except IndexError:
                width = font["desc"].get("MissingWidth") or 500
            if width == 65535:
                width = 0
            return width

        # Get Width of Columns
        def get_col_widths():
            col_width = cell_width
            if col_width == 'even':
                col_width = self.epw / len(table_data[0]) - 1  # distribute content evenly   # epw = effective page width (width of page not including margins)
            elif col_width == 'uneven':
                col_widths = []

                # searching through columns for largest sized cell (not rows but cols)
                for col in range(len(table_data[0])): # for every row
                    longest = 0 
                    for row in range(len(table_data)):
                        cell_value = str(table_data[row][col])
                        value_length = self.get_string_width(cell_value)
                        if value_length > longest:
                            longest = value_length
                    col_widths.append(longest + 4) # add 4 for padding
                col_width = col_widths
                ### compare columns 

            elif isinstance(cell_width, list):
                col_width = cell_width  # TODO: convert all items in list to int        
            else:
                # TODO: Add try catch
                col_width = int(col_width)
            return col_width

        # get the height of a multi_cell
        def get_row_height(txt, index=0):
            # Calculate text length
            txt = self.normalize_text(txt)
            s = txt.replace("\r", "")
            normalized_string_length = len(s)
            if normalized_string_length > 0 and s[-1] == "\n":
                normalized_string_length -= 1

            i = 0
            line_len = 0
            lines_count = 1
            while i < normalized_string_length:
                # Get next character
                c = s[i]

                if isinstance(get_col_widths(), list):
                    max_width = (get_col_widths()[index] - 2 * self.c_margin) * 1000 / self.font_size
                else:
                    max_width = (get_col_widths() - 2 * self.c_margin) * 1000 / self.font_size

                if self.unifontsubset:
                    line_len += self.get_string_width(c, True) / self.font_size * 1000
                else:
                    line_len += _char_width(self.current_font, c)

                # Explicit line break
                if c == "\n":
                    line_len = 0
                    lines_count += 1

                # Automatic line break
                if line_len > max_width:
                    line_len = 0
                    lines_count +=1

                i = i + 1

            return (lines_count+1) * line_height

        def get_title_multicell():
            self.multi_cell(0, line_height, title, border=0, align='j', ln=3)
            self.ln(line_height) # move cursor back to the left margin

        def get_emphazised_datacell(datum, col_width, cell_height, align=align_data, border=border):
            self.set_text_color(*emphasize_color)
            self.set_font(style=emphasize_style)
            if border == True:
                self.multi_cell(col_width, cell_height, datum, border=1, align=align, ln=3, max_line_height=line_height)
            elif border == False:
                self.multi_cell(col_width, cell_height, datum, border=0, align=align, ln=3, max_line_height=line_height)
            self.set_text_color(0,0,0)
            self.set_font(style=default_style)

        def get_regular_datacell(datum, col_width, cell_height, align=align_data, border=border):
            if border == True:
                self.multi_cell(col_width, cell_height, datum, border=1, align=align, ln=3, max_line_height=line_height)
            elif border == False: 
                self.multi_cell(col_width, cell_height, datum, border=0, align=align, ln=3,max_line_height=line_height)

        def get_x_start():
            # Get starting position of x
            # Determin width of table to get x starting point for centred table
            if x_start == 'C':
                table_width = 0
                if isinstance(col_width, list):
                    for width in col_width:
                        table_width += width
                else: # need to multiply cell width by number of cells to get table width 
                    table_width = col_width * len(table_data[0])
                # Get x start by subtracting table width from pdf width and divide by 2 (margins)
                margin_width = self.w - table_width
                # TODO: Check if table_width is larger than pdf width

                center_table = margin_width / 2 # only want width of left margin not both
                x_start_new = center_table
                self.set_x(x_start)
            elif isinstance(x_start, int):
                self.set_x(x_start)
            elif x_start == 'x_default':
                x_start_new = self.set_x(self.l_margin)

            return x_start_new

        def get_cell_height(row):
            cell_height = line_height
            for i, text in enumerate(row):
                new_height = get_row_height(text, i)
                if new_height > cell_height:
                    cell_height = new_height

            return cell_height

        col_width = get_col_widths()

        # TABLE CREATION #
        x_start = get_x_start()
        # add title
        if title != '':
            get_title_multicell()

        # add header
        y1 = self.get_y()
        if x_start:
            x_left = x_start
        else:
            x_left = self.get_x()
        x_right = self.epw + x_left

        self.set_font(size=header_size)
        if not isinstance(col_width, list):
            if x_start:
                self.set_x(x_start)

            cell_height = get_cell_height(header)

            for datum in header:
                get_regular_datacell(datum, col_width, cell_height, align=align_header)
                x_right = self.get_x()
            self.ln(cell_height) # move cursor back to the left margin
             # add line beneeth header
            y2 = self.get_y()
            self.line(x_left,y1,x_right,y1)
            self.line(x_left,y2,x_right,y2)

            # add data
            self.set_font(size=data_size)
            for row in data:
                if x_start: # not sure if I need this
                    self.set_x(x_start)

                cell_height = get_cell_height(row)

                for datum in row:
                    if datum in emphasize_data:
                        get_emphazised_datacell(datum, col_width, cell_height)
                    else:
                        get_regular_datacell(datum, col_width, cell_height)
                self.ln(cell_height) # move cursor back to the left margin

        else:
            if x_start:
                self.set_x(x_start)

            cell_height = get_cell_height(header)

            for i, datum in enumerate(header):
                get_regular_datacell(datum, col_width[i], cell_height, align=align_header)
                x_right = self.get_x()
            self.ln(cell_height) # move cursor back to the left margin
            # add line beneeth header
            y2 = self.get_y()
            self.line(x_left,y1,x_right,y1)
            self.line(x_left,y2,x_right,y2)

            # add data
            self.set_font(size=data_size)
            for i, row in enumerate(data):
                if x_start:
                    self.set_x(x_start)

                cell_height = get_cell_height(row)

                for i, datum in enumerate(row):
                    if not isinstance(datum, str):
                        datum = str(datum)
                    adjusted_col_width = col_width[i]
                    if datum in emphasize_data:
                        get_emphazised_datacell(datum, adjusted_col_width, cell_height)
                    else:
                        get_regular_datacell(datum, adjusted_col_width, cell_height)
                self.ln(cell_height) # move cursor back to the left margin
        y3 = self.get_y()
        self.line(x_left,y3,x_right,y3)

        # footer
        # add data
        self.set_font(style=footer_style, size=data_size)
        for row in footer:
            if isinstance(cell_width, list):
                if x_start:
                    self.set_x(x_start)

                cell_height = get_cell_height(row)

                for i, datum in enumerate(row):
                    if not isinstance(datum, str):
                        datum = str(datum)
                    adjusted_col_width = col_width[i]
                    if datum in emphasize_data:
                        get_emphazised_datacell(datum, adjusted_col_width, cell_height, align=align_footer, border=False)
                    else:
                        get_regular_datacell(datum, adjusted_col_width, cell_height, align=align_footer, border=False)
                self.ln(cell_height) # move cursor back to the left margin
            else:
                if x_start: # not sure if I need this
                    self.set_x(x_start)

                cell_height = get_cell_height(row)

                for datum in row:
                    if datum in emphasize_data:
                        get_emphazised_datacell(datum, col_width, cell_height, align=align_footer)
                    else:
                        get_regular_datacell(datum, col_width, cell_height, align=align_footer)
                self.ln(cell_height) # move cursor back to the left margin
        y4 = self.get_y()
        self.line(x_left,y4,x_right,y4)

I then use the class like this:

pdf = PdfTable()
pdf.create_table(data, border=True, align_data="L", data_size=9, emphasize_style="B", cell_width=[10, 15, 25, 25, 60, 20, 20], footer=self.footer)
Lucas-C commented 3 years ago

OK I see. Thanks for sharing your code.

@bvalgard code is well crafted, but it is not part of fpdf2. Hence we won't be able to help you much with it here...

You can try reaching him about this "vertical alignement" feature on bvalgard/create-pdf-with-python-fpdf2.

Also, I'd be open to introducing a FPDF.table method to fpdf2! The method could include this "vertical alignement" feature. PRs are welcome 😉

johann-su commented 3 years ago

@Lucas-C But isn't it a valid use case to have a cell with a specific height (in my case cell_height) and to then limit the line height with max_line_height? Since this is all default behavior of the library I thought the issue would make more sense here than in @bvalgard's repo?

pdf.multi_cell(col_width, cell_height, datum, border=1, align=align, ln=3, max_line_height=line_height)

I See what I can do regarding the pr :)

Lucas-C commented 3 years ago

Yes, this seems a valid use case, and max_line_height is already supported: https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.multi_cell

Do you mean that this parameter does not behave as it should?

johann-su commented 3 years ago

@Lucas-C Since it is possible to create cells that are bigger (vertically) than the text within them I think there should be an option to align the text to the top, bottom or center of the cell. I use it this functionality to create a table, but I guess there are other use cases where it would be helpful to be able to do this.

You could add a pdf.rect and place the text within the rectangle but that adds quite a lot of complexity to the code for a quite simple goal in my opinion.

Lucas-C commented 3 years ago

Thanks for your explanation. I think I understand precisely the feature you want and why. I agree that an option to align the text to the top, bottom or center of the cell would be great. Again, PRs are welcome to implement that!

Lucas-C commented 1 year ago

For reference, @RubendeBruin is working on an implementation to provide vertical alignement for table() cells in https://github.com/PyFPDF/fpdf2/pull/797

Reviews & comments on this PR are welcome!

RubendeBruin commented 1 year ago

@johann-su Vertical alignment functionality has been merged into master and will be included in the next release.