diff --git a/docs/Maths.md b/docs/Maths.md index aea65451c..5addb2566 100644 --- a/docs/Maths.md +++ b/docs/Maths.md @@ -109,7 +109,7 @@ Result: Create a table with pandas [DataFrame](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html): ```python -from fpdf import FPDF +from fpdf.adapters.table_pandas import FPDF_pandas import pandas as pd df = pd.DataFrame( @@ -121,25 +121,16 @@ df = pd.DataFrame( } ) -df = df.applymap(str) # Convert all data inside dataframe into string type - -columns = [list(df)] # Get list of dataframe columns -rows = df.values.tolist() # Get list of dataframe rows -data = columns + rows # Combine columns and rows in one list - -pdf = FPDF() +pdf = FPDF_pandas() pdf.add_page() pdf.set_font("Times", size=10) -with pdf.table(borders_layout="MINIMAL", - cell_fill_color=200, # grey - cell_fill_mode="ROWS", - line_height=pdf.font_size * 2.5, - text_align="CENTER", - width=160) as table: - for data_row in data: - row = table.row() - for datum in data_row: - row.cell(datum) +pdf.dataframe(df, + borders_layout="MINIMAL", + cell_fill_color=200, # grey + cell_fill_mode="ROWS", + line_height=pdf.font_size * 2.5, + text_align="CENTER", + width=160) pdf.output("table_from_pandas.pdf") ``` diff --git a/docs/Tables.md b/docs/Tables.md index 10464d212..7ea8bac0c 100644 --- a/docs/Tables.md +++ b/docs/Tables.md @@ -380,6 +380,8 @@ Result: ![](table_with_multiple_headings.png) +This also works with index columns. Pass any integer to the `num_index_columns` argument when calling `Table()` and that many columns will be formatted according to the `index_style` argument. + ## Table from pandas DataFrame _cf._ [Maths documentation page](Maths.md#using-pandas) diff --git a/fpdf/adapters/table_pandas.py b/fpdf/adapters/table_pandas.py new file mode 100644 index 000000000..aa84d5005 --- /dev/null +++ b/fpdf/adapters/table_pandas.py @@ -0,0 +1,38 @@ +from pandas import MultiIndex +from fpdf import FPDF + + +class FPDF_pandas(FPDF): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def dataframe(self, df, **kwargs): + with self.table( + num_index_columns=df.index.nlevels, + num_heading_rows=df.columns.nlevels, + **kwargs + ) as table: + TABLE_DATA = format_df(df) + for data_row in TABLE_DATA: + row = table.row() + for datum in data_row: + row.cell(datum) + + +def format_df(df, char: str = " ", convert_to_string: bool = True) -> list: + data = df.map(str).values.tolist() + if isinstance(df.columns, MultiIndex): + heading = [list(c) for c in zip(*df.columns)] + else: + heading = df.columns.values.reshape(1, len(df.columns)).tolist() + + if isinstance(df.index, MultiIndex): + index = [list(c) for c in df.index] + else: + index = df.index.values.reshape(len(df), 1).tolist() + padding = [list(char) * df.index.nlevels] * df.columns.nlevels + + output = [i + j for i, j in zip(padding + index, heading + data)] + if convert_to_string: + output = [[str(d) for d in row] for row in output] + return output \ No newline at end of file diff --git a/fpdf/table.py b/fpdf/table.py index 4d297f745..7843fb001 100644 --- a/fpdf/table.py +++ b/fpdf/table.py @@ -9,6 +9,7 @@ from .util import Padding DEFAULT_HEADINGS_STYLE = FontFace(emphasis="BOLD") +DEFAULT_INDEX_STYLE = FontFace(emphasis="BOLD") class Table: @@ -32,6 +33,7 @@ def __init__( gutter_height=0, gutter_width=0, headings_style=DEFAULT_HEADINGS_STYLE, + index_style=DEFAULT_INDEX_STYLE, line_height=None, markdown=False, text_align="JUSTIFY", @@ -40,6 +42,7 @@ def __init__( padding=None, outer_border_width=None, num_heading_rows=1, + num_index_columns=0 ): """ Args: @@ -58,6 +61,8 @@ def __init__( gutter_width (float): optional horizontal space between columns headings_style (fpdf.fonts.FontFace): optional, default to bold. Defines the visual style of the top headings row: size, color, emphasis... + index_style (fpdf.fonts.FontFace): optional, default to bold. + Defines the visual style of the top headings row: size, color, emphasis... line_height (number): optional. Defines how much vertical space a line of text will occupy markdown (bool): optional, default to False. Enable markdown interpretation of cells textual content text_align (str, fpdf.enums.Align, tuple): optional, default to JUSTIFY. Control text alignment inside cells. @@ -72,6 +77,7 @@ def __init__( num_heading_rows (number): optional. Sets the number of heading rows, default value is 1. If this value is not 1, first_row_as_headings needs to be True if num_heading_rows>1 and False if num_heading_rows=0. For backwards compatibility, first_row_as_headings is used in case num_heading_rows is 1. + num_index_cols (number): optional. Sets the number of index columns, default value is 0. """ self._fpdf = fpdf self._align = align @@ -85,12 +91,14 @@ def __init__( self._gutter_height = gutter_height self._gutter_width = gutter_width self._headings_style = headings_style + self._index_style = index_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._width = fpdf.epw if width is None else width self._wrapmode = wrapmode self._num_heading_rows = num_heading_rows + self.num_index_columns = num_index_columns self._initial_style = None self.rows = [] @@ -129,13 +137,16 @@ def __init__( self.row(row) def row(self, cells=(), style=None): - "Adds a row to the table. Yields a `Row` object." + "Adds a row to the table. Yields a `Row` object. Styles first `self.num_index_columns` cells with `self.index_style`" if self._initial_style is None: self._initial_style = self._fpdf.font_face() row = Row(self, style=style) self.rows.append(row) - for cell in cells: - row.cell(cell) + for n, cell in enumerate(cells): + if n < self.num_index_columns: + row.cell(cell, style=self._index_style) + else: + row.cell(cell) return row def render(self): diff --git a/test/table/table_pandas_multiheading.pdf b/test/table/table_pandas_multiheading.pdf new file mode 100644 index 000000000..d012c8845 Binary files /dev/null and b/test/table/table_pandas_multiheading.pdf differ diff --git a/test/table/table_pandas_multiindex.pdf b/test/table/table_pandas_multiindex.pdf new file mode 100644 index 000000000..431cb6451 Binary files /dev/null and b/test/table/table_pandas_multiindex.pdf differ diff --git a/test/table/test_table.py b/test/table/test_table.py index ce72376f1..2a5a87590 100644 --- a/test/table/test_table.py +++ b/test/table/test_table.py @@ -1,9 +1,11 @@ import logging from pathlib import Path +import pandas as pd import pytest from fpdf import FPDF, FPDFException +from fpdf.adapters.table_pandas import FPDF_pandas from fpdf.drawing import DeviceRGB from fpdf.fonts import FontFace from test.conftest import assert_pdf_equal, LOREM_IPSUM @@ -37,6 +39,13 @@ ("3", "4", "5", "6", "7", "8"), ) +MULTI_LABEL_TABLE_DATA = { + ("tall", "fat"): {"color": "red", "number": 7, "happy": False}, + ("short", "fat"): {"color": "green", "number": 8, "happy": True}, + ("tall", "lean"): {"color": "blue", "number": 9, "happy": True}, + ("short", "lean"): {"color": "yellow", "number": 15, "happy": False}, +} + def test_table_simple(tmp_path): pdf = FPDF() @@ -86,6 +95,17 @@ def test_table_with_syntactic_sugar(tmp_path): table.row(TABLE_DATA[4]) assert_pdf_equal(pdf, HERE / "table_simple.pdf", tmp_path) +def test_pandas_multi_label(tmp_path): + for df, i in zip( + [pd.DataFrame(MULTI_LABEL_TABLE_DATA), pd.DataFrame(MULTI_LABEL_TABLE_DATA).T], + ["heading", "index"], + ): + pdf = FPDF_pandas() + pdf.add_page() + pdf.set_font("Times", size=10) + pdf.dataframe(df, borders_layout="MINIMAL", text_align="CENTER", width=160) + assert_pdf_equal(pdf, HERE / f"table_pandas_multi{i}.pdf", tmp_path) + def test_table_with_fixed_col_width(tmp_path): pdf = FPDF()