From bb3aaa1b99daa16ca969826a2cdad81e0c900513 Mon Sep 17 00:00:00 2001
From: Lucas Cimon <925560+Lucas-C@users.noreply.github.com>
Date: Fri, 17 Jan 2025 15:32:41 +0100
Subject: [PATCH] Min row height in tables + docs on fpdf2 internals (#1346)
---
CHANGELOG.md | 8 +-
docs/Internals.md | 97 +++++++++++
docs/Signing.md | 3 +
docs/Tables.md | 25 ++-
docs/TextRegion.md | 61 +++----
docs/index.md | 2 +-
fpdf/fpdf.py | 2 +-
fpdf/graphics_state.py | 20 ++-
fpdf/output.py | 2 +
fpdf/syntax.py | 9 +-
fpdf/table.py | 42 +++--
mkdocs.yml | 7 +-
...borders.pdf => table_draw_box_borders.pdf} | Bin
test/table/table_min_row_height.pdf | Bin 0 -> 1178 bytes
test/table/table_valign_per_row.pdf | Bin 0 -> 1296 bytes
test/table/test_table.py | 10 ++
test/table/test_table_padding.py | 155 +++++-------------
.../multi_cell_return_value.pdf} | Bin
.../multi_cell_with_padding.pdf} | Bin
test/text/test_multi_cell.py | 95 +++++++++++
tutorial/graphics_state.py | 13 ++
21 files changed, 366 insertions(+), 185 deletions(-)
create mode 100644 docs/Internals.md
rename test/table/{draw_box_borders.pdf => table_draw_box_borders.pdf} (100%)
create mode 100644 test/table/table_min_row_height.pdf
create mode 100644 test/table/table_valign_per_row.pdf
rename test/{table/table_with_padding.pdf => text/multi_cell_return_value.pdf} (100%)
rename test/{table/multicell_with_padding.pdf => text/multi_cell_with_padding.pdf} (100%)
create mode 100644 tutorial/graphics_state.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbeb87044..28adfc535 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,8 +19,12 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
## [2.8.3] - Not released yet
### Added
* support for [shading patterns (gradients)](https://py-pdf.github.io/fpdf2/Patterns.html)
-* support for strikethrough text
-* Improved SVG image speed by 50% to 70% - thanks to @petri-lipponen-movesense - [PR #1350](https://github.com/py-pdf/fpdf2/pull/1350)
+* improved SVG image speed by 50% to 70% - thanks to @petri-lipponen-movesense - [PR #1350](https://github.com/py-pdf/fpdf2/pull/1350)
+* support for strikethrough text
+* support for [setting a minimal row height in tables](https://py-pdf.github.io/fpdf2/Tables.html#setting-row-height)
+* support for [`v_align` at the row level in tables](https://py-pdf.github.io/fpdf2/Tables.html#setting-vertical-alignment-of-text-in-cells)
+* documentation on [verifying provenance of `fpdf2` releases](https://py-pdf.github.io/fpdf2/#verifying-provenance)
+* documentation on [`fpdf2` internals](https://py-pdf.github.io/fpdf2/Internals.html)
### Fixed
* [`FPDF.write_html()`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.write_html): Fixed rendering of content following `` tags; now correctly resets emphasis style post `` tag: hyperlink styling contained within the tag authority. - [Issue #1311](https://github.com/py-pdf/fpdf2/issues/1311)
diff --git a/docs/Internals.md b/docs/Internals.md
new file mode 100644
index 000000000..bd5d46d33
--- /dev/null
+++ b/docs/Internals.md
@@ -0,0 +1,97 @@
+# fpdf2 internals
+
+## FPDF.pages
+`FPDF` is designed to add content progressively to the document generated, page by page.
+
+Each page is an entry in the `.pages` attribute of `FPDF` instances.
+Indices start at 1 (the first page) and values are [`PDFPage`](https://py-pdf.github.io/fpdf2/fpdf/output.html#fpdf.output.PDFPage) instances.
+
+`PDFPage` instances have a `.contents` attribute that is a [`bytearray`](https://docs.python.org/3/library/stdtypes.html#bytearray) and contains the **Content Stream** for this page
+(`bytearray` makes things [a lot faster](https://github.com/reingart/pyfpdf/pull/164)).
+
+Going back to a previously generated page to add content is possible,
+using the [`.page` attribute](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.page), but may result in unexpected behavior, because [.add_page()](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.add_page) takes special care to ensure the page's content stream matches `FPDF`'s instance attributes.
+
+
+## syntax.py & objects serialization
+The [syntax.py](https://github.com/py-pdf/fpdf2/blob/master/fpdf/syntax.py) package contains classes representing core elements of the PDF syntax.
+
+Classes inherit from the [PDFObject](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.syntax.PDFObject) class, that has the following properties:
+
+* every PDF object has an `.id`, that is assigned during the document serialization by the [OutputProducer](#outputproducer)
+* the `.serialize()` method renders the PDF object as an `obj<>>endobj` text block. It can be overridden by child classes.
+* the `.content_stream()` method must return non empty bytes if the PDF Object has a _content stream_
+
+Other notable core classes are:
+
+* [Name](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.syntax.Name)
+* [Raw](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.syntax.Raw)
+* [PDFString](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.syntax.PDFString)
+* [PDFArray](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.syntax.PDFArray)
+* [PDFDate](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.syntax.PDFDate)
+
+
+## GraphicsStateMixin
+This _mixin_ class, inherited by the `FPDF` class,
+allows to manage a stack of graphics state variables:
+
+* docstring: [fpdf.graphics_state.GraphicsStateMixin](https://py-pdf.github.io/fpdf2/fpdf/graphics_state.html#fpdf.graphics_state.GraphicsStateMixin)
+* source file: [graphics_state.py](https://github.com/py-pdf/fpdf2/blob/master/fpdf/graphics_state.py)
+
+The main methods of this API are:
+
+* [_push_local_stack()](https://py-pdf.github.io/fpdf2/fpdf/graphics_state.html#fpdf.graphics_state.GraphicsStateMixin._push_local_stack): Push a graphics state on the stack
+* [_pop_local_stack()](https://py-pdf.github.io/fpdf2/fpdf/graphics_state.html#fpdf.graphics_state.GraphicsStateMixin._pop_local_stack): Pop the last graphics state on the stack
+* [_get_current_graphics_state()](https://py-pdf.github.io/fpdf2/fpdf/graphics_state.html#fpdf.graphics_state.GraphicsStateMixin._get_current_graphics_state): Retrieve the current graphics state
+* [_is_current_graphics_state_nested()](https://py-pdf.github.io/fpdf2/fpdf/graphics_state.html#fpdf.graphics_state.GraphicsStateMixin._is_current_graphics_state_nested): Indicate if the stack contains items (else it is empty)
+
+Thanks to this _mixin_, we can use the following semantics:
+```python
+{% include "../tutorial/graphics_state.py" %}
+```
+
+The graphics states used in the code above
+can be depicted by this diagram:
+
+``` mermaid
+stateDiagram-v2
+ direction LR
+ state gs0 {
+ initial1 : Base state
+ }
+ state gs1 {
+ initial2 : Base state
+ font_size_pt2 : font_size_pt=16
+ underline2 : underline=True
+ font_size_pt2 --> initial2
+ underline2 --> font_size_pt2
+ }
+ gs0 --> gs1: Step 1
+ state "gs0" as stack2 {
+ initial3 : Base state
+ }
+ gs1 --> stack2: Step 2
+```
+
+
+## OutputProducer
+In `fpdf2`, the `FPDF` class is used to store the document **definition**,
+its state as it is progressively built. Most attributes and internal data is **mutable**.
+
+Once it's done, when the [FPDF.output()](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.output) method is called, the actual PDF file creation is delegated to the [OutputProducer](https://py-pdf.github.io/fpdf2/fpdf/output.html#fpdf.output.OutputProducer) class.
+
+It performs the serialization of the PDF document,
+including the generation of the [cross-reference table & file trailer](https://py-pdf.github.io/fpdf2/fpdf/output.html#fpdf.output.PDFXrefAndTrailer).
+This class uses the `FPDF` instance as **immutable input**:
+it does not perform any modification on it.
+
+
diff --git a/docs/Signing.md b/docs/Signing.md
index 160104e37..803cf4dcb 100644
--- a/docs/Signing.md
+++ b/docs/Signing.md
@@ -25,3 +25,6 @@ allows to add a signature based on arbitrary key & certificates, not necessarily
[examples/pdf-verify.py](https://github.com/m32/endesive/blob/master/examples/pdf-verify.py)
or the [`check_signature()`](https://github.com/py-pdf/fpdf2/blob/master/test/conftest.py#L111) function
used in `fpdf2` unit tests can be good starting points for you, if you want to perform PDF signature control.
+
+If you want to sign **existing** PDF documents,
+you should consider using PyHanko: .
diff --git a/docs/Tables.md b/docs/Tables.md
index 744fe083b..5aa267635 100644
--- a/docs/Tables.md
+++ b/docs/Tables.md
@@ -120,24 +120,37 @@ left and right is supplied then c_margin is ignored.
_New in [:octicons-tag-24: 2.7.6](https://github.com/PyFPDF/fpdf2/blob/master/CHANGELOG.md)_
-Can be set globally or per cell.
-Works the same way as padding, but with the `v_align` parameter.
-
+Can be set globally, per row or per cell, by passing a string or a [VAlign](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.VAlign) enum value as `v_align`:
```python
-
+...
with pdf.table(v_align=VAlign.M) as table:
...
- row.cell(f"custom v-align", v_align=VAlign.T) # <-- align to top
+ row.cell(f"custom v-align", v_align="TOP")
```
## Setting row height
-
+First, `line_height` can be provided to set the height of every individual line of text:
```python
...
with pdf.table(line_height=2.5 * pdf.font_size) as table:
...
```
+_New in [:octicons-tag-24: 2.8.3](https://github.com/py-pdf/fpdf2/blob/master/CHANGELOG.md)_
+
+Second, a global `min_row_height` can be set,
+or configured per row as `min_height`:
+```python
+...
+with pdf.table(min_row_height=30) as table:
+ row = table.row()
+ row.cell("A")
+ row.cell("B")
+ row = table.row(min_height=50)
+ row.cell("C")
+ row.cell("D")
+```
+
## Disable table headings
By default, `fpdf2` considers that the first row of tables contains its headings.
diff --git a/docs/TextRegion.md b/docs/TextRegion.md
index 448c556e3..7bbe8a5d1 100644
--- a/docs/TextRegion.md
+++ b/docs/TextRegion.md
@@ -1,9 +1,7 @@
_New in [:octicons-tag-24: 2.7.6](https://github.com/py-pdf/fpdf2/blob/master/CHANGELOG.md)_
-# Text Flow Regions #
-
-**Notice:** As of fpdf2 release 2.7.6, this is an experimental feature. Both the API and the functionality may change before it is finalized, without prior notice.
-Text regions are a hierarchy of classes that enable to flow text within a given outline. In the simplest case, it is just the running text column of a page. But it can also be a sequence of outlines, such as several parallel columns or the cells of a table. Other outlines may be combined by addition or subtraction to create more complex shapes.
+# Text Flow Regions #
+Text regions are a hierarchy of classes that enable to flow text within a given outline. In the simplest case, it is just the running text column of a page. But it can also be a sequence of outlines, such as several parallel columns or the cells of a table. Other outlines may be combined by addition or subtraction to create more complex shapes.
There are two general categories of regions. One defines boundaries for running text that will just continue in the same manner one the next page. Those include columns and tables. The second category are distinct shapes. Examples would be a circle, a rectangle, a polygon of individual shape or even an image. They may be used individually, in combination, or to modify the outline of a multipage column. Shape regions will typically not cause a page break when they are full. In the future, a possibility to chain them may be implemented, so that a new shape will continue with the text that didn't fit into the previous one.
@@ -14,7 +12,6 @@ Other types like Table cells, shaped regions and combinations are still in the d
## General Operation ##
-
Using the different region types and combination always follows the same pattern. The main difference to the normal `FPDF.write()` method is that all added text will first be buffered, and only gets rendered on the page when the context of the region is closed. This is necessary so that text can be aligned within the given boundaries even if its font, style, or size are arbitrarily varied along the way.
* Create the region instance with an `FPDF` method, , for example [text_columns()](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.text_columns).
@@ -34,7 +31,6 @@ The graphic shows the relationship of page, text areas and paragraphs (with vary
### Text Start Position ###
-
When rendering, the vertical start position of the text will be at the lowest one out of:
* the current y position
* the top of the region (if it has a defined top)
@@ -45,56 +41,51 @@ In both horizontal and vertical positioning, regions with multiple columns may f
### Interaction Between Regions ###
-
Several region instances can exist at the same time. But only one of them can act as context manager at any given time. It is not currently possible to activate them recursively. But it is possible to use them intermittingly. This will probably most often make sense between a columnar region and a table or a graphic. You may have some running text ending at a given height, then insert a table/graphic, and finally continue the running text at the new height below the table within the existing column(s).
### Common Parameters ###
-
All types of text regions have the following constructor parameters in common:
-* text (str, optional) - text content to add to the region. This is a convenience parameter for cases when all text is available in one piece, and no partition into paragraphs (possibly with different parameters) is required. (Default: None)
-* text_align (Align/str, optional) - the horizontal alignment of the text in the region. (Default: Align.L)
-* line_height (float, optional) - This is a factor by which the line spacing will be different from the font height. It works similar to the attribute of the same name in HTML/CSS. (default: 1.0)
-* print_sh (bool, optional) - Treat a soft-hyphen (\\u00ad) as a printable character, instead of a line breaking opportunity. (Default: False)
-* skip_leading_spaces (default: False) - This flag is primarily used by `write_html()`, but may also have other uses. It removes all space characters at the beginning of each line.
-* wrapmode (default "WORD") -
-* image (str or PIL.Image.Image or io.BytesIO, optional) - An image to add to the region. This is a convenience parameter for cases when no further text or images need to be added to the paragraph. If both "text" and "image" arguments are present, the text will be inserted first. (Default: None)
-* image_fill_width (bool, optional) - Indicates whether to increase the size of the image to fill the width of the column. Larger images will always be reduced to column width. (Default: False)
+* `text` (str, optional) - text content to add to the region. This is a convenience parameter for cases when all text is available in one piece, and no partition into paragraphs (possibly with different parameters) is required. (Default: None)
+* `text_align` (Align/str, optional) - the horizontal alignment of the text in the region. (Default: Align.L)
+* `line_height` (float, optional) - This is a factor by which the line spacing will be different from the font height. It works similar to the attribute of the same name in HTML/CSS. (default: 1.0)
+* `print_sh` (bool, optional) - Treat a soft-hyphen (\\u00ad) as a printable character, instead of a line breaking opportunity. (Default: False)
+* `skip_leading_spaces` (default: False) - This flag is primarily used by `write_html()`, but may also have other uses. It removes all space characters at the beginning of each line.
+* `wrapmode` (default `WORD`) -
+* `image` (str or PIL.Image.Image or io.BytesIO, optional) - An image to add to the region. This is a convenience parameter for cases when no further text or images need to be added to the paragraph. If both `text` and `image` arguments are present, the text will be inserted first. (Default: None)
+* `image_fill_width` (bool, optional) - Indicates whether to increase the size of the image to fill the width of the column. Larger images will always be reduced to column width. (Default: False)
All of those values can be overriden for each individual paragraph.
### Common Methods ###
-
* `.paragraph()` [see characteristic parameters below] - establish a new paragraph in the text. The text added to this paragraph will start on a new line.
* `.write(text: str, link: = None)` - write text to the region. This is only permitted when no explicit paragraph is currently active.
* `.image()` [see characteristic parameters below] - insert a vector or raster image in the region, flowing with the text like a paragraph.
-* `.ln(h: float = None)` - Start a new line moving either by the current font height or by the parameter "h". Only permitted when no explicit paragraph is currently active.
-* `.render()` - if the region is not used as a context manager with "with", this method must be called to actually process the added text.
+* `.ln(h: float = None)` - Start a new line moving either by the current font height or by the parameter `h`. Only permitted when no explicit paragraph is currently active.
+* `.render()` - if the region is not used as a context manager with `with`, this method must be called to actually process the added text.
## Paragraphs ##
-
The primary purpose of paragraphs is to enable variations in horizontal text alignment, while the horizontal extents of the text are managed by the text region. To set the alignment, you can use the `align` argument when creating the paragraph. Valid values are defined in the [`Align enum`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.Align).
For more typographical control, you can use the following arguments. Most of those override the settings of the current region when set, and default to the value set there.
-* text_align (Align, optional) - The horizontal alignment of the paragraph.
-* line_height (float, optional) - factor by which the line spacing will be different from the font height. (default: by region)
-* top_margin (float, optional) - how much spacing is added above the paragraph. No spacing will be added at the top of the paragraph if the current y position is at (or above) the top margin of the page. (Default: 0.0 mm)
-* bottom_margin (float, optional) - Those two values determine how much spacing is added below the paragraph. No spacing will be added at the bottom if it would result in overstepping the bottom margin of the page. (Default: 0.0 mm)
-* indent (float, optional): determines the indentation of the paragraph. (Default: 0.0 mm)
-* bullet_r_margin (float, optional) - determines the relative displacement of the bullet along the x-axis. The distance is between the rightmost point of the bullet to the leftmost point of the paragraph's text. (Default: 2.0 mm)
-* bullet_string (str, optional): determines the fragments and text lines of the bullet. (Default: "")
-* skip_leading_spaces (float, optional) - removes all space characters at the beginning of each line.
-* wrapmode (WrapMode, optional)
+* `text_align` (Align, optional) - The horizontal alignment of the paragraph.
+* `line_height` (float, optional) - factor by which the line spacing will be different from the font height. (default: by region)
+* `top_margin` (float, optional) - how much spacing is added above the paragraph. No spacing will be added at the top of the paragraph if the current y position is at (or above) the top margin of the page. (Default: 0.0 mm)
+* `bottom_margin` (float, optional) - Those two values determine how much spacing is added below the paragraph. No spacing will be added at the bottom if it would result in overstepping the bottom margin of the page. (Default: 0.0 mm)
+* `indent` (float, optional): determines the indentation of the paragraph. (Default: 0.0 mm)
+* `bullet_r_margin` (float, optional) - determines the relative displacement of the bullet along the x-axis. The distance is between the rightmost point of the bullet to the leftmost point of the paragraph's text. (Default: 2.0 mm)
+* `bullet_string` (str, optional): determines the fragments and text lines of the bullet. (Default: `""`)
+* `skip_leading_spaces` (float, optional) - removes all space characters at the beginning of each line.
+* `wrapmode` (WrapMode, optional)
Other than text regions, paragraphs should always be used as context managers and never be reused. Violating those rules may result in the entered text turning up on the page out of sequence.
### Possible Future Extensions ###
-
Those features are currently not supported, but Pull Requests are welcome to implement them:
* per-paragraph indentation
@@ -102,11 +93,13 @@ Those features are currently not supported, but Pull Requests are welcome to imp
## Images ##
-
_New in [:octicons-tag-24: 2.7.7](https://github.com/py-pdf/fpdf2/blob/master/CHANGELOG.md)_
Most arguments for inserting images into text regions are the same as for the `FPDF.image()` method, and have the same or equivalent meaning.
-Since the image will be placed automatically, the "x" and "y" parameters are not available. The positioning can be controlled with "align", where the default is "LEFT", with the alternatives "RIGHT" and "CENTER".
-If neither width nor height are specified, the image will be inserted with the size resulting from the PDF default resolution of 72 dpi. If the "fill_width" parameter is set to True, it increases the size to fill the full column width if necessary. If the image is wider than the column width, it will always be reduced in size proportionally.
-The "top_margin" and "bottom_margin" parameters have the same effect as with text paragraphs.
+
+Since the image will be placed automatically, the `x` and `y` parameters are not available. The positioning can be controlled with `align`, where the default is `LEFT`, with the alternatives `RIGHT` and `CENTER`.
+
+If neither width nor height are specified, the image will be inserted with the size resulting from the PDF default resolution of 72 dpi. If the `fill_width` parameter is set to True, it increases the size to fill the full column width if necessary. If the image is wider than the column width, it will always be reduced in size proportionally.
+
+The `top_margin` and `bottom_margin` parameters have the same effect as with text paragraphs.
diff --git a/docs/index.md b/docs/index.md
index 35c0c5ad8..f9b892ed4 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -84,7 +84,7 @@ pip install git+https://github.com/py-pdf/fpdf2.git@master
**Developement**: check the [dedicated documentation page](Development.md).
### Verifying provenance
-[`pypi-attestations`](https://pypi.org/project/pypi-attestations/) can be used to check the provenance of a `fpdf2-2.X.Y.tar.gz` or `fpdf2-2.X.Y-py2.py3-none-any.whl` package.
+[`pypi-attestations`](https://pypi.org/project/pypi-attestations/) can be used to check the provenance of a `fpdf2-2.X.Y.tar.gz` or `fpdf2-2.X.Y-py2.py3-none-any.whl` package (_cf._ [PyPI now supports digital attestations](https://blog.pypi.org/posts/2024-11-14-pypi-now-supports-digital-attestations/)).
Example to check that the [`fpdf2-2.8.2.tar.gz` package on Pypi](https://pypi.org/project/fpdf2/#fpdf2-2.8.2.tar.gz) has been published from the [py-pdf/fpdf2](https://github.com/py-pdf/fpdf2) GitHub repository:
diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py
index 6c5720960..eec4badc6 100644
--- a/fpdf/fpdf.py
+++ b/fpdf/fpdf.py
@@ -273,7 +273,7 @@ def __init__(
"""
Note: Setting the page manually may result in unexpected behavior.
`pdf.add_page()` takes special care to ensure the page's content stream
- matches fpdf's instance attributes. Manually setting the page does not.
+ matches FPDF's instance attributes. Manually setting the page does not.
"""
# array of PDFPage objects starting at index 1:
self.pages: Dict[int, PDFPage] = {}
diff --git a/fpdf/graphics_state.py b/fpdf/graphics_state.py
index ed79e407c..e56081728 100644
--- a/fpdf/graphics_state.py
+++ b/fpdf/graphics_state.py
@@ -61,15 +61,18 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _push_local_stack(self, new=None):
- if new:
- self.__statestack.append(new)
- else:
- self.__statestack.append(self._get_current_graphics_state())
+ "Push a graphics state on the stack"
+ if not new:
+ new = self._get_current_graphics_state()
+ self.__statestack.append(new)
+ return new
def _pop_local_stack(self):
+ "Pop the last graphics state on the stack"
return self.__statestack.pop()
def _get_current_graphics_state(self):
+ "Retrieve the current graphics state"
# "current_font" must be shallow copied
# "text_shaping" must be deep copied (different fragments may have different languages/direction)
# Doing a whole copy and then creating a copy of text_shaping to achieve this result
@@ -78,6 +81,7 @@ def _get_current_graphics_state(self):
return gs
def _is_current_graphics_state_nested(self):
+ "Indicate if the stack contains items (else it is empty)"
return len(self.__statestack) > 1
@property
@@ -368,3 +372,11 @@ def font_face(self):
self.fill_color if self.fill_color != self.DEFAULT_FILL_COLOR else None
),
)
+
+
+__pdoc__ = {
+ "GraphicsStateMixin._push_local_stack": True,
+ "GraphicsStateMixin._pop_local_stack": True,
+ "GraphicsStateMixin._get_current_graphics_state": True,
+ "GraphicsStateMixin._is_current_graphics_state_nested": True,
+}
diff --git a/fpdf/output.py b/fpdf/output.py
index ecd3d9613..29e877960 100644
--- a/fpdf/output.py
+++ b/fpdf/output.py
@@ -393,6 +393,8 @@ def serialize(self, obj_dict=None, _security_handler=None):
class PDFXrefAndTrailer(ContentWithoutID):
+ "Cross-reference table & file trailer"
+
def __init__(self, output_builder):
self.output_builder = output_builder
self.count = output_builder.obj_id + 1
diff --git a/fpdf/syntax.py b/fpdf/syntax.py
index ff0d5d105..fe1757be7 100644
--- a/fpdf/syntax.py
+++ b/fpdf/syntax.py
@@ -137,12 +137,9 @@ def serialize(self, _security_handler=None, _obj_id=None) -> str:
class PDFObject:
- """
- Main features of this class:
- * delay ID assignement
- * implement serializing
- """
-
+ # Main features of this class:
+ # * delay ID assignement
+ # * implement serializing
# Note: several child classes use __slots__ to save up some memory
def __init__(self):
diff --git a/fpdf/table.py b/fpdf/table.py
index 359a0afc5..0cb3568dd 100644
--- a/fpdf/table.py
+++ b/fpdf/table.py
@@ -50,6 +50,7 @@ def __init__(
outer_border_width=None,
num_heading_rows=1,
repeat_headings=1,
+ min_row_height=None,
):
"""
Args:
@@ -106,6 +107,7 @@ def __init__(
self._wrapmode = wrapmode
self._num_heading_rows = num_heading_rows
self._repeat_headings = TableHeadingsDisplay.coerce(repeat_headings)
+ self._min_row_height = min_row_height
self._initial_style = None
self.rows = []
@@ -150,11 +152,11 @@ def __init__(
for row in rows:
self.row(row)
- def row(self, cells=(), style=None):
+ def row(self, cells=(), style=None, v_align=None, min_height=None):
"Adds a row to the table. Returns a `Row` object."
if self._initial_style is None:
self._initial_style = self._fpdf.font_face()
- row = Row(self, style=style)
+ row = Row(self, style=style, v_align=v_align, min_height=min_height)
self.rows.append(row)
for cell in cells:
if isinstance(cell, dict):
@@ -223,7 +225,7 @@ def render(self):
cell_x_positions.append(xx)
# Process any rowspans
- row_info = list(self._process_rowpans_entries())
+ rows_info = list(self._compute_rows_info())
# actually render the cells
repeat_headings = (
@@ -231,7 +233,7 @@ def render(self):
)
self._fpdf.y += self._outer_border_margin[1]
for i in range(len(self.rows)):
- pagebreak_height = row_info[i].pagebreak_height
+ pagebreak_height = rows_info[i].pagebreak_height
# pylint: disable=protected-access
page_break = self._fpdf._perform_page_break_if_need_be(pagebreak_height)
if (
@@ -251,12 +253,12 @@ def render(self):
for row_idx in range(self._num_heading_rows):
self._render_table_row(
row_idx,
- row_info[row_idx],
+ rows_info[row_idx],
cell_x_positions=cell_x_positions,
)
if i > 0:
self._fpdf.y += self._gutter_height
- self._render_table_row(i, row_info[i], cell_x_positions)
+ self._render_table_row(i, rows_info[i], cell_x_positions)
# Restoring altered FPDF settings:
self._fpdf.l_margin = prev_l_margin
@@ -390,7 +392,11 @@ def _render_table_cell(
padding = Padding.new(cell.padding) if cell.padding else self._padding
- v_align = cell.v_align if cell.v_align else self._v_align
+ v_align = cell.v_align
+ if not v_align:
+ v_align = row.v_align
+ if not v_align:
+ v_align = self._v_align
# We can not rely on the actual x position of the cell. Notably in case of
# empty cells or cells with an image only the actual x position is incorrect.
@@ -406,13 +412,11 @@ def _render_table_cell(
self._fpdf.set_x(cell_x)
# render cell border and background
-
# if cell_height is defined, that means that we already know the size at which the cell will be rendered
# so we can draw the borders now
#
# If cell_height is None then we're still in the phase of calculating the height of the cell meaning that
# we do not need to set fonts & draw borders yet.
-
if not height_query_only:
x1 = self._fpdf.x
y1 = self._fpdf.y
@@ -431,8 +435,7 @@ def _render_table_cell(
fill_color=style.fill_color if style else None,
)
- # draw outer box if needed
-
+ # draw outer box if needed:
if self._outer_border_width:
_remember_linewidth = self._fpdf.line_width
self._fpdf.set_line_width(self._outer_border_width)
@@ -460,8 +463,7 @@ def _render_table_cell(
self._fpdf.set_line_width(_remember_linewidth)
- # render image
-
+ # render image:
if cell.img:
x, y = self._fpdf.x, self._fpdf.y
@@ -571,7 +573,7 @@ def _get_col_width(self, i, j, colspan=1):
col_width += self._gutter_width
return col_width
- def _process_rowpans_entries(self):
+ def _compute_rows_info(self):
# First pass: Regularise the table by processing the rowspan and colspan entries
active_rowspans = {}
prev_row_in_col = {}
@@ -654,6 +656,12 @@ def _process_rowpans_entries(self):
min_height = max(img_heights)
if min_height == 0:
min_height = self._line_height
+ if row.min_height:
+ if min_height < row.min_height:
+ min_height = row.min_height
+ elif self._min_row_height:
+ if min_height < self._min_row_height:
+ min_height = self._min_row_height
row_min_heights.append(min_height)
row_span_max.append(row.max_rowspan)
@@ -721,10 +729,12 @@ def _process_rowpans_entries(self):
class Row:
"Object that `Table.row()` yields, used to build a row in a table"
- def __init__(self, table, style=None):
+ def __init__(self, table, style=None, v_align=None, min_height=None):
self._table = table
self.cells = []
self.style = style
+ self.v_align = VAlign.coerce(v_align) if v_align else v_align
+ self.min_height = min_height
@property
def cols_count(self):
@@ -828,7 +838,7 @@ def cell(
cell = Cell(
text,
align,
- v_align,
+ VAlign.coerce(v_align) if v_align else v_align,
style,
img,
img_fill_width,
diff --git a/mkdocs.yml b/mkdocs.yml
index aacf83b80..eed856073 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -45,7 +45,11 @@ markdown_extensions:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.highlight
- - pymdownx.superfences
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:pymdownx.superfences.fence_code_format
- toc:
permalink: true
@@ -181,6 +185,7 @@ nav:
- 'Development':
- 'Development guidelines': 'Development.md'
- 'Logging': 'Logging.md'
+ - 'fpdf2 internals': 'Internals.md'
- 'API': 'https://py-pdf.github.io/fpdf2/fpdf/'
- 'History': 'History.md'
diff --git a/test/table/draw_box_borders.pdf b/test/table/table_draw_box_borders.pdf
similarity index 100%
rename from test/table/draw_box_borders.pdf
rename to test/table/table_draw_box_borders.pdf
diff --git a/test/table/table_min_row_height.pdf b/test/table/table_min_row_height.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..f22b2fff1bd15643ebedac5819850f71a8866a48
GIT binary patch
literal 1178
zcmbtU&ubGw6qcfwr3i(fJwDKC1)I(8W|LhBg(RD$v6ZCTg4VR)Hkrni%}m{$K&nMA
z9yJxIzo4MvSuLU{3ZhojqaIYeC`kPSyr`g}Gx=c`TlC;uX7{~$-+SNt-b_#4F#Dvi
zB1jMcGI>ZyBm^-{s+fW#h=Weahl0Xf)`TGLK_$mdks1^t%%bXQSk^#`N?}cB-qD$9
z1Y+JUBVXw67Z5ISI5_~Dn?^WUqz=I?Ob|_n4gjvYE(749L-q`*QV??(rHNau;HKFJ
zUY^xXlD4Vsl5!voFL9-MOcAC&M4K^r+hf0N1Pl43>J^a>@gO8m9>C+FZ9LO)De_pY
zu1%4Fill@DaR}iuJqS`%<552KkX;dK3uou_jU7^F=G(%D^DnxejV;V~4kicd>q7I<
z@k^Z-FDD1K43FLX@%_Pu?MtChP6@g7O^=_P^p>u4XO}x}oqKrtLRPlmb8Y-EJ~7*M
z`{T~H>rYj4`S8BVo8B+Z*W&ZtFMnP;uiXilGxz4MqWYt{{q*Sl
zV??++bVpNW-+$U{z0MAEAzJ0NBvAw0Qnrsw!nz}lIu+#iS)^jKo@X#)>EJSmdmWs_
zzSA}c;z)IpHk9Uivk_^^&M^Y*i~S`3I&mkI^&6IjBU2U^iH+*bZgH}1Sn{M1Zk!&A6tVB0Q4HWBt51}vLZ=(gsuHM
z0%0`|O$iau1@37sh^eZg!W697bZEE$uh?`}Fkblr?y8Mt<%_r>gmbkOyL&HDUpWF|W0
zS13dq07XnKP4$c{6f8^(^(-ubvLTfPsS5f5iRr1uTy}O`sd*_NmCPSDKRpGytU4C$TcWv_wJQKQGleKc_S|4FWCIkQ$(drsVOF*s%7!|-M_bAJ^lOT+ndjtuOBzq
zo43j7jK@uWZlMh>8k*6!erEms@H0`e+T%%}qW6)YzfRNICM|e!OV=dj`ystUCbvX_
z54bdUFKb%pu=0wo$&sCt6?a>g3a(mwbCQ(N@6D>**VvYR<>oD1m)lXC>%600-nHno
zv*)(9d#Fj?wd
z{FiW>{K<;v)SSy^S9z=|HHqdePoEunF2{M!%{Gh8qBm#Xb$xc;W767(LQ>0KNa-0S
zE!)3o8hhLB#8vChEWZvk_`gQT;k!J4%6$ja%07-N<>)bV;MSoBV}$WpzNw4;m@ZglLKsqfUv%sc!keG@&1o
znVVXy832^BLmnM~f(>ExU12YCBv4hM&N;IGZ
z1y3{}<85s8-3%2#HU|L{iGDy)esXYXi9)nKFw+6;POSjCz%v(^@|>WwGnDp>MKabL
z$yjG#Is;`O7hqCU&~ULbv^29cG%_|cv@`&MC`}}76-B9OTm}jjTp$?;P%tw!H8xd9
zQ$UDWDuAUF@(^N{K!1P$l9-_-raB{YOfhp4pl8waT9^Pc6PlQ%F)%;F#7c@1Gjmdl
zz$H&`W>qTC$@)S0`6UXVvICgsJ@eA?6+nRiV!J3r+qk%x8JajcIhnZ{xi}fSnp&7R
pnmL)7xfxlSn47wo8`=?80S@2dlEk7Ca4=a~m~pA9y863u0RUrPs;>Y5
literal 0
HcmV?d00001
diff --git a/test/table/test_table.py b/test/table/test_table.py
index 9c2ee2b29..b8a8dc261 100644
--- a/test/table/test_table.py
+++ b/test/table/test_table.py
@@ -1036,3 +1036,13 @@ def test_table_cell_border_inherit(tmp_path):
for datum in data_row:
row.cell(datum, border="inherit")
assert_pdf_equal(pdf, HERE / "test_table_cell_border_inherit.pdf", tmp_path)
+
+
+def test_table_min_row_height(tmp_path):
+ pdf = FPDF()
+ pdf.add_page()
+ pdf.set_font("Times", size=20)
+ with pdf.table(min_row_height=30) as table:
+ row = table.row(("A", "B"))
+ row = table.row(("C", "D"), min_height=50)
+ assert_pdf_equal(pdf, HERE / "table_min_row_height.pdf", tmp_path)
diff --git a/test/table/test_table_padding.py b/test/table/test_table_padding.py
index fc4a057cf..e0e904088 100644
--- a/test/table/test_table_padding.py
+++ b/test/table/test_table_padding.py
@@ -3,7 +3,7 @@
import pytest
from fpdf import FPDF
-from fpdf.enums import MethodReturnValue, YPos, TableCellFillMode, VAlign
+from fpdf.enums import TableCellFillMode, VAlign
from fpdf.fonts import FontFace
from fpdf.table import draw_box_borders
@@ -12,11 +12,6 @@
HERE = Path(__file__).resolve().parent
-def run_comparison(pdf, name, tmp_path, **kwargs):
- filename = HERE / f"{name}.pdf"
- assert_pdf_equal(pdf, filename, tmp_path, **kwargs)
-
-
IMG_DIR = HERE.parent / "image"
IMAGE = IMG_DIR / "image_types/pythonknight.png"
@@ -65,94 +60,6 @@ def run_comparison(pdf, name, tmp_path, **kwargs):
TABLE_DATA_LIST = ["And", "now", "for", "something", "completely", "different"]
-SHORT_TEXT = "Monty Python / Killer Sheep"
-
-TWO_LINE_TEXT = "Monty Python\nKiller Sheep"
-
-
-def test_multicell_with_padding(tmp_path):
- pdf = FPDF()
- pdf.add_page()
- pdf.set_font("Times", size=16)
- pdf.multi_cell(0, 5, LONG_TEXT, border=1, padding=(10, 20, 30, 40))
-
- run_comparison(pdf, "multicell_with_padding", tmp_path)
-
-
-def test_multicell_with_padding_check_input():
- pdf = FPDF()
- pdf.add_page()
- pdf.set_font("Times", size=16)
-
- with pytest.raises(ValueError):
- pdf.multi_cell(0, 5, LONG_TEXT, border=1, padding=(5, 5, 5, 5, 5, 5))
-
-
-def test_multicell_return_value(tmp_path):
- pdf = FPDF()
- pdf.add_page()
- pdf.set_font("Times", size=16)
-
- pdf.x = 5
-
- out = pdf.multi_cell(
- 0,
- 5,
- TWO_LINE_TEXT,
- border=1,
- padding=0,
- output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT,
- )
- height_without_padding = out[1]
-
- pdf.x = 5
- # pdf.y += 50
-
- # try again
- out = pdf.multi_cell(
- 0,
- 5,
- TWO_LINE_TEXT,
- border=1,
- padding=0,
- output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT,
- )
-
- height_without_padding2 = out[1]
-
- pdf.x = 5
- # pdf.y += 50
-
- # try again
- out = pdf.multi_cell(
- 0,
- 5,
- TWO_LINE_TEXT,
- border=1,
- padding=10,
- output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT,
- )
-
- height_with_padding = out[1]
-
- assert height_without_padding == height_without_padding2
- assert height_without_padding + 20 == height_with_padding
-
- pdf.x = 5
- pdf.y += 10
-
- out = pdf.multi_cell(
- 0,
- 5,
- TWO_LINE_TEXT,
- border=1,
- padding=10,
- output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT,
- new_y=YPos.NEXT,
- )
-
- run_comparison(pdf, "table_with_padding", tmp_path)
-
def test_table_with_multiline_cells_and_images_padding_and_pagebreak(tmp_path):
pdf = FPDF()
@@ -179,8 +86,10 @@ def test_table_with_multiline_cells_and_images_padding_and_pagebreak(tmp_path):
else:
row.cell(datum)
- run_comparison(
- pdf, "table_with_multiline_cells_and_images_padding_and_pagebreak", tmp_path
+ assert_pdf_equal(
+ pdf,
+ HERE / "table_with_multiline_cells_and_images_padding_and_pagebreak.pdf",
+ tmp_path,
)
@@ -202,7 +111,7 @@ def test_table_with_single_row_of_images(tmp_path):
for datum in data_row:
row.cell(img=datum)
- run_comparison(pdf, "table_with_single_row_of_images", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_with_single_row_of_images.pdf", tmp_path)
def test_table_with_only_images(tmp_path):
@@ -222,7 +131,7 @@ def test_table_with_only_images(tmp_path):
for datum in data_row:
row.cell(img=datum)
- run_comparison(pdf, "table_with_only_images", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_with_only_images.pdf", tmp_path)
def test_table_vertical_alignment(tmp_path):
@@ -254,10 +163,10 @@ def test_table_vertical_alignment(tmp_path):
else:
row.cell(datum)
- run_comparison(pdf, "table_vertical_alignment", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_vertical_alignment.pdf", tmp_path)
-def test_padding_per_cell(tmp_path):
+def test_table_padding_per_cell(tmp_path):
pdf = FPDF()
pdf.add_page()
pdf.set_font("Times", size=12)
@@ -278,10 +187,26 @@ def test_padding_per_cell(tmp_path):
else:
row.cell(datum)
- run_comparison(pdf, "table_padding_per_cell", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_padding_per_cell.pdf", tmp_path)
+
+
+def test_table_valign_per_row(tmp_path):
+ pdf = FPDF()
+ pdf.add_page()
+ pdf.set_font("Times", size=12)
+ with pdf.table(first_row_as_headings=False, padding=2) as table:
+ for irow, v_align in enumerate(VAlign):
+ row = table.row(v_align=v_align)
+ for icol in range(5):
+ datum = icol * "Circus\n"
+ row.cell(
+ f"{datum}v_align={v_align}",
+ v_align=v_align,
+ )
+ assert_pdf_equal(pdf, HERE / "table_valign_per_row.pdf", tmp_path)
-def test_valign_per_cell(tmp_path):
+def test_table_valign_per_cell(tmp_path):
pdf = FPDF()
pdf.add_page()
pdf.set_font("Times", size=12)
@@ -311,7 +236,7 @@ def test_valign_per_cell(tmp_path):
else:
row.cell(datum)
- run_comparison(pdf, "table_valign_per_cell", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_valign_per_cell.pdf", tmp_path)
def test_table_with_gutter_and_padding_and_outer_border_width(tmp_path):
@@ -328,8 +253,8 @@ def test_table_with_gutter_and_padding_and_outer_border_width(tmp_path):
):
pass
- run_comparison(
- pdf, "table_with_gutter_and_padding_and_outer_border_width", tmp_path
+ assert_pdf_equal(
+ pdf, HERE / "table_with_gutter_and_padding_and_outer_border_width.pdf", tmp_path
)
@@ -365,10 +290,10 @@ def make_text(n):
continue
row.cell(txt)
- run_comparison(pdf, "table_with_colspan", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_with_colspan.pdf", tmp_path)
-def test_outside_border_width(tmp_path):
+def test_table_outside_border_width(tmp_path):
pdf = FPDF()
pdf.add_page()
pdf.set_font("Times", size=12)
@@ -380,7 +305,7 @@ def test_outside_border_width(tmp_path):
datum = "Circus"
row.cell(datum)
- run_comparison(pdf, "table_with_outside_border_width", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_with_outside_border_width.pdf", tmp_path)
def test_table_colspan_and_padding(tmp_path):
@@ -426,7 +351,7 @@ def test_table_colspan_and_padding(tmp_path):
row.cell("B3")
row.cell("B4")
- run_comparison(pdf, "table_colspan_and_padding", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_colspan_and_padding.pdf", tmp_path)
def test_table_colspan_and_padding_and_gutter(tmp_path):
@@ -452,7 +377,7 @@ def test_table_colspan_and_padding_and_gutter(tmp_path):
row.cell("B3")
row.cell("B4")
- run_comparison(pdf, "table_colspan_and_padding_and_gutter", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_colspan_and_padding_and_gutter.pdf", tmp_path)
def test_table_colspan_and_padding_and_gutter_and_width(tmp_path):
@@ -477,7 +402,9 @@ def test_table_colspan_and_padding_and_gutter_and_width(tmp_path):
row.cell("B3")
row.cell("B4")
- run_comparison(pdf, "table_colspan_and_padding_and_gutter_and_width", tmp_path)
+ assert_pdf_equal(
+ pdf, HERE / "table_colspan_and_padding_and_gutter_and_width.pdf", tmp_path
+ )
def test_table_with_cell_overflow(tmp_path):
@@ -507,10 +434,10 @@ def test_table_with_cell_overflow(tmp_path):
row.cell("B2")
row.cell("B3")
- run_comparison(pdf, "table_with_cell_overflow_font_setting", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_with_cell_overflow_font_setting.pdf", tmp_path)
-def test_draw_box_borders(tmp_path):
+def test_table_draw_box_borders(tmp_path):
pdf = FPDF()
pdf.set_font("Times", size=16)
pdf.add_page()
@@ -535,4 +462,4 @@ def box(x, y, borders):
box(40, 140, "T")
box(140, 140, "B")
- run_comparison(pdf, "draw_box_borders", tmp_path)
+ assert_pdf_equal(pdf, HERE / "table_draw_box_borders.pdf", tmp_path)
diff --git a/test/table/table_with_padding.pdf b/test/text/multi_cell_return_value.pdf
similarity index 100%
rename from test/table/table_with_padding.pdf
rename to test/text/multi_cell_return_value.pdf
diff --git a/test/table/multicell_with_padding.pdf b/test/text/multi_cell_with_padding.pdf
similarity index 100%
rename from test/table/multicell_with_padding.pdf
rename to test/text/multi_cell_with_padding.pdf
diff --git a/test/text/test_multi_cell.py b/test/text/test_multi_cell.py
index 7ebe344eb..0f9348280 100644
--- a/test/text/test_multi_cell.py
+++ b/test/text/test_multi_cell.py
@@ -3,6 +3,8 @@
import pytest
from fpdf import FPDF, FPDFException
+from fpdf.enums import MethodReturnValue, YPos
+
from test.conftest import assert_pdf_equal, LOREM_IPSUM
@@ -20,6 +22,15 @@
("Lucas", "Cimon", "31", "Angers"),
)
+LONG_TEXT = (
+ "\nProfessor: (Eric Idle) It's an entirely new strain of sheep, a killer sheep that can not only hold a rifle but is also a first-class shot.\n"
+ "Assistant: But where are they coming from, professor?\n"
+ "Professor: That I don't know. I just don't know. I really just don't know. I'm afraid I really just don't know. I'm afraid even I really just"
+ " don't know. I have to tell you I'm afraid even I really just don't know. I'm afraid I have to tell you... (she hands him a glass of water"
+ " which she had been busy getting as soon as he started into this speech) ... thank you ... (resuming normal breezy voice) ... I don't know."
+ " Our only clue is this portion of wolf's clothing which the killer sheep ..."
+)
+
def test_multi_cell_without_any_font_set():
pdf = FPDF()
@@ -514,3 +525,87 @@ def create_boxes(new_x, new_y, align, padding=2):
create_boxes("START", "NEXT", "LEFT")
create_boxes("END", "NEXT", "RIGHT")
assert_pdf_equal(pdf, HERE / "multi_cell_align_with_padding.pdf", tmp_path)
+
+
+def test_multi_cell_with_padding(tmp_path):
+ pdf = FPDF()
+ pdf.add_page()
+ pdf.set_font("Times", size=16)
+ pdf.multi_cell(0, 5, LONG_TEXT, border=1, padding=(10, 20, 30, 40))
+
+ assert_pdf_equal(pdf, HERE / "multi_cell_with_padding.pdf", tmp_path)
+
+
+def test_multi_cell_with_padding_check_input():
+ pdf = FPDF()
+ pdf.add_page()
+ pdf.set_font("Times", size=16)
+
+ with pytest.raises(ValueError):
+ pdf.multi_cell(0, 5, LONG_TEXT, border=1, padding=(5, 5, 5, 5, 5, 5))
+
+
+def test_multi_cell_return_value(tmp_path):
+ pdf = FPDF()
+ pdf.add_page()
+ pdf.set_font("Times", size=16)
+
+ pdf.x = 5
+
+ out = pdf.multi_cell(
+ 0,
+ 5,
+ "Monty Python\nKiller Sheep",
+ border=1,
+ padding=0,
+ output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT,
+ )
+ height_without_padding = out[1]
+
+ pdf.x = 5
+ # pdf.y += 50
+
+ # try again
+ out = pdf.multi_cell(
+ 0,
+ 5,
+ "Monty Python\nKiller Sheep",
+ border=1,
+ padding=0,
+ output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT,
+ )
+
+ height_without_padding2 = out[1]
+
+ pdf.x = 5
+ # pdf.y += 50
+
+ # try again
+ out = pdf.multi_cell(
+ 0,
+ 5,
+ "Monty Python\nKiller Sheep",
+ border=1,
+ padding=10,
+ output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT,
+ )
+
+ height_with_padding = out[1]
+
+ assert height_without_padding == height_without_padding2
+ assert height_without_padding + 20 == height_with_padding
+
+ pdf.x = 5
+ pdf.y += 10
+
+ out = pdf.multi_cell(
+ 0,
+ 5,
+ "Monty Python\nKiller Sheep",
+ border=1,
+ padding=10,
+ output=MethodReturnValue.PAGE_BREAK | MethodReturnValue.HEIGHT,
+ new_y=YPos.NEXT,
+ )
+
+ assert_pdf_equal(pdf, HERE / "multi_cell_return_value.pdf", tmp_path)
diff --git a/tutorial/graphics_state.py b/tutorial/graphics_state.py
new file mode 100644
index 000000000..0768a1ebd
--- /dev/null
+++ b/tutorial/graphics_state.py
@@ -0,0 +1,13 @@
+from fpdf.graphics_state import GraphicsStateMixin
+
+f = GraphicsStateMixin()
+# Push initial state in stack: gs0
+gs0 = f._push_local_stack()
+# Step 1 - set some graphic styles: gs1
+f.font_size_pt = 16
+f.underline = True
+gs1 = f._get_current_graphics_state()
+# Step 2 - restore gs0
+f._pop_local_stack()
+print(f"{f.font_size_pt=} {f.underline=}")
+# -> f.font_size_pt=0 f.underline=False