Skip to content

Commit

Permalink
Implement FPDF.table() - close #701
Browse files Browse the repository at this point in the history
  • Loading branch information
Lucas-C committed Feb 20, 2023
1 parent 0f007cf commit 70d192b
Show file tree
Hide file tree
Showing 14 changed files with 241 additions and 18 deletions.
2 changes: 1 addition & 1 deletion docs/Images.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
):
Expand Down
88 changes: 88 additions & 0 deletions fpdf/table.py
Original file line number Diff line number Diff line change
@@ -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)
Binary file added test/table/table_simple.pdf
Binary file not shown.
Binary file added test/table/table_with_fixed_col_width.pdf
Binary file not shown.
Binary file added test/table/table_with_fixed_row_height.pdf
Binary file not shown.
Binary file added test/table/table_with_fixed_width.pdf
Binary file not shown.
Binary file added test/table/table_with_multiline_cells.pdf
Binary file not shown.
Binary file not shown.
Binary file added test/table/table_with_varying_col_widths.pdf
Binary file not shown.
128 changes: 128 additions & 0 deletions test/table/test_table.py
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 12 additions & 11 deletions test/text/test_cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions test/text/test_multi_cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions test/text/test_multi_cell_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest

HERE = Path(__file__).resolve().parent
FONTS_DIR = HERE.parent / "fonts"


def test_multi_cell_markdown(tmp_path):
Expand All @@ -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,"
Expand All @@ -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)
Expand Down

0 comments on commit 70d192b

Please sign in to comment.