From 579b9a17773fd29e10cb30894c1555871ab99cdb Mon Sep 17 00:00:00 2001 From: Andy Friedman Date: Tue, 5 Dec 2023 14:25:39 -0500 Subject: [PATCH 1/2] reupping tables updates --- Makefile | 48 +++++++++++++++++++++++ docs/Maths.md | 27 +++++-------- docs/Tables.md | 2 + fpdf/adapters/table_pandas.py | 38 ++++++++++++++++++ fpdf/table.py | 17 ++++++-- test/table/table_pandas_multiheading.pdf | Bin 0 -> 1612 bytes test/table/table_pandas_multiindex.pdf | Bin 0 -> 1553 bytes test/table/test_table.py | 20 ++++++++++ 8 files changed, 131 insertions(+), 21 deletions(-) create mode 100644 Makefile create mode 100644 fpdf/adapters/table_pandas.py create mode 100644 test/table/table_pandas_multiheading.pdf create mode 100644 test/table/table_pandas_multiindex.pdf diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..d7b98eca6 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +VENV_NAME = venv +VENV_PATH = $(VENV_NAME)/bin/activate +SRC_DIR = fpdf +PYTHON := venv/bin/python + +.PHONY: venv + +venv: +ifeq ($(OS),Windows_NT) + python -m venv $(VENV_NAME) + . $(VENV_PATH) && pip install -r test/requirements.txt +else + python3 -m venv $(VENV_NAME) + . $(VENV_PATH); pip install -r test/requirements.txt +endif + +.PHONY: test + +test: + @export FLASK_ENV=test && python -m pytest test/ + +.PHONY: install + +install: venv + . $(VENV_PATH); pip install --upgrade -r test/requirements.txt + +.PHONY: clean + +clean: + rm -rf $(VENV_NAME) + +check-autopep: + ${PYTHON} -m autopep8 $(SRC_DIR)/*.py test/*.py --in-place + +check-isort: + ${PYTHON} -m isort --check-only $(SRC_DIR) test + +check-flake: + ${PYTHON} -m flake8 $(SRC_DIR) test + +check-mypy: + ${PYTHON} -m mypy --strict --implicit-reexport $(SRC_DIR) + +lint: check-flake check-mypy check-autopep check-isort + +format: + ${PYTHON} -m autopep8 $(SRC_DIR)/*.py test/*.py --in-place + ${PYTHON} -m isort $(SRC_DIR) test \ No newline at end of file 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 0000000000000000000000000000000000000000..d012c8845388cfebef5653ab43db752e10582162 GIT binary patch literal 1612 zcmbtUdr(wW7*D9Ft^ux~rXzKTkch{9tatY=t=K+s1!G%qoLGsuzyVj5dtvY0aY1<) z>M#xjYM4F7UW5`QVL>?rI+KmiYNi?GprTT<2q{s3ii6X+JYpMW>W|Jp=ic9UzTfwl z@Ark7^~Pv2Mvjqy0DQ(~Os&Rn9q;5sKw@};ElU7aIWk$&Fnk@%vawp;1*`-@XcZkp zF@RY~#xN>mO)1QW05`KaP{88iFvw*|bd(ouQa)n=1 z#DcGeHuN9ZxMJBV_4(S5@;mAK^8ynOzs5%R8isFQgnMd?boVXUxr5xOm|Hd8^21eA z`^_W9(Y|GiHh-U5wPnEbWvJiwAjAAC4G-`1eI?+=kYOS_e5s5Gc_DcnJ(|CxAMC|a zpDgJ6WW2tkak$r0daN=g;e)STzAN^x9o$#hA6X^Kh^p;KbRP|AO}Mb8`Mc#2So5gK zD1P%MQTD}$KYQ*?j30fk)qP=8LATEhOJ4C_Sz3z%FvZ(j(%v`jR{AerobgZu2P*C_ z?2BvEe7x*}es5^Uw!xO%w&#Lk240OS54ETp9*v6c9D2UI#-~wcetpZaw^}z2MP922 zO5f~rM)rsG9H{Hj?mV5o?zPiJOi#Od3wkw&^_u@`!D%4%x^fqqBqAKZMB|Ruc3ax@N;^)qMJ^8XIWQ2@H~wSIt%~0W>Qv;ygn3V`eLANqU-v+h zL3!WzbjcCBq?zeSnpO|(HHKPNfJPn-GM-|~gF>{0&ts813>+Fr8etzFby6PL{csXfxS9$3{n zhSX?iLSvu^g@Q2X7=u>F$mNWdkrP^)QO5oEi^HDUpWF|W0 zS13dq07XnKP4$c{6f8^(^(-ubvLTfPsS5f5iRr1uTy}O`sd*_NmCPSDKRpGytU4C$TcWv_wJQKQGleKc_S|4Y$V8XB=bO8VdlAI=9Hus z0o|IDSd!|Jnw+1K%BAmTFFFI@PkM;&+zxU#_6FKXTr>DVA$!8!Y2P zlR{#9@4cII&4KNM+XVkz#|!5z&WTr^*12lyd%a`j$3-osC)@NL_qceyLv>-+(%nn# z16f$5in~&)Iz(!FXV`|WzNPzl<#f4&;vyoFzqrJ|p10!HC~m!7SU0!g)rp|xn=JUY zh&{Q~bA9>YqCFLEUM!KTwp``D{>rt~>63J1+R|gs!?}IeeN3OZ%=Y|VE9J^Q9no7} zU)MaUTy^&OTJ8s2``e%9iq1?ddwgx(?Pu%4-Bs#&KkpXdl$&$L^;zbf&nH$XPukmd zvo|$`G3BG$_s=(b-gP|Lkl6P)EGd2B0pV;rdEw1xkBDr(oufN5Pd~EM?Nhkn{PJp1|KobzfvPc`kdU7F6u2?o;xPXuT4b=-H``#1LwyYe^3 zRiK0qO%Bj3V2YHMoDz#u-SUB{Q9mRzH?>$dC_gt5n8sc6fT=1oFI_=DJTuQRuQ(GX zz@;Btnp6VLS)lv{%#x671~LOFxtigz!YMx|g>oA}nFXF9KpwEM(RVXc0Qn7=E!~V1 zKu!n(W(EC#qWt9G)DnegePGc7^k-@X(6^qs!2IO|rJbR)XDpHzERZa42IeMEG2sHt za0(hOR)&^lmWD>ghK7~~KoDhUscvAPu8E|%q9`?u%Rs@B3nUK#3TCFJ#-<8s3J5Vv z1+bJt9zx6#=xh)`5;L^KRA*$4DP~~?3==fHmSz~{85)=Y3lmgzhK6Rq@(d>lz`*s)OUqXP1rLbrq7ZFkX<=+(VQ%bZZe(U=WN2w& sWa#2*YGP>SZft34M_2_ohHz{08-Hw8UO$Q literal 0 HcmV?d00001 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() From 3669ebe6011fdb7ca1f3451f0d1e0edcfcf79483 Mon Sep 17 00:00:00 2001 From: Andy Friedman Date: Tue, 5 Dec 2023 14:26:16 -0500 Subject: [PATCH 2/2] undoing makefile --- Makefile | 48 ------------------------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 Makefile diff --git a/Makefile b/Makefile deleted file mode 100644 index d7b98eca6..000000000 --- a/Makefile +++ /dev/null @@ -1,48 +0,0 @@ -VENV_NAME = venv -VENV_PATH = $(VENV_NAME)/bin/activate -SRC_DIR = fpdf -PYTHON := venv/bin/python - -.PHONY: venv - -venv: -ifeq ($(OS),Windows_NT) - python -m venv $(VENV_NAME) - . $(VENV_PATH) && pip install -r test/requirements.txt -else - python3 -m venv $(VENV_NAME) - . $(VENV_PATH); pip install -r test/requirements.txt -endif - -.PHONY: test - -test: - @export FLASK_ENV=test && python -m pytest test/ - -.PHONY: install - -install: venv - . $(VENV_PATH); pip install --upgrade -r test/requirements.txt - -.PHONY: clean - -clean: - rm -rf $(VENV_NAME) - -check-autopep: - ${PYTHON} -m autopep8 $(SRC_DIR)/*.py test/*.py --in-place - -check-isort: - ${PYTHON} -m isort --check-only $(SRC_DIR) test - -check-flake: - ${PYTHON} -m flake8 $(SRC_DIR) test - -check-mypy: - ${PYTHON} -m mypy --strict --implicit-reexport $(SRC_DIR) - -lint: check-flake check-mypy check-autopep check-isort - -format: - ${PYTHON} -m autopep8 $(SRC_DIR)/*.py test/*.py --in-place - ${PYTHON} -m isort $(SRC_DIR) test \ No newline at end of file