diff --git a/docs/Images.md b/docs/Images.md index ea9050650..06bfb140d 100644 --- a/docs/Images.md +++ b/docs/Images.md @@ -56,7 +56,7 @@ When you want to scale an image to fill a rectangle, while keeping its aspect ra and ensuring it does **not** overflow the rectangle width nor height in the process, you can set `w` / `h` and also provide `keep_aspect_ratio=True` to the [`image()`](fpdf/fpdf.html#fpdf.fpdf.FPDF.image) method. -The following unit test illustrates that: +The following unit tests illustrate that: * [test_image_fit.py](https://github.com/PyFPDF/fpdf2/blob/master/test/image/test_image_fit.py) * resulting document: [image_fit_in_rect.pdf](https://github.com/PyFPDF/fpdf2/blob/master/test/image/image_fit_in_rect.pdf) diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index d1c9e3066..7e4764253 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -84,6 +84,7 @@ class Image: from .sign import Signature from .svg import Percent, SVGObject from .syntax import DestinationXYZ +from .table import Table from .util import ( escape_parens, format_date, @@ -4532,6 +4533,10 @@ def _apply_style(self, title_style): self.text_color = prev_text_color self.underline = prev_underline + @check_page + def table(self, *args, **kwargs): + return Table(self, *args, **kwargs) + def output( self, name="", dest="", linearize=False, output_producer_class=OutputProducer ): diff --git a/fpdf/table.py b/fpdf/table.py new file mode 100644 index 000000000..374b523ae --- /dev/null +++ b/fpdf/table.py @@ -0,0 +1,88 @@ +from numbers import Number + + +class Table: + def __init__(self, fpdf, line_height=None, width=None): + self.fpdf = fpdf + self.line_height = line_height or 2 * self.fpdf.font_size + self.width = width or fpdf.epw + self.col_widths = None + self.rows = [] + + def __enter__(self): + return self + + def row(self): + row = Row(self) + self.rows.append(row) + return row + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + for i, row in enumerate(self.rows): + lines_count_per_cell = self._get_lines_count_per_cell(i) + row_height = max(lines_count_per_cell) * self.line_height + for j in range(len(row.cells)): + cell_line_height = row_height / lines_count_per_cell[j] + self._render_table_cell( + i, j, h=row_height, max_line_height=cell_line_height + ) + self.fpdf.ln(row_height) + + def _render_table_cell(self, i, j, h, **kwargs): + row = self.rows[i] + col_width = self._get_col_width(i, j) + return self.fpdf.multi_cell( + w=col_width, + h=h, + txt=row.cells[j], + border=1, + new_x="RIGHT", + new_y="TOP", + **kwargs, + ) + + def _get_col_width(self, i, j): + if not self.col_widths: + cols_count = len(self.rows[i].cells) + return self.width / cols_count + if isinstance(self.col_widths, Number): + return 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 + + def _get_lines_count_per_cell(self, i): + row = self.rows[i] + lines_count = [] + for j in range(len(row.cells)): + lines_count.append( + len( + self._render_table_cell( + i, + j, + h=self.line_height, + max_line_height=self.line_height, + split_only=True, + ) + ) + ) + return lines_count + + +class Row: + def __init__(self, table): + self.table = table + self.cells = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + pass + + def cell(self, text): + self.cells.append(text) diff --git a/test/table/table_simple.pdf b/test/table/table_simple.pdf new file mode 100644 index 000000000..174442a4f Binary files /dev/null and b/test/table/table_simple.pdf differ diff --git a/test/table/table_with_fixed_col_width.pdf b/test/table/table_with_fixed_col_width.pdf new file mode 100644 index 000000000..5d4ac8ad9 Binary files /dev/null and b/test/table/table_with_fixed_col_width.pdf differ diff --git a/test/table/table_with_fixed_row_height.pdf b/test/table/table_with_fixed_row_height.pdf new file mode 100644 index 000000000..81b59a94d Binary files /dev/null and b/test/table/table_with_fixed_row_height.pdf differ diff --git a/test/table/table_with_fixed_width.pdf b/test/table/table_with_fixed_width.pdf new file mode 100644 index 000000000..1176eee7b Binary files /dev/null and b/test/table/table_with_fixed_width.pdf differ diff --git a/test/table/table_with_multiline_cells.pdf b/test/table/table_with_multiline_cells.pdf new file mode 100644 index 000000000..3cc55e00a Binary files /dev/null and b/test/table/table_with_multiline_cells.pdf differ diff --git a/test/table/table_with_multiline_cells_and_fixed_row_height.pdf b/test/table/table_with_multiline_cells_and_fixed_row_height.pdf new file mode 100644 index 000000000..4ba56376b Binary files /dev/null and b/test/table/table_with_multiline_cells_and_fixed_row_height.pdf differ diff --git a/test/table/table_with_varying_col_widths.pdf b/test/table/table_with_varying_col_widths.pdf new file mode 100644 index 000000000..ded59f62c Binary files /dev/null and b/test/table/table_with_varying_col_widths.pdf differ diff --git a/test/table/test_table.py b/test/table/test_table.py new file mode 100755 index 000000000..6ff0ac9de --- /dev/null +++ b/test/table/test_table.py @@ -0,0 +1,128 @@ +from pathlib import Path + +import pytest + +from fpdf import FPDF +from test.conftest import assert_pdf_equal, LOREM_IPSUM + + +HERE = Path(__file__).resolve().parent +FONTS_DIR = HERE.parent / "fonts" + +TABLE_DATA = ( + ("First name", "Last name", "Age", "City"), + ("Jules", "Smith", "34", "San Juan"), + ("Mary", "Ramos", "45", "Orlando"), + ("Carlson", "Banks", "19", "Los Angeles"), + ("Lucas", "Cimon", "31", "Angers"), +) +MULTILINE_TABLE_DATA = ( + ("Extract", "Text length"), + (LOREM_IPSUM[:200], str(len(LOREM_IPSUM[:200]))), + (LOREM_IPSUM[200:400], str(len(LOREM_IPSUM[200:400]))), + (LOREM_IPSUM[400:600], str(len(LOREM_IPSUM[400:600]))), + (LOREM_IPSUM[600:800], str(len(LOREM_IPSUM[600:800]))), + (LOREM_IPSUM[800:1000], str(len(LOREM_IPSUM[800:1000]))), + (LOREM_IPSUM[1000:1200], str(len(LOREM_IPSUM[1000:1200]))), +) + + +def test_table_simple(tmp_path): + pdf = FPDF() + pdf.add_page() + pdf.set_font("Times", size=16) + with pdf.table() as table: + for data_row in TABLE_DATA: + with table.row() as row: + for datum in data_row: + row.cell(datum) + assert_pdf_equal(pdf, HERE / "table_simple.pdf", tmp_path) + + +def test_table_with_fixed_col_width(tmp_path): + pdf = FPDF() + pdf.add_page() + pdf.set_font("Times", size=16) + with pdf.table() as table: + table.col_widths = pdf.epw / 5 + for data_row in TABLE_DATA: + with table.row() as row: + for datum in data_row: + row.cell(datum) + assert_pdf_equal(pdf, HERE / "table_with_fixed_col_width.pdf", tmp_path) + + +def test_table_with_varying_col_widths(tmp_path): + pdf = FPDF() + pdf.add_page() + pdf.set_font("Times", size=16) + with pdf.table() as table: + table.col_widths = (30, 30, 10, 30) + for data_row in TABLE_DATA: + with table.row() as row: + for datum in data_row: + row.cell(datum) + assert_pdf_equal(pdf, HERE / "table_with_varying_col_widths.pdf", tmp_path) + + +def test_table_with_invalid_col_widths(): + pdf = FPDF() + pdf.add_page() + pdf.set_font("Times", size=16) + with pytest.raises(ValueError): + with pdf.table() as table: + table.col_widths = (20, 30, 50) + for data_row in TABLE_DATA: + with table.row() as row: + for datum in data_row: + row.cell(datum) + + +def test_table_with_fixed_row_height(tmp_path): + pdf = FPDF() + pdf.add_page() + pdf.set_font("Times", size=16) + with pdf.table(line_height=2.5 * pdf.font_size) as table: + for data_row in TABLE_DATA: + with table.row() as row: + for datum in data_row: + row.cell(datum) + assert_pdf_equal(pdf, HERE / "table_with_fixed_row_height.pdf", tmp_path) + + +def test_table_with_multiline_cells(tmp_path): + pdf = FPDF() + pdf.add_page() + pdf.set_font("Times", size=16) + with pdf.table() as table: + for data_row in MULTILINE_TABLE_DATA: + with table.row() as row: + for datum in data_row: + row.cell(datum) + assert_pdf_equal(pdf, HERE / "table_with_multiline_cells.pdf", tmp_path) + + +def test_table_with_multiline_cells_and_fixed_row_height(tmp_path): + pdf = FPDF() + pdf.add_page() + pdf.set_font("Times", size=16) + with pdf.table(line_height=2.5 * pdf.font_size) as table: + for data_row in MULTILINE_TABLE_DATA: + with table.row() as row: + for datum in data_row: + row.cell(datum) + assert_pdf_equal( + pdf, HERE / "table_with_multiline_cells_and_fixed_row_height.pdf", tmp_path + ) + + +def test_table_with_fixed_width(tmp_path): + pdf = FPDF() + pdf.add_page() + pdf.set_font("Times", size=16) + with pdf.table(width=150) as table: + for data_row in TABLE_DATA: + with table.row() as row: + for datum in data_row: + row.cell(datum) + assert_pdf_equal(pdf, HERE / "table_with_fixed_width.pdf", tmp_path) diff --git a/test/text/test_cell.py b/test/text/test_cell.py index c518c2736..edf4d687a 100644 --- a/test/text/test_cell.py +++ b/test/text/test_cell.py @@ -5,11 +5,12 @@ from fpdf import FPDF, FPDFException from test.conftest import assert_pdf_equal, LOREM_IPSUM -TEXT_SIZE, SPACING = 36, 1.15 -LINE_HEIGHT = TEXT_SIZE * SPACING - HERE = Path(__file__).resolve().parent +FONTS_DIR = HERE.parent / "fonts" + +TEXT_SIZE, SPACING = 36, 1.15 +LINE_HEIGHT = TEXT_SIZE * SPACING TABLE_DATA = ( ("First name", "Last name", "Age", "City"), @@ -155,9 +156,9 @@ def test_cell_markdown(tmp_path): def test_cell_markdown_with_ttf_fonts(tmp_path): pdf = FPDF() pdf.add_page() - pdf.add_font("Roboto", "", HERE / "../fonts/Roboto-Regular.ttf") - pdf.add_font("Roboto", "B", HERE / "../fonts/Roboto-Bold.ttf") - pdf.add_font("Roboto", "I", HERE / "../fonts/Roboto-Italic.ttf") + pdf.add_font("Roboto", "", FONTS_DIR / "Roboto-Regular.ttf") + pdf.add_font("Roboto", "B", FONTS_DIR / "Roboto-Bold.ttf") + pdf.add_font("Roboto", "I", FONTS_DIR / "Roboto-Italic.ttf") pdf.set_font("Roboto", size=60) pdf.cell(txt="**Lorem** __Ipsum__ --dolor--", markdown=True) assert_pdf_equal(pdf, HERE / "cell_markdown_with_ttf_fonts.pdf", tmp_path) @@ -166,7 +167,7 @@ def test_cell_markdown_with_ttf_fonts(tmp_path): def test_cell_markdown_missing_ttf_font(): pdf = FPDF() pdf.add_page() - pdf.add_font(fname=HERE / "../fonts/Roboto-Regular.ttf") + pdf.add_font(fname=FONTS_DIR / "Roboto-Regular.ttf") pdf.set_font("Roboto-Regular", size=60) with pytest.raises(FPDFException) as error: pdf.cell(txt="**Lorem Ipsum**", markdown=True) @@ -190,8 +191,8 @@ def test_cell_markdown_bleeding(tmp_path): # issue 241 def test_cell_markdown_right_aligned(tmp_path): # issue 333 pdf = FPDF() pdf.add_page() - pdf.add_font("Roboto", fname=HERE / "../fonts/Roboto-Regular.ttf") - pdf.add_font("Roboto", style="B", fname=HERE / "../fonts/Roboto-Bold.ttf") + pdf.add_font("Roboto", fname=FONTS_DIR / "Roboto-Regular.ttf") + pdf.add_font("Roboto", style="B", fname=FONTS_DIR / "Roboto-Bold.ttf") pdf.set_font("Roboto", size=60) pdf.cell( 0, @@ -246,8 +247,8 @@ def test_cell_newpos_badinput(): def test_cell_curfont_leak(tmp_path): # issue #475 pdf = FPDF() pdf.add_page() - pdf.add_font("Roboto", fname=HERE / "../fonts/Roboto-Regular.ttf") - pdf.add_font("Roboto", style="B", fname=HERE / "../fonts/Roboto-Bold.ttf") + pdf.add_font("Roboto", fname=FONTS_DIR / "Roboto-Regular.ttf") + pdf.add_font("Roboto", style="B", fname=FONTS_DIR / "Roboto-Bold.ttf") with pdf.local_context(): pdf.set_font("Roboto", "B", 10) pdf.cell(txt="ABCDEFGH", new_x="LEFT", new_y="NEXT") diff --git a/test/text/test_multi_cell.py b/test/text/test_multi_cell.py index a513b6735..464b99975 100644 --- a/test/text/test_multi_cell.py +++ b/test/text/test_multi_cell.py @@ -232,7 +232,7 @@ def test_multi_cell_table_with_max_line_height(tmp_path): # issue 589 def test_multi_cell_justified_with_unicode_font(tmp_path): # issue 118 pdf = FPDF() pdf.add_page() - pdf.add_font(fname=HERE / "../fonts/DejaVuSans.ttf") + pdf.add_font(fname=FONTS_DIR / "DejaVuSans.ttf") pdf.set_font("DejaVuSans", size=14) text = 'Justified line containing "()" that is long enough to trigger wrapping and a line jump' pdf.multi_cell(w=0, h=8, txt=text, new_x="LMARGIN", new_y="NEXT") @@ -280,7 +280,7 @@ def test_multicell_newpos_badinput(): def test_multi_cell_j_paragraphs(tmp_path): # issue 364 pdf = FPDF(format="A5") pdf.add_page() - pdf.add_font(fname=HERE / "../fonts/DejaVuSans.ttf") + pdf.add_font(fname=FONTS_DIR / "DejaVuSans.ttf") pdf.set_font("DejaVuSans", size=14) pdf.set_margins(34, 55, 34) pdf.set_auto_page_break(auto=True, margin=55) diff --git a/test/text/test_multi_cell_markdown.py b/test/text/test_multi_cell_markdown.py index b96bd85b1..5044a7705 100644 --- a/test/text/test_multi_cell_markdown.py +++ b/test/text/test_multi_cell_markdown.py @@ -6,6 +6,7 @@ import pytest HERE = Path(__file__).resolve().parent +FONTS_DIR = HERE.parent / "fonts" def test_multi_cell_markdown(tmp_path): @@ -27,9 +28,9 @@ def test_multi_cell_markdown(tmp_path): def test_multi_cell_markdown_with_ttf_fonts(tmp_path): pdf = fpdf.FPDF() pdf.add_page() - pdf.add_font("Roboto", "", HERE / "../fonts/Roboto-Regular.ttf") - pdf.add_font("Roboto", "B", HERE / "../fonts/Roboto-Bold.ttf") - pdf.add_font("Roboto", "I", HERE / "../fonts/Roboto-Italic.ttf") + pdf.add_font("Roboto", "", FONTS_DIR / "Roboto-Regular.ttf") + pdf.add_font("Roboto", "B", FONTS_DIR / "Roboto-Bold.ttf") + pdf.add_font("Roboto", "I", FONTS_DIR / "Roboto-Italic.ttf") pdf.set_font("Roboto", size=32) text = ( # Some text where styling occur over line breaks: "Lorem ipsum dolor, **consectetur adipiscing** elit," @@ -46,7 +47,7 @@ def test_multi_cell_markdown_with_ttf_fonts(tmp_path): def test_multi_cell_markdown_missing_ttf_font(): pdf = fpdf.FPDF() pdf.add_page() - pdf.add_font(fname=HERE / "../fonts/Roboto-Regular.ttf") + pdf.add_font(fname=FONTS_DIR / "Roboto-Regular.ttf") pdf.set_font("Roboto-Regular", size=60) with pytest.raises(fpdf.FPDFException) as error: pdf.multi_cell(w=pdf.epw, txt="**Lorem Ipsum**", markdown=True)