Edit on GitHub

fpdf2_textindex

License

Coverage GitHub last commit

fpdf2 Text Index

Adds a text index to fpdf2, based on the documentation and source code of Math Gemmell's Text Index:

from fpdf2_textindex import FPDF, TextIndexRenderer

pdf = FPDF()
pdf.add_page()
pdf.set_font('helvetica', size=12)
# Adding text index entry "example
pdf.cell(text="example{^}", markdown=True)
# Add the text index to a page
pdf.add_page()
pdf.insert_index_placeholder(TextIndexRenderer().render_text_index)
# Save as pdf
pdf.output("example.pdf")

Adding a Text Index Entry

Use the text index-syntax to define index directives in a text:

Most mechanical keyboard firmware{^} supports the use of [key combinations]{^}.

Print it in the PDF by enabling markdown in fpdf2.FPDF.cell or fpdf2.FPDF.multi_cell:

pdf = FPDF()
pdf.add_page()
pdf.set_font('helvetica', size=12)
pdf.cell(
  text="Most mechanical keyboard firmware{^} supports the use of [key combinations]{^}.",
  markdown=True,
)
...

For a complete documentation of the supported text index directives, see the excellent documentation of Math Gemmell.

The only difference to this documentation is the adaption of the emphasis to the markdown style of fpdf2. So the text:

This entry will be **emphasised**{^} in the index.
This expanded entry will be **[not emphasised]{^"* (nope)"}** in the index but here in the text.

will be printed in the PDF as:

This entry will be emphasised in the index.
This expanded entry will be not emphasised in the index but here in the text.

Similarly, the marks for italics __, underline -- and strikethrough ~~ are supported.

Inserting a Text Index

Use the adapted FPDF-class of this package that offers a fpdf2_textindex.FPDF.insert_index_placeholder-method to define a placeholder for the text index. A page break is triggered after inserting the text index:

...
pdf.add_page()
pdf.insert_index_placeholder(render_index_function)

Parameters:

  • render_index_function: Function called to render the text index, receiving two parameters: pdf, an adapted FPDF instance, and entries, a list of fpdf2_textindex.TextIndexEntrys. A reference implementation is supported through fpdf2_textindex.TextIndexRenderer.render_text_index.
  • pages: The number of pages that the text index will span, including the current one. A page break occurs for each page specified.
  • allow_extra_pages: If True, allows unlimited additional pages to be added to the text index as needed. These extra text index pages are initially created at the end of the document and then reordered when the final PDF is produced.

Note: Enabling allow_extra_pages may affect page numbering for headers or footers. Since extra text index pages are added after the document content, they might cause page numbers to appear out of sequence. To maintain consistent numbering, use Page Labels to assign a specific numbering style to the index pages. When using Page Labels, any extra text index pages will follow the numbering style of the first text index page

Example

An example can be created by example/textindex_figures.py and produces textindex_figures.pdf with all the examples from Math Gemmell's website.


Internals - Idea

For the curious reader:

This package adds a markdown parser to fpdf2 that intercepts markdown-styled strings to fpdf2.FPDF.cell or fpdf2.FPDF.multi_cell and translates Math Gemmell's Text Index-directives into markdown-links with an unset internal PDF link as destination, while the created index entries are internally saved:

"example{^}"
=
"[example](#idx0)"
+
TextIndexEntry(label="example", references=[Reference(start_id=0)])

When creating the actual text index in the PDF, all unset internal PDF link annotations that are related to the text index (identified by an unique id schema) are collected and its page, x/y-position on the page added to the entry's references:

{"idx0": LinkLocation(page=3, x=20.0, y=40.0, ...), ...}
->
TextIndexEntry.references[0].start_location = LinkLocation(page=3, x=20.0, y=40.0, ...)

Finally, a render_index_function similar to the official TOC-implementation of fpdf2 is used to render the index. The package supports a reference implementation, but the user can implement its own version if necessary.

The reference render_index_function renders each index entry according to The Chicago Manual of Style - Indexes. The references (locators) of each entry are matched by its id to an unset link annotation in the PDF and, thus, to a page number/label, which is used to print the reference (locator) in the index:

"example, 3"

The unset link annotation in the text is pointed to this entry in the index and, thus, is finally set.

In the reference implementation, inverted links are added as well: To create a connection of the index entry to the text page, the printed page number will point to the text page as well.
So clicking on "example" on the text page will lead to corresponding entry in the text index. Clicking on the reference (locator) in the text index, page "3", will return the reader to the text page. Cross-references are connected in the same way but inside of the text index.


The module gives direct access to some classes defined in submodules:

 1"""
 2[![License](https://img.shields.io/badge/license-GPLv3-blue.svg?style=flat)](https://www.gnu.org/licenses/gpl-3.0)
 3
 4.. include:: ../README.md
 5   :start-line: 8
 6
 7---
 8
 9The module gives direct access to some classes defined in submodules:
10
11* :py:class:`fpdf2_textindex.Alias`
12* :py:class:`fpdf2_textindex.CrossReference`
13* :py:class:`fpdf2_textindex.CrossReferenceType`
14* :py:class:`fpdf2_textindex.FPDF`
15* :py:class:`fpdf2_textindex.LinkLocation`
16* :py:class:`fpdf2_textindex.Reference`
17* :py:class:`fpdf2_textindex.TextIndexEntry`
18* :py:class:`fpdf2_textindex.TextIndexRenderer`
19"""  # noqa: D212, D415
20
21# Monkey-patch fpdf bugs first
22import fpdf2_textindex._fpdf  # noqa: F401
23from fpdf2_textindex.interface import Alias
24from fpdf2_textindex.interface import CrossReference
25from fpdf2_textindex.interface import CrossReferenceType
26from fpdf2_textindex.interface import LinkLocation
27from fpdf2_textindex.interface import Reference
28from fpdf2_textindex.interface import TextIndexEntry
29from fpdf2_textindex.pdf import FPDF
30from fpdf2_textindex.renderer import TextIndexRenderer
31from fpdf2_textindex.version import FPDF2_TEXTINDEX_VERSION
32
33__docformat__ = "google"
34__license__ = "GPL 3.0"
35__version__ = FPDF2_TEXTINDEX_VERSION
36
37__all__ = (  # noqa: RUF022
38    "Alias",
39    "CrossReference",
40    "CrossReferenceType",
41    "FPDF",
42    "LinkLocation",
43    "Reference",
44    "TextIndexEntry",
45    "TextIndexRenderer",
46    "__license__",
47    "__version__",
48)

API Documentation

@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
class Alias(fpdf2_textindex.interface._LabelPathABC):
35@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
36class Alias(_LabelPathABC):
37    """Alias."""
38
39    name: str
40    """The name of the alias."""
41
42    label_path: tuple[str, ...]
43    """The label path of the alias."""
44
45    def __repr__(self) -> str:
46        return (
47            f"{type(self).__name__}"
48            f"(#{self.name:s} -> {self.joined_label_path!r:s})"
49        )

Alias.

Alias(*, name: str, label_path: tuple[str, ...] = <property object>)
name: str

The name of the alias.

label_path: tuple[str, ...]

The label path of the alias.

@dataclasses.dataclass(kw_only=True, slots=True)
class CrossReference(fpdf2_textindex.interface._LabelPathABC):
72@dataclasses.dataclass(kw_only=True, slots=True)
73class CrossReference(_LabelPathABC):
74    """Cross Reference."""
75
76    id: int
77    """The id of the cross reference."""
78
79    type: CrossReferenceType
80    """The type of the cross reference."""
81
82    label_path: tuple[str, ...]
83    """The label path the cross reference points to."""
84
85    location: LinkLocation | None = dataclasses.field(default=None, init=False)
86    """The (link) location in the document the cross reference is set at."""
87
88    def __str__(self) -> str:
89        return f"{self.type.capitalize():s} {self.joined_label_path:s}"
90
91    def __repr__(self) -> str:
92        return f"{type(self).__name__}('{self!s:s}')"
93
94    @property
95    def link(self) -> str:
96        """The link in the document that must be set in the text index to lead
97        from the text to the text index.
98        """
99        return f"{const.INDEX_ID_PREFIX:s}{self.id:d}"

Cross Reference.

CrossReference( *, id: int, type: CrossReferenceType, label_path: tuple[str, ...] = <property object>)
id: int

The id of the cross reference.

The type of the cross reference.

label_path: tuple[str, ...]

The label path the cross reference points to.

location: LinkLocation | None

The (link) location in the document the cross reference is set at.

class CrossReferenceType(builtins.str, enum.Enum):
102class CrossReferenceType(str, enum.Enum):
103    """Cross Reference Type."""
104
105    NONE = "none"
106    """No cross reference."""
107
108    SEE = "see"
109    """SEE-cross reference."""
110
111    ALSO = "see also"
112    """SEE ALSO-cross reference."""
113
114    def __str__(self) -> str:
115        return self.value
116
117    @classmethod
118    def _missing_(cls, value: Any) -> Self | None:  # noqa: ANN401
119        if value is None:
120            return cls.NONE
121        if isinstance(value, str):
122            return cls(value.upper())
123        return None

Cross Reference Type.

NONE = <CrossReferenceType.NONE: 'none'>

No cross reference.

SEE = <CrossReferenceType.SEE: 'see'>

SEE-cross reference.

ALSO = <CrossReferenceType.ALSO: 'see also'>

SEE ALSO-cross reference.

class FPDF(fpdf2_textindex._fpdf._fpdf.FPDF):
 52class FPDF(fpdf.FPDF):
 53    """PDF Generation Class."""
 54
 55    if TYPE_CHECKING:
 56        _index_allow_page_insertion: bool
 57        _index_links: dict[str, int]
 58        _index_parser: TextIndexParser
 59        index_placeholder: IndexPlaceholder | None
 60
 61    CONCORDANCE_FILE: os.PathLike[str] | str | None = None
 62    """The path to a concordance file. Defaults to `None`."""
 63
 64    STRICT_INDEX_MODE: bool = True
 65    """If `True` and an entry has a normal reference (locator) and a SEE-cross
 66    reference, a `ValueError` will be raised. Else, it will just be a warning.
 67    Defaults to `True`.
 68    """
 69
 70    def __init__(
 71        self,
 72        orientation: PageOrientation | str = PageOrientation.PORTRAIT,
 73        unit: str | float = "mm",
 74        format: str | tuple[float, float] = "A4",
 75        font_cache_dir: Literal["DEPRECATED"] = "DEPRECATED",
 76        *,
 77        enforce_compliance: DocumentCompliance | str | None = None,
 78    ) -> None:
 79        """Initializes the :py:class:`FPDF`.
 80
 81        Args:
 82            orientation: Page orientation. Possible values are `"portrait"` (can
 83                be abbreviated `"P"`) or `"landscape"` (can be abbreviated
 84                `"L"`). Defaults to `"portrait"`.
 85            unit: Possible values are `"pt"`, `"mm"`, `"cm"`, `"in"`, or a
 86                number. A point equals 1/72 of an inch, that is to say about
 87                0.35 mm (an inch being 2.54 cm). This is a very common unit in
 88                typography; font sizes are expressed in this unit.
 89                If given a number, then it will be treated as the number of
 90                points per unit (eg. 72 = 1 in). Default to `"mm"`.
 91            format: Page format. Possible values are `"a3"`, `"a4"`, `"a5"`,
 92                `"letter"`, `"legal"` or a tuple `(width, height)` expressed in
 93                the given unit. Default to `"a4"`.
 94            font_cache_dir: [**DEPRECATED since v2.5.1**] unused.
 95            enforce_compliance: When enforce compliance is set, :py:class:`FPDF`
 96                actively prevents non-compliant operations and will raise errors
 97                if you try something forbidden for the selected profile.
 98                Defaults to `None`.
 99        """
100        super().__init__(
101            orientation=orientation,
102            unit=unit,
103            format=format,
104            font_cache_dir=font_cache_dir,
105            enforce_compliance=enforce_compliance,
106        )
107        self._concordance_list = None
108        if self.CONCORDANCE_FILE is not None:
109            self._concordance_list = ConcordanceList.from_file(
110                self.CONCORDANCE_FILE
111            )
112        self._index_allow_page_insertion = False
113        self._index_links = {}
114        self._index_parser = TextIndexParser(strict=self.STRICT_INDEX_MODE)
115        self.index_placeholder: IndexPlaceholder | None = None
116        """Index placeholder. Defaults to ``None``."""
117
118    def _set_index_link_locations(self) -> None:
119        link_locations = {}
120
121        # Collect index locations
122        for page_num, pdf_page in self.pages.items():
123            if pdf_page.annots is None:
124                continue
125
126            h_page = pdf_page.dimensions()[1] / self.k
127            for a in pdf_page.annots:
128                link_name = str(a.dest)
129                if not (
130                    link_name.startswith(const.INDEX_ID_PREFIX)
131                    or link_name.startswith(const.ENTRY_ID_PREFIX)
132                ):
133                    continue
134                assert a.rect.startswith("[") and a.rect.endswith("]"), a.rect
135                x, y_h, x_w, y = map(
136                    lambda x: float(x) / self.k,
137                    a.rect[1:-1].split(" ", maxsplit=3),
138                )
139                w = x_w - x
140                h = y - y_h
141                y = h_page - y
142                link_locations[link_name] = LinkLocation(
143                    page=page_num, x=x, y=y, w=w, h=h
144                )
145
146        # Add link locations to entries
147        for entry in self._index_parser.entries:
148            for ref in entry.references:
149                ref.start_location = link_locations[ref.start_link]
150                if ref.end_link:
151                    ref.end_location = link_locations[ref.end_link]
152            for cross_ref in entry.cross_references:
153                cross_ref.location = link_locations[cross_ref.link]
154
155    def _insert_index(self) -> None:
156        # NOTE: Text Index reuses functionality of ToC
157
158        # Collect links locations and add them to entries
159        self._set_index_link_locations()
160
161        # Doc has been closed but we want to write to self.pages[self.page]
162        # instead of self.buffer:
163        indexp = self.index_placeholder
164        assert indexp is not None
165        prev_page, prev_y = self.page, self.y
166        prev_toc_placeholder = self.toc_placeholder
167        prev_toc_allow_page_insertion = self._toc_allow_page_insertion
168
169        self.page, self.y = indexp.start_page, indexp.y
170        self.toc_placeholder = fpdf.fpdf.ToCPlaceholder(
171            lambda pdf, outlines: None,
172            indexp.start_page,
173            indexp.y,
174            indexp.page_orientation,
175            pages=indexp.pages,
176            reset_page_indices=indexp.reset_page_indices,
177        )
178        self._toc_allow_page_insertion = self._index_allow_page_insertion
179        # flag rendering ToC for page breaking function
180        self.in_toc_rendering = True
181        # Reset toc inserted counter to 0
182        self._toc_inserted_pages = 0
183        self._set_orientation(indexp.page_orientation, self.dw_pt, self.dh_pt)
184        indexp.render_function(self, self._index_parser.entries)
185        self.in_toc_rendering = False  # set ToC rendering flag off
186        expected_final_page = indexp.start_page + indexp.pages - 1
187        if (
188            self.page != expected_final_page
189            and not self._index_allow_page_insertion
190        ):
191            too = "many" if self.page > expected_final_page else "few"
192            error_msg = (
193                f"The rendering function passed to "
194                f"'FPDF.insert_index_placeholder' triggered too {too:s} page "
195                f"breaks: ToC ended on page {self.page:d} while it was "
196                f"expected to span exactly {indexp.pages:d} pages"
197            )
198            raise fpdf.errors.FPDFException(error_msg)
199        if self._toc_inserted_pages:
200            # Generating final page footer after more pages were inserted:
201            self._render_footer()
202            # We need to reorder the pages, because some new pages have been
203            # inserted in the Index, but they have been inserted at the end of
204            # self.pages:
205            new_pages = [
206                self.pages.pop(len(self.pages))
207                for _ in range(self._toc_inserted_pages)
208            ]
209            new_pages = list(reversed(new_pages))
210            indices_remap: dict[int, int] = {}
211            for page_index in range(
212                indexp.start_page + 1, self.pages_count + len(new_pages) + 1
213            ):
214                if page_index in self.pages:
215                    new_pages.append(self.pages.pop(page_index))
216                page = self.pages[page_index] = new_pages.pop(0)
217                # Fix page indices:
218                indices_remap[page.index()] = page_index
219                page.set_index(page_index)
220                # Fix page labels:
221                if indexp.reset_page_indices is False:
222                    page.get_page_label().st = page_index  # type: ignore[union-attr]
223            assert len(new_pages) == 0, f"#new_pages: {len(new_pages)}"
224            # Fix links:
225            for dest in self.links.values():
226                assert dest.page_number is not None
227                new_index = indices_remap.get(dest.page_number)
228                if new_index is not None:
229                    dest.page_number = new_index
230            # Fix outline:
231            for section in self._outline:
232                new_index = indices_remap.get(section.page_number)
233                if new_index is not None:
234                    section.dest = section.dest.replace(page=new_index)
235                    section.page_number = new_index
236                    if section.struct_elem:
237                        # pylint: disable=protected-access
238                        section.struct_elem._page_number = (  # pyright: ignore[reportPrivateUsage]
239                            new_index
240                        )
241            # Fix resource catalog:
242            resources_per_page = self._resource_catalog.resources_per_page
243            new_resources_per_page: dict[
244                tuple[int, PDFResourceType], set[ResourceTypes]
245            ] = defaultdict(set)
246            for (
247                page_number,
248                resource_type,
249            ), resource in resources_per_page.items():
250                key = (
251                    indices_remap.get(page_number, page_number),
252                    resource_type,
253                )
254                new_resources_per_page[key] = resource
255            self._resource_catalog.resources_per_page = new_resources_per_page
256
257        self._toc_allow_page_insertion = prev_toc_allow_page_insertion
258        self._toc_inserted_pages = 0
259        self.toc_placeholder = prev_toc_placeholder
260        self.page, self.y = prev_page, prev_y
261
262    def _preload_font_styles(
263        self,
264        text: str | None,
265        markdown: bool,
266    ) -> Sequence[Fragment]:
267        """Preloads the font styles by markdown parsing.
268
269        When Markdown styling is enabled, we require secondary fonts to
270        render text in bold & italics. This function ensure that those fonts are
271        available. It needs to perform Markdown parsing, so we return the
272        resulting `styled_txt_frags` tuple to avoid repeating this processing
273        later on.
274
275        Args:
276            text: The text to parse the markdown of.
277            markdown: Whether markdown is enabled.
278
279        Returns:
280            The preloaded text fragments.
281        """
282        if not self.in_toc_rendering and text and markdown:
283            # Load concordance list if not done in init
284            if (
285                self.CONCORDANCE_FILE is not None
286                and self._concordance_list is None
287            ):
288                self._concordance_list = ConcordanceList.from_file(
289                    self.CONCORDANCE_FILE
290                )
291            # Replace concordance entries by entry annotations
292            if self._concordance_list:
293                text = self._concordance_list.parse_text(text)
294            # Replace entry annotations by markdown link
295            first_id = self._index_parser.last_directive_id + 1
296            text = self._index_parser.parse_text(text)
297            last_id = self._index_parser.last_directive_id + 1
298            # Reserve the links (named destinations)
299            for text_to_index_id in range(first_id, last_id):
300                link_name = f"{const.INDEX_ID_PREFIX:s}{text_to_index_id:d}"
301                link_idx = self.add_link(name=link_name)
302                self._index_links[link_name] = link_idx
303        return super()._preload_font_styles(text, markdown)
304
305    @property
306    def index_entries(self) -> list[TextIndexEntry]:
307        """The (so far parsed) index entries."""
308        return self._index_parser.entries
309
310    def add_index_entry(
311        self,
312        label_path: Iterable[str],
313        sort_key: str | None = None,
314    ) -> TextIndexEntry:
315        """Adds manually a text index entry.
316
317        Note: References (locators) to pages cannot be added manually, only
318            cross references.
319
320        Args:
321            label_path: The label path of the entry.
322            sort_key: The sort key of the entry. Defaults to `None`.
323
324        Returns:
325            The text index entry.
326        """
327        entry = self._index_parser.entry_at_label_path(label_path, create=True)
328        assert isinstance(entry, TextIndexEntry)
329        entry.sort_key = sort_key
330        return entry
331
332    @fpdf.fpdf.check_page
333    def insert_index_placeholder(
334        self,
335        render_index_function: Callable[["FPDF", list[TextIndexEntry]], None],
336        *,
337        pages: int = 1,
338        allow_extra_pages: bool = False,
339        reset_page_indices: bool = True,
340    ) -> None:
341        """Configures Text Index rendering at the end of the document
342        generation, and reserves some vertical space right now in order to
343        insert it. At least one page break is triggered by this method.
344
345        Args:
346            render_index_function: A function that will be invoked to  render
347                the Index. This function will receive 2 parameters:
348                    `pdf`: an instance of :py:class:`fpdf2_textindex.pdf.FPDF`;
349                    `entries`: a list of
350                        :py:class:`fpdf2_textindex.interface.TextIndexEntry`s.
351            pages: The number of pages that the Index will span, including the
352                current one. As many page breaks as the value of this argument
353                will occur immediately after calling this method. Defaults to
354                `1`.
355            allow_extra_pages: If set to `True`, allows for an unlimited
356                number of extra pages in the Text Index, which may cause
357                discrepancies with pre-rendered page numbers.
358                For consistent numbering, using page labels to create a separate
359                numbering style for the Index is recommended. Defaults to
360                `False`.
361            reset_page_indices : Whether to reset the pages indices after the
362                Text Index. Defaults to `True`.
363
364        Raises:
365            FPDFException: If an index placeholder has been inserted before.
366            TypeError: If `render_index_function` is not callable.
367            ValueError: If ``pages`` is less than `1`.
368        """
369        if not callable(render_index_function):
370            msg = (
371                f"The first argument must be a callable, got: "
372                f"{type(render_index_function)!s:s}"
373            )
374            raise TypeError(msg)
375        if pages < 1:
376            msg = (
377                f"'pages' parameter must be equal or greater than 1: {pages:d}"
378            )
379            raise ValueError(msg)
380        if self.index_placeholder:
381            msg = (
382                "A placeholder for the index has already been defined on page "
383                f"{self.index_placeholder.start_page}"
384            )
385            raise fpdf.errors.FPDFException(msg)
386        self.index_placeholder = IndexPlaceholder(
387            render_index_function,
388            self.page,
389            self.y,
390            self.cur_orientation,
391            pages,
392            reset_page_indices,
393        )
394        self._index_allow_page_insertion = allow_extra_pages
395        for _ in range(pages):
396            self._perform_page_break()
397
398    @fpdf.fpdf.check_page
399    @fpdf.deprecation.support_deprecated_txt_arg
400    def multi_cell(
401        self,
402        w: float,
403        h: float | None = None,
404        text: str = "",
405        border: Literal[0, 1] | str = 0,
406        align: Align | str = Align.J,
407        fill: bool = False,
408        split_only: bool = False,  # DEPRECATED
409        link: int | str | None = None,
410        ln: Literal["DEPRECATED"] = "DEPRECATED",
411        max_line_height: float | None = None,
412        markdown: bool = False,
413        print_sh: bool = False,
414        new_x: XPos | str = XPos.RIGHT,
415        new_y: YPos | str = YPos.NEXT,
416        wrapmode: WrapMode = WrapMode.WORD,
417        dry_run: bool = False,
418        output: MethodReturnValue | str = MethodReturnValue.PAGE_BREAK,
419        center: bool = False,
420        padding: Padding | Sequence[int] | int = 0,
421        first_line_indent: float = 0,
422    ) -> fpdf.FPDF.MultiCellResult:
423        r"""This method allows printing text with line breaks.
424
425        They can be automatic (breaking at the most recent space or soft-hyphen
426        character) as soon as the text reaches the right border of the cell, or
427        explicit (via the `"\\n"` character). As many cells as necessary are
428        stacked, one below the other. Text can be aligned, centered or
429        justified. The cell block can be framed and the background painted. A
430        cell has an horizontal padding, on the left & right sides, defined by
431        the
432        [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html)-property.
433
434        Note:
435            Using
436            `new_x=XPos.RIGHT, new_y=XPos.TOP, maximum height=pdf.font_size`
437            is useful to build tables with multiline text in cells.
438
439        Args:
440            w: Cell width. If `0`, they extend up to the right margin of the
441                page.
442            h: Height of a single line of text. Defaults to `None`, meaning to
443                use the current font size.
444            text: Text to print.
445            border: Indicates if borders must be drawn around the cell.
446                The value can be either a number (`0`: no border; `1`:
447                frame) or a string containing some or all of the following
448                characters (in any order):
449                `"L"`: left,
450                `"T"`: top,
451                `"R"`: right,
452                `"B"`: bottom.
453                Defaults to `0`.
454            align: Sets the text alignment inside the cell.
455                Possible values are:
456                `"J"`: justify (default value),
457                `"L"` / `""`: left align,
458                `"C"`: center,
459                `"X"`: center around current x-position, or
460                `"R"`: right align.
461            fill: Indicates if the cell background must be painted (`True`)
462                or transparent (`False`). Defaults to `False`.
463            split_only: **DEPRECATED since 2.7.4**: Use `dry_run=True` and
464                `output=("LINES",)` instead.
465            link: Optional link to add on the cell, internal (identifier
466                returned by [:py:meth:`fpdf.FPDF.add_link`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.add_link)
467                or external URL.
468            new_x: New current position in x after the call. Defaults to
469                [:py:attr:`fpdf.XPos.RIGHT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.XPos).
470            new_y: New current position in y after the call. Defaults to
471                [:py:attr:`fpdf.YPos.NEXT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.YPos).
472            ln: **DEPRECATED since 2.5.1**: Use `new_x` and `new_y` instead.
473            max_line_height: Optional maximum height of each sub-cell generated.
474                Defaults to `None`.
475            markdown: Enables minimal markdown-like markup to render part
476                of text as bold / italics / strikethrough / underlined.
477                Supports `"\\"` as escape character. Defaults to `False`.
478            print_sh: Treat a soft-hyphen (`"\\u00ad"`) as a normal printable
479                character, instead of a line breaking opportunity. Defaults to
480                `False`.
481            wrapmode: [:py:attr:`fpdf.enums.WrapMode.WORD`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode)
482                for word based line wrapping (default) or
483                [:py:attr:`fpdf.enums.WrapMode.CHAR`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode)
484                for character based line wrapping.
485            dry_run: If `True`, does not output anything in the document.
486                Can be useful when combined with `output`. Defaults to
487                `False`.
488            output: Defines what this method returns. If several enum values are
489                joined, the result will be a tuple.
490            txt: [**DEPRECATED since v2.7.6**] string to print.
491            center: Center the cell horizontally on the page. Defaults to
492                `False`.
493            padding: Padding to apply around the text. Defaults to `0`.
494                When one value is specified, it applies the same padding to all
495                four sides.
496                When two values are specified, the first padding applies to the
497                top and bottom, the second to the left and right.
498                When three values are specified, the first padding applies to
499                the top, the second to the right and left, the third to the
500                bottom.
501                When four values are specified, the paddings apply to the top,
502                right, bottom, and left in that order (clockwise)
503                If padding for left or right ends up being non-zero then the
504                respective [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html)
505                is ignored. Center overrides values for horizontal padding.
506            first_line_indent: The indent of the first line. Defaults to `0`.
507
508        Returns:
509            A single value or a tuple, depending on the `output` parameter
510            value.
511
512        Raises:
513            FPDFException: If no font has been set before.
514            ValueError: If `w` or `h` is a string.
515        """  # noqa: DOC102
516        padding = Padding.new(padding)
517        wrapmode = WrapMode.coerce(wrapmode)
518
519        if split_only:
520            warnings.warn(
521                (
522                    'The parameter "split_only" is deprecated since v2.7.4.'
523                    ' Use instead dry_run=True and output="LINES".'
524                ),
525                DeprecationWarning,
526                stacklevel=fpdf.deprecation.get_stack_level(),
527            )
528        if dry_run or split_only:
529            with self._disable_writing():
530                return self.multi_cell(
531                    w=w,
532                    h=h,
533                    text=text,
534                    border=border,
535                    align=align,
536                    fill=fill,
537                    link=link,
538                    ln=ln,
539                    max_line_height=max_line_height,
540                    markdown=markdown,
541                    print_sh=print_sh,
542                    new_x=new_x,
543                    new_y=new_y,
544                    wrapmode=wrapmode,
545                    dry_run=False,
546                    split_only=False,
547                    output=MethodReturnValue.LINES if split_only else output,
548                    center=center,
549                    padding=padding,
550                    # CHANGE
551                    first_line_indent=first_line_indent,
552                )
553        if not self.font_family:
554            raise fpdf.errors.FPDFException(
555                "No font set, you need to call set_font() beforehand"
556            )
557        if isinstance(w, str) or isinstance(h, str):
558            raise ValueError(
559                "Parameter 'w' and 'h' must be numbers, not strings."
560                " You can omit them by passing string content with text="
561            )
562        new_x = XPos.coerce(new_x)
563        new_y = YPos.coerce(new_y)
564        if ln != "DEPRECATED":
565            # For backwards compatibility, if "ln" is used we overwrite
566            # "new_[xy]".
567            if ln == 0:
568                new_x = XPos.RIGHT
569                new_y = YPos.NEXT
570            elif ln == 1:
571                new_x = XPos.LMARGIN
572                new_y = YPos.NEXT
573            elif ln == 2:
574                new_x = XPos.LEFT
575                new_y = YPos.NEXT
576            elif ln == 3:
577                new_x = XPos.RIGHT
578                new_y = YPos.TOP
579            else:
580                raise ValueError(
581                    f'Invalid value for parameter "ln" ({ln}),'
582                    " must be an int between 0 and 3."
583                )
584            warnings.warn(
585                (
586                    f'The parameter "ln" is deprecated since v2.5.2.'
587                    f" Instead of ln={ln} use new_x=XPos.{new_x.name}, "
588                    f"new_y=YPos.{new_y.name}."
589                ),
590                DeprecationWarning,
591                stacklevel=fpdf.errors.get_stack_level(),
592            )
593        align = Align.coerce(align)
594
595        page_break_triggered = False
596
597        if h is None:
598            h = self.font_size
599
600        # If width is 0, set width to available width between margins
601        if w == 0:
602            w = self.w - self.r_margin - self.x
603
604        # Store the starting position before applying padding
605        prev_x, prev_y = self.x, self.y
606
607        # Apply padding to contents
608        # decrease maximum allowed width by padding
609        # shift the starting point by padding
610        maximum_allowed_width = w = w - padding.right - padding.left
611        clearance_margins: list[float] = []
612        # If we don't have padding on either side, we need a clearance margin.
613        if not padding.left:
614            clearance_margins.append(self.c_margin)
615        if not padding.right:
616            clearance_margins.append(self.c_margin)
617        if align != Align.X:
618            self.x += padding.left
619        self.y += padding.top
620
621        # Center overrides padding
622        if center:
623            self.x = (
624                self.w / 2
625                if align == Align.X
626                else self.l_margin + (self.epw - w) / 2
627            )
628            prev_x = self.x
629
630        # Calculate text length
631        text = self.normalize_text(text)
632        normalized_string = text.replace("\r", "")
633        styled_text_fragments = (
634            self._preload_bidirectional_text(normalized_string, markdown)
635            if self.text_shaping
636            else self._preload_font_styles(normalized_string, markdown)
637        )
638
639        prev_current_font = self.current_font
640        prev_font_style = self.font_style
641        prev_underline = self.underline
642        total_height: float = 0
643
644        text_lines: list[TextLine] = []
645        multi_line_break = MultiLineBreak(
646            styled_text_fragments,
647            maximum_allowed_width,
648            clearance_margins,
649            align=align,
650            print_sh=print_sh,
651            wrapmode=wrapmode,
652            # CHANGE
653            first_line_indent=first_line_indent,
654        )
655        text_line = multi_line_break.get_line()
656        while (text_line) is not None:
657            text_lines.append(text_line)
658            text_line = multi_line_break.get_line()
659
660        if (
661            not text_lines
662        ):  # ensure we display at least one cell - cf. issue #349
663            text_lines = [
664                TextLine(
665                    [],
666                    text_width=0,
667                    number_of_spaces=0,
668                    align=align,
669                    height=h,
670                    max_width=w,
671                    trailing_nl=False,
672                )
673            ]
674
675        if max_line_height is None or len(text_lines) == 1:
676            line_height = h
677        else:
678            line_height = min(h, max_line_height)
679
680        box_required = fill or border
681        page_break_triggered = False
682
683        for text_line_index, text_line in enumerate(text_lines):
684            start_of_new_page = self._perform_page_break_if_need_be(
685                h + padding.bottom
686            )
687            if start_of_new_page:
688                page_break_triggered = True
689                self.y += padding.top
690            # CHANGE
691            if text_line_index == 0:
692                self.x += first_line_indent
693            # END CHANGE
694
695            if box_required and (text_line_index == 0 or start_of_new_page):
696                # estimate how many cells can fit on this page
697                top_gap = self.y  # Top padding has already been added
698                bottom_gap = padding.bottom + self.b_margin
699                lines_before_break = int(
700                    (self.h - top_gap - bottom_gap) // line_height
701                )
702                # check how many cells should be rendered
703                num_lines = min(
704                    lines_before_break, len(text_lines) - text_line_index
705                )
706                box_height = max(
707                    h - text_line_index * line_height, num_lines * line_height
708                )
709                # render the box
710                x = self.x - (w / 2 if align == Align.X else 0)
711                draw_box_borders(
712                    self,
713                    x - padding.left,
714                    self.y - padding.top,
715                    # CHANGE
716                    x + w + padding.right + max(0, -first_line_indent),
717                    # END CHANGE
718                    self.y + box_height + padding.bottom,
719                    border,
720                    self.fill_color if fill else None,
721                )
722            is_last_line = text_line_index == len(text_lines) - 1
723            self._render_styled_text_line(
724                text_line,
725                h=line_height,
726                new_x=new_x if is_last_line else XPos.LEFT,
727                new_y=new_y if is_last_line else YPos.NEXT,
728                border=0,  # already rendered
729                fill=False,  # already rendered
730                link=link,
731                padding=Padding(0, padding.right, 0, padding.left),
732                prevent_font_change=markdown,
733            )
734            total_height += line_height
735            if not is_last_line and align == Align.X:
736                # prevent cumulative shift to the left
737                self.x = prev_x
738            # CHANGE
739            if text_line_index == 0:
740                self.x -= first_line_indent
741            # END CHANGE
742
743        if total_height < h:
744            # Move to the bottom of the multi_cell
745            if new_y == YPos.NEXT:
746                self.y += h - total_height
747            total_height = h
748
749        if page_break_triggered and new_y == YPos.TOP:
750            # When a page jump is performed and the requested y is TOP,
751            # pretend we started at the top of the text block on the new page.
752            # cf. test_multi_cell_table_with_automatic_page_break
753            prev_y = self.y
754
755        last_line = text_lines[-1]
756        if (
757            last_line
758            and last_line.trailing_nl
759            and new_y in (YPos.LAST, YPos.NEXT)
760        ):
761            # The line renderer can't handle trailing newlines in the text.
762            self.ln()
763
764        if new_y == YPos.TOP:  # We may have jumped a few lines -> reset
765            self.y = prev_y
766        elif new_y == YPos.NEXT:  # move down by bottom padding
767            self.y += padding.bottom
768
769        if markdown:
770            self.font_style = prev_font_style
771            self.current_font = prev_current_font
772            self.underline = prev_underline
773
774        if (
775            new_x == XPos.RIGHT
776        ):  # move right by right padding to align outer RHS edge
777            self.x += padding.right
778        elif (
779            new_x == XPos.LEFT
780        ):  # move left by left padding to align outer LHS edge
781            self.x -= padding.left
782
783        output = MethodReturnValue.coerce(output)
784        return_value = ()
785        if output & MethodReturnValue.PAGE_BREAK:
786            return_value += (page_break_triggered,)  # type: ignore[assignment]
787        if output & MethodReturnValue.LINES:
788            output_lines: list[str] = []
789            for text_line in text_lines:
790                characters: list[str] = []
791                for frag in text_line.fragments:
792                    characters.extend(frag.characters)
793                output_lines.append("".join(characters))
794            return_value += (output_lines,)  # type: ignore[assignment]
795        if output & MethodReturnValue.HEIGHT:
796            return_value += (total_height + padding.top + padding.bottom,)  # type: ignore[assignment]
797        if len(return_value) == 1:
798            return return_value[0]
799        return return_value  # type: ignore[return-value]
800
801    @overload
802    def output(  # type: ignore[overload-overlap]
803        self,
804        name: Literal[""] | None = "",
805        *,
806        linearize: bool = False,
807        output_producer_class: type[OutputProducer] = OutputProducer,
808    ) -> bytearray: ...
809
810    @overload
811    def output(
812        self,
813        name: os.PathLike[str] | str | BinaryIO,
814        *,
815        linearize: bool = False,
816        output_producer_class: type[OutputProducer] = OutputProducer,
817    ) -> None: ...
818
819    def output(
820        self,
821        name: os.PathLike[str] | BinaryIO | str | Literal[""] | None = "",
822        *,
823        linearize: bool = False,
824        output_producer_class: type[OutputProducer] = OutputProducer,
825    ) -> bytearray | None:
826        """Output PDF to some destination.
827
828        By default the bytearray buffer is returned.
829        If a `name` is given, the PDF is written to a new file.
830
831        Args:
832            name: Optional file object or file path where to save the PDF under.
833                Defaults to `""`.
834            linearize: Whether to use the
835                :py:class:`fpdf.output.LinearizedOutputProducer`. Defaults to
836                `False`.
837            output_producer_class: Use a custom class for PDF file generation.
838                Defaults to :py:class:`fpdf.output.OutputProducer`.
839
840        Returns:
841            If a `name` is given, the PDF will be written to a new file and
842            `None` will be returned. Else, a bytearray buffer is returned,
843            comprising the PDF.
844
845        Raises:
846            PDFAComplianceError: If the compliance requires at least one
847                embedded file.
848        """
849        # Clear cache of cached functions to free up memory after output
850        get_unicode_script.cache_clear()
851        # Finish document if necessary:
852        if not self.buffer:
853            if self.page == 0:
854                self.add_page()
855            # Generating final page footer:
856            self._render_footer()
857            # Generating .buffer based on .pages:
858            if self.toc_placeholder:
859                self._insert_table_of_contents()
860            # CHANGE
861            if self.index_placeholder:
862                self._insert_index()
863            # CHANGE
864            if self.str_alias_nb_pages:
865                for page in self.pages.values():
866                    for substitution_item in page.get_text_substitutions():
867                        page.contents = page.contents.replace(  # type: ignore[union-attr]
868                            substitution_item.get_placeholder_string().encode(
869                                "latin-1"
870                            ),
871                            substitution_item.render_text_substitution(
872                                str(self.pages_count)
873                            ).encode("latin-1"),
874                        )
875            for _, font in self.fonts.items():
876                if isinstance(font, TTFFont) and font.color_font:
877                    font.color_font.load_glyphs()
878            if self._compliance and self._compliance.profile == "PDFA":
879                if len(self._output_intents) == 0:
880                    self.add_output_intent(
881                        OutputIntentSubType.PDFA,
882                        output_condition_identifier="sRGB",
883                        output_condition="IEC 61966-2-1:1999",
884                        registry_name="http://www.color.org",
885                        dest_output_profile=PDFICCProfile(
886                            contents=builtin_srgb2014_bytes(),
887                            n=3,
888                            alternate="DeviceRGB",
889                        ),
890                        info="sRGB2014 (v2)",
891                    )
892                if (
893                    self._compliance.part == 4
894                    and self._compliance.conformance == "F"
895                    and len(self.embedded_files) == 0
896                ):
897                    msg = (
898                        f"{self._compliance.label} requires at least one "
899                        "embedded file"
900                    )
901                    raise fpdf.errors.PDFAComplianceError(msg)
902            if linearize:
903                output_producer_class = LinearizedOutputProducer
904            output_producer = output_producer_class(self)
905            self.buffer = output_producer.bufferize()
906        if name:
907            if isinstance(name, (str, os.PathLike)):
908                pathlib.Path(name).write_bytes(self.buffer)
909            else:
910                name.write(self.buffer)
911            return None
912        return self.buffer

PDF Generation Class.

FPDF( orientation: fpdf.enums.PageOrientation | str = <PageOrientation.PORTRAIT: 'P'>, unit: str | float = 'mm', format: str | tuple[float, float] = 'A4', font_cache_dir: Literal['DEPRECATED'] = 'DEPRECATED', *, enforce_compliance: fpdf.enums.DocumentCompliance | str | None = None)
 70    def __init__(
 71        self,
 72        orientation: PageOrientation | str = PageOrientation.PORTRAIT,
 73        unit: str | float = "mm",
 74        format: str | tuple[float, float] = "A4",
 75        font_cache_dir: Literal["DEPRECATED"] = "DEPRECATED",
 76        *,
 77        enforce_compliance: DocumentCompliance | str | None = None,
 78    ) -> None:
 79        """Initializes the :py:class:`FPDF`.
 80
 81        Args:
 82            orientation: Page orientation. Possible values are `"portrait"` (can
 83                be abbreviated `"P"`) or `"landscape"` (can be abbreviated
 84                `"L"`). Defaults to `"portrait"`.
 85            unit: Possible values are `"pt"`, `"mm"`, `"cm"`, `"in"`, or a
 86                number. A point equals 1/72 of an inch, that is to say about
 87                0.35 mm (an inch being 2.54 cm). This is a very common unit in
 88                typography; font sizes are expressed in this unit.
 89                If given a number, then it will be treated as the number of
 90                points per unit (eg. 72 = 1 in). Default to `"mm"`.
 91            format: Page format. Possible values are `"a3"`, `"a4"`, `"a5"`,
 92                `"letter"`, `"legal"` or a tuple `(width, height)` expressed in
 93                the given unit. Default to `"a4"`.
 94            font_cache_dir: [**DEPRECATED since v2.5.1**] unused.
 95            enforce_compliance: When enforce compliance is set, :py:class:`FPDF`
 96                actively prevents non-compliant operations and will raise errors
 97                if you try something forbidden for the selected profile.
 98                Defaults to `None`.
 99        """
100        super().__init__(
101            orientation=orientation,
102            unit=unit,
103            format=format,
104            font_cache_dir=font_cache_dir,
105            enforce_compliance=enforce_compliance,
106        )
107        self._concordance_list = None
108        if self.CONCORDANCE_FILE is not None:
109            self._concordance_list = ConcordanceList.from_file(
110                self.CONCORDANCE_FILE
111            )
112        self._index_allow_page_insertion = False
113        self._index_links = {}
114        self._index_parser = TextIndexParser(strict=self.STRICT_INDEX_MODE)
115        self.index_placeholder: IndexPlaceholder | None = None
116        """Index placeholder. Defaults to ``None``."""

Initializes the FPDF.

Arguments:
  • orientation: Page orientation. Possible values are "portrait" (can be abbreviated "P") or "landscape" (can be abbreviated "L"). Defaults to "portrait".
  • unit: Possible values are "pt", "mm", "cm", "in", or a number. A point equals 1/72 of an inch, that is to say about 0.35 mm (an inch being 2.54 cm). This is a very common unit in typography; font sizes are expressed in this unit. If given a number, then it will be treated as the number of points per unit (eg. 72 = 1 in). Default to "mm".
  • format: Page format. Possible values are "a3", "a4", "a5", "letter", "legal" or a tuple (width, height) expressed in the given unit. Default to "a4".
  • font_cache_dir: [DEPRECATED since v2.5.1] unused.
  • enforce_compliance: When enforce compliance is set, FPDF actively prevents non-compliant operations and will raise errors if you try something forbidden for the selected profile. Defaults to None.
CONCORDANCE_FILE: os.PathLike[str] | str | None = None

The path to a concordance file. Defaults to None.

STRICT_INDEX_MODE: bool = True

If True and an entry has a normal reference (locator) and a SEE-cross reference, a ValueError will be raised. Else, it will just be a warning. Defaults to True.

index_placeholder: fpdf2_textindex.pdf.IndexPlaceholder | None

Index placeholder. Defaults to None.

index_entries: list[TextIndexEntry]
305    @property
306    def index_entries(self) -> list[TextIndexEntry]:
307        """The (so far parsed) index entries."""
308        return self._index_parser.entries

The (so far parsed) index entries.

def add_index_entry( self, label_path: Iterable[str], sort_key: str | None = None) -> TextIndexEntry:
310    def add_index_entry(
311        self,
312        label_path: Iterable[str],
313        sort_key: str | None = None,
314    ) -> TextIndexEntry:
315        """Adds manually a text index entry.
316
317        Note: References (locators) to pages cannot be added manually, only
318            cross references.
319
320        Args:
321            label_path: The label path of the entry.
322            sort_key: The sort key of the entry. Defaults to `None`.
323
324        Returns:
325            The text index entry.
326        """
327        entry = self._index_parser.entry_at_label_path(label_path, create=True)
328        assert isinstance(entry, TextIndexEntry)
329        entry.sort_key = sort_key
330        return entry

Adds manually a text index entry.

Note: References (locators) to pages cannot be added manually, only cross references.

Arguments:
  • label_path: The label path of the entry.
  • sort_key: The sort key of the entry. Defaults to None.
Returns:

The text index entry.

@fpdf.fpdf.check_page
def insert_index_placeholder( self, render_index_function: Callable[FPDF, list[TextIndexEntry], None], *, pages: int = 1, allow_extra_pages: bool = False, reset_page_indices: bool = True) -> None:
332    @fpdf.fpdf.check_page
333    def insert_index_placeholder(
334        self,
335        render_index_function: Callable[["FPDF", list[TextIndexEntry]], None],
336        *,
337        pages: int = 1,
338        allow_extra_pages: bool = False,
339        reset_page_indices: bool = True,
340    ) -> None:
341        """Configures Text Index rendering at the end of the document
342        generation, and reserves some vertical space right now in order to
343        insert it. At least one page break is triggered by this method.
344
345        Args:
346            render_index_function: A function that will be invoked to  render
347                the Index. This function will receive 2 parameters:
348                    `pdf`: an instance of :py:class:`fpdf2_textindex.pdf.FPDF`;
349                    `entries`: a list of
350                        :py:class:`fpdf2_textindex.interface.TextIndexEntry`s.
351            pages: The number of pages that the Index will span, including the
352                current one. As many page breaks as the value of this argument
353                will occur immediately after calling this method. Defaults to
354                `1`.
355            allow_extra_pages: If set to `True`, allows for an unlimited
356                number of extra pages in the Text Index, which may cause
357                discrepancies with pre-rendered page numbers.
358                For consistent numbering, using page labels to create a separate
359                numbering style for the Index is recommended. Defaults to
360                `False`.
361            reset_page_indices : Whether to reset the pages indices after the
362                Text Index. Defaults to `True`.
363
364        Raises:
365            FPDFException: If an index placeholder has been inserted before.
366            TypeError: If `render_index_function` is not callable.
367            ValueError: If ``pages`` is less than `1`.
368        """
369        if not callable(render_index_function):
370            msg = (
371                f"The first argument must be a callable, got: "
372                f"{type(render_index_function)!s:s}"
373            )
374            raise TypeError(msg)
375        if pages < 1:
376            msg = (
377                f"'pages' parameter must be equal or greater than 1: {pages:d}"
378            )
379            raise ValueError(msg)
380        if self.index_placeholder:
381            msg = (
382                "A placeholder for the index has already been defined on page "
383                f"{self.index_placeholder.start_page}"
384            )
385            raise fpdf.errors.FPDFException(msg)
386        self.index_placeholder = IndexPlaceholder(
387            render_index_function,
388            self.page,
389            self.y,
390            self.cur_orientation,
391            pages,
392            reset_page_indices,
393        )
394        self._index_allow_page_insertion = allow_extra_pages
395        for _ in range(pages):
396            self._perform_page_break()

Configures Text Index rendering at the end of the document generation, and reserves some vertical space right now in order to insert it. At least one page break is triggered by this method.

Arguments:
  • render_index_function: A function that will be invoked to render the Index. This function will receive 2 parameters: pdf: an instance of fpdf2_textindex.pdf.FPDF; entries: a list of fpdf2_textindex.interface.TextIndexEntrys.
  • pages: The number of pages that the Index will span, including the current one. As many page breaks as the value of this argument will occur immediately after calling this method. Defaults to 1.
  • allow_extra_pages: If set to True, allows for an unlimited number of extra pages in the Text Index, which may cause discrepancies with pre-rendered page numbers. For consistent numbering, using page labels to create a separate numbering style for the Index is recommended. Defaults to False.
  • reset_page_indices : Whether to reset the pages indices after the Text Index. Defaults to True.
Raises:
  • FPDFException: If an index placeholder has been inserted before.
  • TypeError: If render_index_function is not callable.
  • ValueError: If pages is less than 1.
@fpdf.fpdf.check_page
@fpdf.deprecation.support_deprecated_txt_arg
def multi_cell( self, w: float, h: float | None = None, text: str = '', border: Union[Literal[0, 1], str] = 0, align: fpdf.enums.Align | str = <Align.J: 'JUSTIFY'>, fill: bool = False, split_only: bool = False, link: int | str | None = None, ln: Literal['DEPRECATED'] = 'DEPRECATED', max_line_height: float | None = None, markdown: bool = False, print_sh: bool = False, new_x: fpdf.enums.XPos | str = <XPos.RIGHT: 'RIGHT'>, new_y: fpdf.enums.YPos | str = <YPos.NEXT: 'NEXT'>, wrapmode: fpdf.enums.WrapMode = <WrapMode.WORD: 'WORD'>, dry_run: bool = False, output: fpdf.enums.MethodReturnValue | str = <MethodReturnValue.PAGE_BREAK: 1>, center: bool = False, padding: fpdf.util.Padding | Sequence[int] | int = 0, first_line_indent: float = 0) -> bool | list[str] | float | tuple[bool, list[str]] | tuple[bool, float] | tuple[list[str], float] | tuple[bool, list[str], float]:
398    @fpdf.fpdf.check_page
399    @fpdf.deprecation.support_deprecated_txt_arg
400    def multi_cell(
401        self,
402        w: float,
403        h: float | None = None,
404        text: str = "",
405        border: Literal[0, 1] | str = 0,
406        align: Align | str = Align.J,
407        fill: bool = False,
408        split_only: bool = False,  # DEPRECATED
409        link: int | str | None = None,
410        ln: Literal["DEPRECATED"] = "DEPRECATED",
411        max_line_height: float | None = None,
412        markdown: bool = False,
413        print_sh: bool = False,
414        new_x: XPos | str = XPos.RIGHT,
415        new_y: YPos | str = YPos.NEXT,
416        wrapmode: WrapMode = WrapMode.WORD,
417        dry_run: bool = False,
418        output: MethodReturnValue | str = MethodReturnValue.PAGE_BREAK,
419        center: bool = False,
420        padding: Padding | Sequence[int] | int = 0,
421        first_line_indent: float = 0,
422    ) -> fpdf.FPDF.MultiCellResult:
423        r"""This method allows printing text with line breaks.
424
425        They can be automatic (breaking at the most recent space or soft-hyphen
426        character) as soon as the text reaches the right border of the cell, or
427        explicit (via the `"\\n"` character). As many cells as necessary are
428        stacked, one below the other. Text can be aligned, centered or
429        justified. The cell block can be framed and the background painted. A
430        cell has an horizontal padding, on the left & right sides, defined by
431        the
432        [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html)-property.
433
434        Note:
435            Using
436            `new_x=XPos.RIGHT, new_y=XPos.TOP, maximum height=pdf.font_size`
437            is useful to build tables with multiline text in cells.
438
439        Args:
440            w: Cell width. If `0`, they extend up to the right margin of the
441                page.
442            h: Height of a single line of text. Defaults to `None`, meaning to
443                use the current font size.
444            text: Text to print.
445            border: Indicates if borders must be drawn around the cell.
446                The value can be either a number (`0`: no border; `1`:
447                frame) or a string containing some or all of the following
448                characters (in any order):
449                `"L"`: left,
450                `"T"`: top,
451                `"R"`: right,
452                `"B"`: bottom.
453                Defaults to `0`.
454            align: Sets the text alignment inside the cell.
455                Possible values are:
456                `"J"`: justify (default value),
457                `"L"` / `""`: left align,
458                `"C"`: center,
459                `"X"`: center around current x-position, or
460                `"R"`: right align.
461            fill: Indicates if the cell background must be painted (`True`)
462                or transparent (`False`). Defaults to `False`.
463            split_only: **DEPRECATED since 2.7.4**: Use `dry_run=True` and
464                `output=("LINES",)` instead.
465            link: Optional link to add on the cell, internal (identifier
466                returned by [:py:meth:`fpdf.FPDF.add_link`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.add_link)
467                or external URL.
468            new_x: New current position in x after the call. Defaults to
469                [:py:attr:`fpdf.XPos.RIGHT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.XPos).
470            new_y: New current position in y after the call. Defaults to
471                [:py:attr:`fpdf.YPos.NEXT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.YPos).
472            ln: **DEPRECATED since 2.5.1**: Use `new_x` and `new_y` instead.
473            max_line_height: Optional maximum height of each sub-cell generated.
474                Defaults to `None`.
475            markdown: Enables minimal markdown-like markup to render part
476                of text as bold / italics / strikethrough / underlined.
477                Supports `"\\"` as escape character. Defaults to `False`.
478            print_sh: Treat a soft-hyphen (`"\\u00ad"`) as a normal printable
479                character, instead of a line breaking opportunity. Defaults to
480                `False`.
481            wrapmode: [:py:attr:`fpdf.enums.WrapMode.WORD`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode)
482                for word based line wrapping (default) or
483                [:py:attr:`fpdf.enums.WrapMode.CHAR`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode)
484                for character based line wrapping.
485            dry_run: If `True`, does not output anything in the document.
486                Can be useful when combined with `output`. Defaults to
487                `False`.
488            output: Defines what this method returns. If several enum values are
489                joined, the result will be a tuple.
490            txt: [**DEPRECATED since v2.7.6**] string to print.
491            center: Center the cell horizontally on the page. Defaults to
492                `False`.
493            padding: Padding to apply around the text. Defaults to `0`.
494                When one value is specified, it applies the same padding to all
495                four sides.
496                When two values are specified, the first padding applies to the
497                top and bottom, the second to the left and right.
498                When three values are specified, the first padding applies to
499                the top, the second to the right and left, the third to the
500                bottom.
501                When four values are specified, the paddings apply to the top,
502                right, bottom, and left in that order (clockwise)
503                If padding for left or right ends up being non-zero then the
504                respective [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html)
505                is ignored. Center overrides values for horizontal padding.
506            first_line_indent: The indent of the first line. Defaults to `0`.
507
508        Returns:
509            A single value or a tuple, depending on the `output` parameter
510            value.
511
512        Raises:
513            FPDFException: If no font has been set before.
514            ValueError: If `w` or `h` is a string.
515        """  # noqa: DOC102
516        padding = Padding.new(padding)
517        wrapmode = WrapMode.coerce(wrapmode)
518
519        if split_only:
520            warnings.warn(
521                (
522                    'The parameter "split_only" is deprecated since v2.7.4.'
523                    ' Use instead dry_run=True and output="LINES".'
524                ),
525                DeprecationWarning,
526                stacklevel=fpdf.deprecation.get_stack_level(),
527            )
528        if dry_run or split_only:
529            with self._disable_writing():
530                return self.multi_cell(
531                    w=w,
532                    h=h,
533                    text=text,
534                    border=border,
535                    align=align,
536                    fill=fill,
537                    link=link,
538                    ln=ln,
539                    max_line_height=max_line_height,
540                    markdown=markdown,
541                    print_sh=print_sh,
542                    new_x=new_x,
543                    new_y=new_y,
544                    wrapmode=wrapmode,
545                    dry_run=False,
546                    split_only=False,
547                    output=MethodReturnValue.LINES if split_only else output,
548                    center=center,
549                    padding=padding,
550                    # CHANGE
551                    first_line_indent=first_line_indent,
552                )
553        if not self.font_family:
554            raise fpdf.errors.FPDFException(
555                "No font set, you need to call set_font() beforehand"
556            )
557        if isinstance(w, str) or isinstance(h, str):
558            raise ValueError(
559                "Parameter 'w' and 'h' must be numbers, not strings."
560                " You can omit them by passing string content with text="
561            )
562        new_x = XPos.coerce(new_x)
563        new_y = YPos.coerce(new_y)
564        if ln != "DEPRECATED":
565            # For backwards compatibility, if "ln" is used we overwrite
566            # "new_[xy]".
567            if ln == 0:
568                new_x = XPos.RIGHT
569                new_y = YPos.NEXT
570            elif ln == 1:
571                new_x = XPos.LMARGIN
572                new_y = YPos.NEXT
573            elif ln == 2:
574                new_x = XPos.LEFT
575                new_y = YPos.NEXT
576            elif ln == 3:
577                new_x = XPos.RIGHT
578                new_y = YPos.TOP
579            else:
580                raise ValueError(
581                    f'Invalid value for parameter "ln" ({ln}),'
582                    " must be an int between 0 and 3."
583                )
584            warnings.warn(
585                (
586                    f'The parameter "ln" is deprecated since v2.5.2.'
587                    f" Instead of ln={ln} use new_x=XPos.{new_x.name}, "
588                    f"new_y=YPos.{new_y.name}."
589                ),
590                DeprecationWarning,
591                stacklevel=fpdf.errors.get_stack_level(),
592            )
593        align = Align.coerce(align)
594
595        page_break_triggered = False
596
597        if h is None:
598            h = self.font_size
599
600        # If width is 0, set width to available width between margins
601        if w == 0:
602            w = self.w - self.r_margin - self.x
603
604        # Store the starting position before applying padding
605        prev_x, prev_y = self.x, self.y
606
607        # Apply padding to contents
608        # decrease maximum allowed width by padding
609        # shift the starting point by padding
610        maximum_allowed_width = w = w - padding.right - padding.left
611        clearance_margins: list[float] = []
612        # If we don't have padding on either side, we need a clearance margin.
613        if not padding.left:
614            clearance_margins.append(self.c_margin)
615        if not padding.right:
616            clearance_margins.append(self.c_margin)
617        if align != Align.X:
618            self.x += padding.left
619        self.y += padding.top
620
621        # Center overrides padding
622        if center:
623            self.x = (
624                self.w / 2
625                if align == Align.X
626                else self.l_margin + (self.epw - w) / 2
627            )
628            prev_x = self.x
629
630        # Calculate text length
631        text = self.normalize_text(text)
632        normalized_string = text.replace("\r", "")
633        styled_text_fragments = (
634            self._preload_bidirectional_text(normalized_string, markdown)
635            if self.text_shaping
636            else self._preload_font_styles(normalized_string, markdown)
637        )
638
639        prev_current_font = self.current_font
640        prev_font_style = self.font_style
641        prev_underline = self.underline
642        total_height: float = 0
643
644        text_lines: list[TextLine] = []
645        multi_line_break = MultiLineBreak(
646            styled_text_fragments,
647            maximum_allowed_width,
648            clearance_margins,
649            align=align,
650            print_sh=print_sh,
651            wrapmode=wrapmode,
652            # CHANGE
653            first_line_indent=first_line_indent,
654        )
655        text_line = multi_line_break.get_line()
656        while (text_line) is not None:
657            text_lines.append(text_line)
658            text_line = multi_line_break.get_line()
659
660        if (
661            not text_lines
662        ):  # ensure we display at least one cell - cf. issue #349
663            text_lines = [
664                TextLine(
665                    [],
666                    text_width=0,
667                    number_of_spaces=0,
668                    align=align,
669                    height=h,
670                    max_width=w,
671                    trailing_nl=False,
672                )
673            ]
674
675        if max_line_height is None or len(text_lines) == 1:
676            line_height = h
677        else:
678            line_height = min(h, max_line_height)
679
680        box_required = fill or border
681        page_break_triggered = False
682
683        for text_line_index, text_line in enumerate(text_lines):
684            start_of_new_page = self._perform_page_break_if_need_be(
685                h + padding.bottom
686            )
687            if start_of_new_page:
688                page_break_triggered = True
689                self.y += padding.top
690            # CHANGE
691            if text_line_index == 0:
692                self.x += first_line_indent
693            # END CHANGE
694
695            if box_required and (text_line_index == 0 or start_of_new_page):
696                # estimate how many cells can fit on this page
697                top_gap = self.y  # Top padding has already been added
698                bottom_gap = padding.bottom + self.b_margin
699                lines_before_break = int(
700                    (self.h - top_gap - bottom_gap) // line_height
701                )
702                # check how many cells should be rendered
703                num_lines = min(
704                    lines_before_break, len(text_lines) - text_line_index
705                )
706                box_height = max(
707                    h - text_line_index * line_height, num_lines * line_height
708                )
709                # render the box
710                x = self.x - (w / 2 if align == Align.X else 0)
711                draw_box_borders(
712                    self,
713                    x - padding.left,
714                    self.y - padding.top,
715                    # CHANGE
716                    x + w + padding.right + max(0, -first_line_indent),
717                    # END CHANGE
718                    self.y + box_height + padding.bottom,
719                    border,
720                    self.fill_color if fill else None,
721                )
722            is_last_line = text_line_index == len(text_lines) - 1
723            self._render_styled_text_line(
724                text_line,
725                h=line_height,
726                new_x=new_x if is_last_line else XPos.LEFT,
727                new_y=new_y if is_last_line else YPos.NEXT,
728                border=0,  # already rendered
729                fill=False,  # already rendered
730                link=link,
731                padding=Padding(0, padding.right, 0, padding.left),
732                prevent_font_change=markdown,
733            )
734            total_height += line_height
735            if not is_last_line and align == Align.X:
736                # prevent cumulative shift to the left
737                self.x = prev_x
738            # CHANGE
739            if text_line_index == 0:
740                self.x -= first_line_indent
741            # END CHANGE
742
743        if total_height < h:
744            # Move to the bottom of the multi_cell
745            if new_y == YPos.NEXT:
746                self.y += h - total_height
747            total_height = h
748
749        if page_break_triggered and new_y == YPos.TOP:
750            # When a page jump is performed and the requested y is TOP,
751            # pretend we started at the top of the text block on the new page.
752            # cf. test_multi_cell_table_with_automatic_page_break
753            prev_y = self.y
754
755        last_line = text_lines[-1]
756        if (
757            last_line
758            and last_line.trailing_nl
759            and new_y in (YPos.LAST, YPos.NEXT)
760        ):
761            # The line renderer can't handle trailing newlines in the text.
762            self.ln()
763
764        if new_y == YPos.TOP:  # We may have jumped a few lines -> reset
765            self.y = prev_y
766        elif new_y == YPos.NEXT:  # move down by bottom padding
767            self.y += padding.bottom
768
769        if markdown:
770            self.font_style = prev_font_style
771            self.current_font = prev_current_font
772            self.underline = prev_underline
773
774        if (
775            new_x == XPos.RIGHT
776        ):  # move right by right padding to align outer RHS edge
777            self.x += padding.right
778        elif (
779            new_x == XPos.LEFT
780        ):  # move left by left padding to align outer LHS edge
781            self.x -= padding.left
782
783        output = MethodReturnValue.coerce(output)
784        return_value = ()
785        if output & MethodReturnValue.PAGE_BREAK:
786            return_value += (page_break_triggered,)  # type: ignore[assignment]
787        if output & MethodReturnValue.LINES:
788            output_lines: list[str] = []
789            for text_line in text_lines:
790                characters: list[str] = []
791                for frag in text_line.fragments:
792                    characters.extend(frag.characters)
793                output_lines.append("".join(characters))
794            return_value += (output_lines,)  # type: ignore[assignment]
795        if output & MethodReturnValue.HEIGHT:
796            return_value += (total_height + padding.top + padding.bottom,)  # type: ignore[assignment]
797        if len(return_value) == 1:
798            return return_value[0]
799        return return_value  # type: ignore[return-value]

This method allows printing text with line breaks.

They can be automatic (breaking at the most recent space or soft-hyphen character) as soon as the text reaches the right border of the cell, or explicit (via the "\\n" character). As many cells as necessary are stacked, one below the other. Text can be aligned, centered or justified. The cell block can be framed and the background painted. A cell has an horizontal padding, on the left & right sides, defined by the fpdf.FPDF.c_margin-property.

Note:

Using new_x=XPos.RIGHT, new_y=XPos.TOP, maximum height=pdf.font_size is useful to build tables with multiline text in cells.

Arguments:
  • w: Cell width. If 0, they extend up to the right margin of the page.
  • h: Height of a single line of text. Defaults to None, meaning to use the current font size.
  • text: Text to print.
  • border: Indicates if borders must be drawn around the cell. The value can be either a number (0: no border; 1: frame) or a string containing some or all of the following characters (in any order): "L": left, "T": top, "R": right, "B": bottom. Defaults to 0.
  • align: Sets the text alignment inside the cell. Possible values are: "J": justify (default value), "L" / "": left align, "C": center, "X": center around current x-position, or "R": right align.
  • fill: Indicates if the cell background must be painted (True) or transparent (False). Defaults to False.
  • split_only: DEPRECATED since 2.7.4: Use dry_run=True and output=("LINES",) instead.
  • link: Optional link to add on the cell, internal (identifier returned by .fpdf.FPDF.add_link">fpdf.FPDF.add_link() or external URL.
  • new_x: New current position in x after the call. Defaults to fpdf.XPos.RIGHT.
  • new_y: New current position in y after the call. Defaults to fpdf.YPos.NEXT.
  • ln: DEPRECATED since 2.5.1: Use new_x and new_y instead.
  • max_line_height: Optional maximum height of each sub-cell generated. Defaults to None.
  • markdown: Enables minimal markdown-like markup to render part of text as bold / italics / strikethrough / underlined. Supports "\\" as escape character. Defaults to False.
  • print_sh: Treat a soft-hyphen ("\\u00ad") as a normal printable character, instead of a line breaking opportunity. Defaults to False.
  • wrapmode: fpdf.enums.WrapMode.WORD for word based line wrapping (default) or fpdf.enums.WrapMode.CHAR for character based line wrapping.
  • dry_run: If True, does not output anything in the document. Can be useful when combined with output. Defaults to False.
  • output: Defines what this method returns. If several enum values are joined, the result will be a tuple.
  • txt: [DEPRECATED since v2.7.6] string to print.
  • center: Center the cell horizontally on the page. Defaults to False.
  • padding: Padding to apply around the text. Defaults to 0. When one value is specified, it applies the same padding to all four sides. When two values are specified, the first padding applies to the top and bottom, the second to the left and right. When three values are specified, the first padding applies to the top, the second to the right and left, the third to the bottom. When four values are specified, the paddings apply to the top, right, bottom, and left in that order (clockwise) If padding for left or right ends up being non-zero then the respective fpdf.FPDF.c_margin is ignored. Center overrides values for horizontal padding.
  • first_line_indent: The indent of the first line. Defaults to 0.
Returns:

A single value or a tuple, depending on the output parameter value.

Raises:
  • FPDFException: If no font has been set before.
  • ValueError: If w or h is a string.
def output( self, name: Union[os.PathLike[str], BinaryIO, str, Literal[''], NoneType] = '', *, linearize: bool = False, output_producer_class: type[fpdf.output.OutputProducer] = <class 'fpdf.output.OutputProducer'>) -> bytearray | None:
819    def output(
820        self,
821        name: os.PathLike[str] | BinaryIO | str | Literal[""] | None = "",
822        *,
823        linearize: bool = False,
824        output_producer_class: type[OutputProducer] = OutputProducer,
825    ) -> bytearray | None:
826        """Output PDF to some destination.
827
828        By default the bytearray buffer is returned.
829        If a `name` is given, the PDF is written to a new file.
830
831        Args:
832            name: Optional file object or file path where to save the PDF under.
833                Defaults to `""`.
834            linearize: Whether to use the
835                :py:class:`fpdf.output.LinearizedOutputProducer`. Defaults to
836                `False`.
837            output_producer_class: Use a custom class for PDF file generation.
838                Defaults to :py:class:`fpdf.output.OutputProducer`.
839
840        Returns:
841            If a `name` is given, the PDF will be written to a new file and
842            `None` will be returned. Else, a bytearray buffer is returned,
843            comprising the PDF.
844
845        Raises:
846            PDFAComplianceError: If the compliance requires at least one
847                embedded file.
848        """
849        # Clear cache of cached functions to free up memory after output
850        get_unicode_script.cache_clear()
851        # Finish document if necessary:
852        if not self.buffer:
853            if self.page == 0:
854                self.add_page()
855            # Generating final page footer:
856            self._render_footer()
857            # Generating .buffer based on .pages:
858            if self.toc_placeholder:
859                self._insert_table_of_contents()
860            # CHANGE
861            if self.index_placeholder:
862                self._insert_index()
863            # CHANGE
864            if self.str_alias_nb_pages:
865                for page in self.pages.values():
866                    for substitution_item in page.get_text_substitutions():
867                        page.contents = page.contents.replace(  # type: ignore[union-attr]
868                            substitution_item.get_placeholder_string().encode(
869                                "latin-1"
870                            ),
871                            substitution_item.render_text_substitution(
872                                str(self.pages_count)
873                            ).encode("latin-1"),
874                        )
875            for _, font in self.fonts.items():
876                if isinstance(font, TTFFont) and font.color_font:
877                    font.color_font.load_glyphs()
878            if self._compliance and self._compliance.profile == "PDFA":
879                if len(self._output_intents) == 0:
880                    self.add_output_intent(
881                        OutputIntentSubType.PDFA,
882                        output_condition_identifier="sRGB",
883                        output_condition="IEC 61966-2-1:1999",
884                        registry_name="http://www.color.org",
885                        dest_output_profile=PDFICCProfile(
886                            contents=builtin_srgb2014_bytes(),
887                            n=3,
888                            alternate="DeviceRGB",
889                        ),
890                        info="sRGB2014 (v2)",
891                    )
892                if (
893                    self._compliance.part == 4
894                    and self._compliance.conformance == "F"
895                    and len(self.embedded_files) == 0
896                ):
897                    msg = (
898                        f"{self._compliance.label} requires at least one "
899                        "embedded file"
900                    )
901                    raise fpdf.errors.PDFAComplianceError(msg)
902            if linearize:
903                output_producer_class = LinearizedOutputProducer
904            output_producer = output_producer_class(self)
905            self.buffer = output_producer.bufferize()
906        if name:
907            if isinstance(name, (str, os.PathLike)):
908                pathlib.Path(name).write_bytes(self.buffer)
909            else:
910                name.write(self.buffer)
911            return None
912        return self.buffer

Output PDF to some destination.

By default the bytearray buffer is returned. If a name is given, the PDF is written to a new file.

Arguments:
  • name: Optional file object or file path where to save the PDF under. Defaults to "".
  • linearize: Whether to use the fpdf.output.LinearizedOutputProducer. Defaults to False.
  • output_producer_class: Use a custom class for PDF file generation. Defaults to fpdf.output.OutputProducer.
Returns:

If a name is given, the PDF will be written to a new file and None will be returned. Else, a bytearray buffer is returned, comprising the PDF.

Raises:
  • PDFAComplianceError: If the compliance requires at least one embedded file.
@dataclasses.dataclass(kw_only=True, slots=True)
class LinkLocation:
52@dataclasses.dataclass(kw_only=True, slots=True)
53class LinkLocation:
54    """Link Location."""
55
56    page: int
57    """The page the link is referened/used on."""
58
59    x: float
60    """The `x`-position on the page."""
61
62    y: float
63    """The `y`-position on the page."""
64
65    w: float
66    """The width the link has on the page."""
67
68    h: float
69    """The height the link has on the page."""

Link Location.

LinkLocation(*, page: int, x: float, y: float, w: float, h: float)
page: int

The page the link is referened/used on.

x: float

The x-position on the page.

y: float

The y-position on the page.

w: float

The width the link has on the page.

h: float

The height the link has on the page.

@dataclasses.dataclass(kw_only=True, slots=True)
class Reference:
258@dataclasses.dataclass(kw_only=True, slots=True)
259class Reference:
260    """Reference."""
261
262    start_id: int
263    """The start id of the reference."""
264
265    start_suffix: str | None = None
266    """The start suffix of the reference or `None`."""
267
268    start_location: LinkLocation | None = dataclasses.field(
269        default=None, init=False
270    )
271    """The start (link) location in the document the reference is set at."""
272
273    end_id: int | None = dataclasses.field(default=None, init=False)
274    """The end id of the reference or `None`."""
275
276    end_suffix: str | None = dataclasses.field(default=None, init=False)
277    """The end suffix of the reference or `None`."""
278
279    end_location: LinkLocation | None = dataclasses.field(
280        default=None, init=False
281    )
282    """The end (link) location in the document the reference is set at."""
283
284    locator_emphasis: bool = False
285    """Whether to emphasize the locator (page number) of the reference in the
286    text index (`True`) or not (`False`)."""
287
288    @property
289    def start_link(self) -> str:
290        """The start link in the document that must be set in the text index to
291        lead from the text to the text index.
292        """
293        return f"{const.INDEX_ID_PREFIX:s}{self.start_id:d}"
294
295    @property
296    def end_link(self) -> str | None:
297        """The end link in the document that must be set in the text index to
298        lead from the text to the text index. In case of no end id, the end link
299        will be `None`.
300        """
301        if self.end_id is None:
302            return None
303        return f"{const.INDEX_ID_PREFIX:s}{self.end_id:d}"

Reference.

Reference( *, start_id: int, start_suffix: str | None = None, locator_emphasis: bool = False)
start_id: int

The start id of the reference.

start_suffix: str | None

The start suffix of the reference or None.

start_location: LinkLocation | None

The start (link) location in the document the reference is set at.

end_id: int | None

The end id of the reference or None.

end_suffix: str | None

The end suffix of the reference or None.

end_location: LinkLocation | None

The end (link) location in the document the reference is set at.

locator_emphasis: bool

Whether to emphasize the locator (page number) of the reference in the text index (True) or not (False).

@dataclasses.dataclass(kw_only=True, repr=False, slots=True)
class TextIndexEntry(fpdf2_textindex.interface.Node):
306@dataclasses.dataclass(kw_only=True, repr=False, slots=True)
307class TextIndexEntry(Node):
308    """Text Index Entry."""
309
310    references: list[Reference] = dataclasses.field(
311        default_factory=list, init=False
312    )
313    """The references."""
314
315    cross_references: list[CrossReference] = dataclasses.field(
316        default_factory=list, init=False
317    )
318    """The cross references."""
319
320    sort_key: str | None = dataclasses.field(default=None, init=False)
321    """The sort key."""
322
323    def __hash__(self) -> int:
324        return hash((self.id, self.label))
325
326    @property
327    def children(self) -> list[TextIndexEntry]:
328        """The child entries."""
329        return sorted(self._children, key=lambda c: c.sort_label)
330
331    @property
332    def sort_label(self) -> str:
333        """The sort label of the entry."""
334        label = self.label
335        label = MDEmphasis.remove(self.label) if self.label else "\uffff"
336        if self.sort_key:
337            label = self.sort_key + label
338        return label.lower()
339
340    def add_cross_reference(
341        self,
342        id: int,
343        cross_ref_type: CrossReferenceType,
344        label_path: Iterable[str],
345        *,
346        strict: bool = True,
347    ) -> None:
348        """Adds a cross reference to the entry.
349
350        Args:
351            id: The id of the cross reference.
352            cross_ref_type: The type of the cross reference.
353            label_path: The label path of the cross reference.
354            strict: Whether to raise a `ValueError` if adding a SEE-cross
355                reference to an entry with former "normal" reference (locator).
356                Else, it will just be a warning and the SEE-cross reference will
357                be automatically converted to SEE ALSO. Defaults to `True`.
358
359        Raises:
360            ValueError: If `strict=True` and adding a SEE-cross reference to
361                an entry with former "normal" reference (locator).
362        """
363        if self.references and cross_ref_type == CrossReferenceType.SEE:
364            if strict:
365                msg = (
366                    f"cannot add a SEE-cross reference to entry "
367                    f"{self.joined_label_path!r} with former reference "
368                    f"(locator)"
369                )
370                raise ValueError(msg)
371            LOGGER.warning(
372                "Adding a SEE-cross reference to entry %r with former "
373                "reference (locator); cross reference will be converted to SEE "
374                "ALSO",
375                self.joined_label_path,
376            )
377            cross_ref_type = CrossReferenceType.ALSO
378        label_path = tuple(label_path)
379        if len(self.cross_references) > 0:
380            for cr in self.cross_references:
381                if cr.type == cross_ref_type and cr.label_path == label_path:
382                    return
383        self.cross_references.append(
384            CrossReference(id=id, type=cross_ref_type, label_path=label_path)
385        )
386
387    def add_reference(
388        self,
389        start_id: int,
390        *,
391        locator_emphasis: bool = False,
392        start_suffix: str | None = None,
393        strict: bool = True,
394    ) -> None:
395        """Adds a reference (locator) to the entry.
396
397        Args:
398            start_id: The start id of the reference.
399            locator_emphasis: Whether to emphasize the locator of the reference.
400                Defaults to `False`.
401            start_suffix: The start suffix of the reference. Defaults to
402                `None`.
403            strict: Whether to raise a `ValueError` if adding a SEE-cross
404                reference to an entry with former "normal" reference (locator).
405                Else, it will just be a warning and the SEE-cross reference will
406                be automatically converted to SEE ALSO. Defaults to `True`.
407
408        Raises:
409            ValueError: If `strict=True` and adding a reference locator to an
410                entry with former SEE-cross reference.
411        """
412        if any(
413            cr.type == CrossReferenceType.SEE for cr in self.cross_references
414        ):
415            if strict:
416                msg = (
417                    f"cannot add a reference (locator) to entry "
418                    f"{self.joined_label_path!r} with former SEE-cross "
419                    f"reference"
420                )
421                raise ValueError(msg)
422            LOGGER.warning(
423                "Adding a reference (locator) to entry %r with former SEE-"
424                "cross reference(s); cross reference(s) will be converted to "
425                "SEE ALSO",
426                self.joined_label_path,
427            )
428            for cr in self.cross_references:
429                if cr.type == CrossReferenceType.SEE:
430                    cr.type = CrossReferenceType.ALSO
431
432        self.references.append(
433            Reference(
434                start_id=start_id,
435                start_suffix=start_suffix,
436                locator_emphasis=locator_emphasis,
437            )
438        )
439
440    def update_latest_reference_end(
441        self,
442        end_id: int,
443        end_suffix: str | None = None,
444    ) -> None:
445        """Updates the end of the latest reference.
446
447        Args:
448            end_id: The end id of the latest reference.
449            end_suffix: The end suffix of the latest reference. Defaults to
450                `None`.
451
452        Raises:
453            RuntimeError: If there has been no reference before.
454        """
455        if len(self.references) == 0:
456            msg = "cannot update latest reference end without reference"
457            raise RuntimeError(msg)
458        self.references[-1].end_id = end_id
459        if end_suffix:
460            self.references[-1].end_suffix = end_suffix

Text Index Entry.

TextIndexEntry(*, label: str, parent: Optional[typing_extensions.Self] = None)
references: list[Reference]

The references.

cross_references: list[CrossReference]

The cross references.

sort_key: str | None

The sort key.

children: list[TextIndexEntry]
326    @property
327    def children(self) -> list[TextIndexEntry]:
328        """The child entries."""
329        return sorted(self._children, key=lambda c: c.sort_label)

The child entries.

sort_label: str
331    @property
332    def sort_label(self) -> str:
333        """The sort label of the entry."""
334        label = self.label
335        label = MDEmphasis.remove(self.label) if self.label else "\uffff"
336        if self.sort_key:
337            label = self.sort_key + label
338        return label.lower()

The sort label of the entry.

def add_cross_reference( self, id: int, cross_ref_type: CrossReferenceType, label_path: Iterable[str], *, strict: bool = True) -> None:
340    def add_cross_reference(
341        self,
342        id: int,
343        cross_ref_type: CrossReferenceType,
344        label_path: Iterable[str],
345        *,
346        strict: bool = True,
347    ) -> None:
348        """Adds a cross reference to the entry.
349
350        Args:
351            id: The id of the cross reference.
352            cross_ref_type: The type of the cross reference.
353            label_path: The label path of the cross reference.
354            strict: Whether to raise a `ValueError` if adding a SEE-cross
355                reference to an entry with former "normal" reference (locator).
356                Else, it will just be a warning and the SEE-cross reference will
357                be automatically converted to SEE ALSO. Defaults to `True`.
358
359        Raises:
360            ValueError: If `strict=True` and adding a SEE-cross reference to
361                an entry with former "normal" reference (locator).
362        """
363        if self.references and cross_ref_type == CrossReferenceType.SEE:
364            if strict:
365                msg = (
366                    f"cannot add a SEE-cross reference to entry "
367                    f"{self.joined_label_path!r} with former reference "
368                    f"(locator)"
369                )
370                raise ValueError(msg)
371            LOGGER.warning(
372                "Adding a SEE-cross reference to entry %r with former "
373                "reference (locator); cross reference will be converted to SEE "
374                "ALSO",
375                self.joined_label_path,
376            )
377            cross_ref_type = CrossReferenceType.ALSO
378        label_path = tuple(label_path)
379        if len(self.cross_references) > 0:
380            for cr in self.cross_references:
381                if cr.type == cross_ref_type and cr.label_path == label_path:
382                    return
383        self.cross_references.append(
384            CrossReference(id=id, type=cross_ref_type, label_path=label_path)
385        )

Adds a cross reference to the entry.

Arguments:
  • id: The id of the cross reference.
  • cross_ref_type: The type of the cross reference.
  • label_path: The label path of the cross reference.
  • strict: Whether to raise a ValueError if adding a SEE-cross reference to an entry with former "normal" reference (locator). Else, it will just be a warning and the SEE-cross reference will be automatically converted to SEE ALSO. Defaults to True.
Raises:
  • ValueError: If strict=True and adding a SEE-cross reference to an entry with former "normal" reference (locator).
def add_reference( self, start_id: int, *, locator_emphasis: bool = False, start_suffix: str | None = None, strict: bool = True) -> None:
387    def add_reference(
388        self,
389        start_id: int,
390        *,
391        locator_emphasis: bool = False,
392        start_suffix: str | None = None,
393        strict: bool = True,
394    ) -> None:
395        """Adds a reference (locator) to the entry.
396
397        Args:
398            start_id: The start id of the reference.
399            locator_emphasis: Whether to emphasize the locator of the reference.
400                Defaults to `False`.
401            start_suffix: The start suffix of the reference. Defaults to
402                `None`.
403            strict: Whether to raise a `ValueError` if adding a SEE-cross
404                reference to an entry with former "normal" reference (locator).
405                Else, it will just be a warning and the SEE-cross reference will
406                be automatically converted to SEE ALSO. Defaults to `True`.
407
408        Raises:
409            ValueError: If `strict=True` and adding a reference locator to an
410                entry with former SEE-cross reference.
411        """
412        if any(
413            cr.type == CrossReferenceType.SEE for cr in self.cross_references
414        ):
415            if strict:
416                msg = (
417                    f"cannot add a reference (locator) to entry "
418                    f"{self.joined_label_path!r} with former SEE-cross "
419                    f"reference"
420                )
421                raise ValueError(msg)
422            LOGGER.warning(
423                "Adding a reference (locator) to entry %r with former SEE-"
424                "cross reference(s); cross reference(s) will be converted to "
425                "SEE ALSO",
426                self.joined_label_path,
427            )
428            for cr in self.cross_references:
429                if cr.type == CrossReferenceType.SEE:
430                    cr.type = CrossReferenceType.ALSO
431
432        self.references.append(
433            Reference(
434                start_id=start_id,
435                start_suffix=start_suffix,
436                locator_emphasis=locator_emphasis,
437            )
438        )

Adds a reference (locator) to the entry.

Arguments:
  • start_id: The start id of the reference.
  • locator_emphasis: Whether to emphasize the locator of the reference. Defaults to False.
  • start_suffix: The start suffix of the reference. Defaults to None.
  • strict: Whether to raise a ValueError if adding a SEE-cross reference to an entry with former "normal" reference (locator). Else, it will just be a warning and the SEE-cross reference will be automatically converted to SEE ALSO. Defaults to True.
Raises:
  • ValueError: If strict=True and adding a reference locator to an entry with former SEE-cross reference.
def update_latest_reference_end(self, end_id: int, end_suffix: str | None = None) -> None:
440    def update_latest_reference_end(
441        self,
442        end_id: int,
443        end_suffix: str | None = None,
444    ) -> None:
445        """Updates the end of the latest reference.
446
447        Args:
448            end_id: The end id of the latest reference.
449            end_suffix: The end suffix of the latest reference. Defaults to
450                `None`.
451
452        Raises:
453            RuntimeError: If there has been no reference before.
454        """
455        if len(self.references) == 0:
456            msg = "cannot update latest reference end without reference"
457            raise RuntimeError(msg)
458        self.references[-1].end_id = end_id
459        if end_suffix:
460            self.references[-1].end_suffix = end_suffix

Updates the end of the latest reference.

Arguments:
  • end_id: The end id of the latest reference.
  • end_suffix: The end suffix of the latest reference. Defaults to None.
Raises:
  • RuntimeError: If there has been no reference before.
id: int

The id.

label: str

The label.

parent: Optional[typing_extensions.Self]

The parent.

class TextIndexRenderer:
 57class TextIndexRenderer:
 58    """Text Index (Writer).
 59
 60    A reference implementation of a Text Index to use with [fpdf2](https://py-pdf.github.io/fpdf2/index.html).
 61
 62    This class provides a customizable Text Index that can be used directly or
 63    subclassed for additional functionality.
 64    To use this class, create an instance of :py:class:`TextIndexRenderer`,
 65    configure it as needed, and pass its
 66    :py:meth:`TextIndexRenderer.render_text_index`-method as
 67    `render_index_function`-argument to
 68    :py:meth:`fpdf2_textindex.pdf.FPDF.insert_index_placeholder`.
 69    """
 70
 71    if TYPE_CHECKING:
 72        _cur_header: str | None
 73        _link_locations: dict[str, LinkLocation]
 74        border: bool
 75        ignore_same_page_refs: bool
 76        level_indent: float
 77        line_spacing: float
 78        max_outline_level: int
 79        outline_level: int
 80        run_in_style: bool
 81        show_header: bool
 82        sort_emph_first: bool
 83        text_styles: list[fpdf.TextStyle]
 84
 85    def __init__(
 86        self,
 87        *,
 88        border: bool = False,
 89        ignore_same_page_refs: bool = True,
 90        level_indent: float | None = 7.5,
 91        line_spacing: float | None = None,
 92        max_outline_level: int | None = None,
 93        outline_level: int | None = None,
 94        run_in_style: bool = True,
 95        show_header: bool = False,
 96        sort_emph_first: bool = False,
 97        text_styles: Iterable[fpdf.TextStyle] | fpdf.TextStyle | None = None,
 98    ) -> None:
 99        """Initializes the renderer.
100
101        Args:
102            border: Whether to show borders around the entries and headers.
103                Mainly for debugging purposes. Defaults to `False`.
104            ignore_same_page_refs: Whether to ignore references (locators) to
105                the same PDF page (default), else same pages will be printed
106                multiple times.
107            level_indent: The indent to add per entry depth to the left of the
108                entry. Defaults to `7.5` times the
109                [fpdf.FPDF.unit](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF).
110            line_spacing: The spacing between lines as multiple of the font
111                size. Defaults to `None`, meaning `1.0`.
112            max_outline_level: If `outline_level` >= 0, `max_outline_level`
113                will decide how many deeper entries will be added to the PDF
114                outline. Defaults to `None`, meaning that no liimit is set.
115            outline_level: If `outline_level` >= 0, the first entry depth will
116                be added at this outline level to the PDF. If
117                `show_header=True`, the headers will be added at this outline
118                level to the PDF. Defaults to `None`, meaning to not show the
119                entries (or headers) in the PDF outline.
120            run_in_style: Whether to print the deepest entry levels at "run-in"-
121                style (>2). Defaults to `True`.
122            show_header: Whether to show the headers. Defaults to `False`.
123            sort_emph_first: Whether to show emphasized references (locators)
124                first. Defaults to `False`.
125            text_styles: The text styles to use to print the entries at the
126                different depths. If `show_header=True`, the first text style
127                refers to the style of the headers. If an entry is "deeper" than
128                there are text styles, the renderer will fall back to deepest
129                given text style. Defaults to `None`, meaning to take the
130                text style of the last PDF page.
131        """  # noqa: DOC501
132        self.border = border
133        self.ignore_same_page_refs = bool(ignore_same_page_refs)
134        self.level_indent = 0.0 if level_indent is None else float(level_indent)
135        self.line_spacing = 1.0 if line_spacing is None else float(line_spacing)
136        self.max_outline_level = (
137            -1 if max_outline_level is None else int(max_outline_level)
138        )
139        self.outline_level = -1 if outline_level is None else int(outline_level)
140        self.run_in_style = bool(run_in_style)
141        self.show_header = bool(show_header)
142        self.sort_emph_first = bool(sort_emph_first)
143
144        if text_styles is None:
145            self.text_styles = [fpdf.TextStyle()]
146        elif isinstance(text_styles, Iterable):
147            self.text_styles = list(text_styles)
148        elif isinstance(text_styles, fpdf.TextStyle):
149            self.text_styles = [text_styles]
150        else:
151            msg = f"invalid type of text_styles: {type(text_styles):__name__:s}"
152            raise TypeError(msg)
153
154        self._cur_header = None
155        self._h_header_min = None
156        self._link_locations = {}
157
158    def render_text_index(
159        self,
160        pdf: FPDF,
161        entries: list[TextIndexEntry],
162    ) -> None:
163        """Renders the text index.
164
165        Note:
166            Use this method as `render_index_function`-argument in
167            `fpdf2_textindex.pdf.FPDF.insert_index_placeholder`.
168
169        Args:
170            pdf: The `fpdf2_textindex.pdf.FPDF`-instance to render in.
171            entries: The list of entries to render.
172
173        Raises:
174            ValueError: If a textstyle has a [fpdf.Align](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.Align)
175                -value as left margin.
176        """  # noqa: DOC502
177        assert pdf.index_placeholder is not None
178
179        LOGGER.info("Rendering text index")
180        if not entries:
181            LOGGER.warning("No entries defined")
182            return
183
184        max_depth = max(e.depth for e in entries)
185        if max_depth > 2:
186            if self.run_in_style:
187                LOGGER.warning(
188                    "Deep index (>2 levels): Level %d entries will be run-in "
189                    "to level %d (see docs to disable)",
190                    max_depth,
191                    max_depth - 1,
192                )
193            else:
194                LOGGER.warning(
195                    "Deep index (>2 levels): Consider reducing depth, or "
196                    "enable run-in (see docs)"
197                )
198
199        # Reset section title styles to guarantee adding to outline without add
200        # section title
201        prev_section_title_styles = pdf.section_title_styles
202        pdf.section_title_styles = {}
203
204        for entry in entries:
205            if entry.depth > 1:
206                continue
207
208            prepared_entries: list[tuple[TextIndexEntryP, str]] = list(
209                self._prepare_entry(pdf, entry, max_depth)
210            )
211            self._render_header(pdf, entry, prepared_entries[0][1])
212            for e, text in prepared_entries:
213                # LOGGER.info("%d %r", pdf.page, e.label)
214                page_entry, x_entry, y_entry, w_entry, h_entry = (
215                    self._render_entry(pdf, e, text)
216                )
217                if isinstance(e, TextIndexEntry):
218                    self._set_links(
219                        pdf, e, page_entry, x_entry, y_entry, w_entry, h_entry
220                    )
221                    if self._run_in_children(e, max_depth):
222                        for c in e.children:
223                            self._set_links(
224                                pdf,
225                                c,
226                                page_entry,
227                                x_entry,
228                                y_entry,
229                                w_entry,
230                                h_entry,
231                            )
232
233        pdf.section_title_styles = prev_section_title_styles
234
235        LOGGER.info("Rendered text index")
236
237    def _render_entry(
238        self,
239        pdf: FPDF,
240        entry: TextIndexEntryP,
241        entry_text: str,
242    ) -> tuple[int, float, float, float, float]:
243        # Do not fit half an entry
244        text_style = self._get_text_style(entry.depth)
245        w_entry, h_entry = self._calc_entry_size(pdf, entry.depth, entry_text)
246        pdf._perform_page_break_if_need_be(h_entry)
247
248        x_entry, y_entry = pdf.x, pdf.y
249        # Consider level indent
250        if TYPE_CHECKING:
251            assert not isinstance(text_style.l_margin, fpdf.Align)
252        l_margin = (
253            text_style.l_margin or pdf.l_margin
254        ) + self.level_indent * entry.depth
255        with (
256            self._add_to_outline(pdf, entry.depth, entry.label),
257            pdf.use_text_style(text_style.replace(l_margin=l_margin)),
258        ):
259            page_entry = pdf.page
260            pdf.multi_cell(
261                w=0,
262                h=pdf.font_size * self.line_spacing,
263                text=entry_text,
264                align=fpdf.Align.L,
265                border=int(self.border),  # type: ignore[arg-type]
266                first_line_indent=-self.level_indent,
267                markdown=True,
268                new_x=fpdf.XPos.LMARGIN,
269                new_y=fpdf.YPos.NEXT,
270            )
271        x_entry += self.level_indent * (entry.depth - 1)
272        assert fpdf.util.FloatTolerance.equal(pdf.y - y_entry, h_entry), (
273            pdf.y - y_entry,
274            h_entry,
275        )
276        return page_entry, x_entry, y_entry, w_entry, h_entry
277
278    def _render_header(
279        self,
280        pdf: FPDF,
281        entry: TextIndexEntryP,
282        first_entry_text: str,
283    ) -> None:
284        if not self.show_header or entry.depth > 1:
285            return
286
287        if entry.sort_label == "\uffff":  # Empty label and sort key
288            return
289
290        next_header = entry.sort_label[0].upper()
291        if next_header == self._cur_header:
292            return
293
294        # Do not fit a single header without an entry at page bottom
295        h_header_min = self._calc_min_header_height(pdf, first_entry_text)
296        pdf._perform_page_break_if_need_be(h_header_min)
297
298        with (
299            self._add_to_outline(pdf, entry.depth, next_header, header=True),
300            pdf.use_text_style(self._get_text_style(0)),
301        ):
302            h = pdf.font_size * self.line_spacing
303            pdf.cell(
304                h=h,
305                text=next_header,
306                border=int(self.border),  # type: ignore[arg-type]
307                new_x=fpdf.XPos.LMARGIN,
308                new_y=fpdf.YPos.NEXT,
309            )
310
311        self._cur_header = next_header
312
313    @contextlib.contextmanager
314    def _add_to_outline(
315        self,
316        pdf: FPDF,
317        entry_depth: int,
318        entry_label: str | None,
319        *,
320        header: bool = False,
321    ) -> Iterator[None]:
322        if entry_label is None or self.outline_level < 0:
323            yield
324            return
325
326        level = (
327            self.outline_level
328            + int(self.show_header and not header)
329            + entry_depth
330            - 1
331        )
332        if self.max_outline_level > -1 and level > self.max_outline_level:
333            yield
334            return
335
336        name = MDEmphasis.remove(entry_label)
337        pdf.start_section(name, level=level)
338        with pdf._marked_sequence(title=name) as struct_elem:
339            outline_struct_elem = struct_elem
340            yield
341        pdf._outline[-1].struct_elem = outline_struct_elem
342
343    def _calc_entry_size(
344        self,
345        pdf: FPDF,
346        entry_depth: int,
347        entry_text: str,
348    ) -> tuple[float, float]:
349        text_style = self._get_text_style(entry_depth)
350        if isinstance(text_style.l_margin, (fpdf.Align | str)):
351            align = fpdf.Align.coerce(text_style.l_margin)
352            msg = (
353                f"TextStyle with l_margin as align value {align!r} cannot be "
354                f"used in {type(self).__name__:s}"
355            )
356            raise ValueError(msg)
357
358        prev_x, prev_y = pdf.x, pdf.y
359        # Consider level indent
360        l_margin = (
361            text_style.l_margin or pdf.l_margin
362        ) + self.level_indent * entry_depth
363
364        with pdf.use_text_style(
365            text_style.replace(t_margin=0, l_margin=l_margin, b_margin=0)
366        ):
367            if TYPE_CHECKING:
368                lines: list[str]
369                h: float
370            lines, h = pdf.multi_cell(  # type: ignore[assignment, misc]
371                w=0,
372                h=pdf.font_size * self.line_spacing,
373                text=entry_text,
374                align=fpdf.Align.L,
375                dry_run=True,
376                first_line_indent=-self.level_indent,
377                markdown=True,
378                output=fpdf.enums.MethodReturnValue.LINES
379                | fpdf.enums.MethodReturnValue.HEIGHT,
380                padding=fpdf.util.Padding(
381                    top=text_style.t_margin or 0,
382                    bottom=text_style.b_margin or 0,
383                ),
384            )
385            w = max(
386                pdf.get_string_width(
387                    line,
388                    normalized=True,
389                    markdown=True,
390                )
391                for line in lines
392            )
393            w += 2 * pdf.c_margin + self.level_indent
394
395        assert pdf.x == prev_x and pdf.y == prev_y, (
396            "position changed during calculation of entry height"
397        )
398        return w, h
399
400    def _calc_min_header_height(
401        self,
402        pdf: FPDF,
403        entry_text: str,
404    ) -> float:
405        # Header
406        text_style = self.text_styles[0]
407        h_min = text_style.t_margin
408        h_min += (
409            (text_style.size_pt or pdf.font_size_pt) * self.line_spacing / pdf.k
410        )
411        h_min += text_style.b_margin
412
413        # First entry
414        text_style = self.text_styles[min(1, len(self.text_styles) - 1)]
415        h_min += self._calc_entry_size(pdf, 1, entry_text)[1]
416        return h_min
417
418    @staticmethod
419    def _entry_at_label_path(
420        entry: TextIndexEntry,
421        label_path: Iterable[str],
422    ) -> TextIndexEntry | None:
423        # Go to root
424        d = deque(entry.iter_parents(), maxlen=1)
425        node: TextIndexEntry | None = (d[0] if d else entry).parent  # root
426        if node is None:
427            return None
428
429        # Iterate down according to label path
430        for label in label_path:
431            node = node.get_child(label)
432            if node is None:
433                return None
434        return node
435
436    def _get_text_style(self, entry_depth: int) -> fpdf.TextStyle:
437        d = min(
438            int(self.show_header) + entry_depth - 1,
439            len(self.text_styles) - 1,
440        )
441        return self.text_styles[d]
442
443    def _prepare_entry(
444        self,
445        pdf: FPDF,
446        entry: TextIndexEntry,
447        max_depth: int,
448        *,
449        _run_in: bool = False,
450    ) -> Iterator[tuple[TextIndexEntryP, str]]:
451        running_in = entry.parent and self._run_in_children(
452            entry.parent, max_depth
453        )
454        if running_in and not _run_in:
455            return
456
457        has_refs = len(entry.references) > 0
458        has_see_refs = any(
459            cr.type == CrossReferenceType.SEE for cr in entry.cross_references
460        )
461        assert not (has_see_refs and has_refs), (
462            f"Entry {entry.joined_label_path!r} has a reference (locator) "
463            f"and a SEE-ross reference"
464        )
465        has_also_refs = any(
466            cr.type == CrossReferenceType.ALSO for cr in entry.cross_references
467        )
468
469        # Label
470        text_pts = [entry.label]
471
472        # SEE-cross references
473        if has_see_refs:
474            text_pts.extend(
475                self._prepare_cross_references(
476                    pdf,
477                    entry,
478                    CrossReferenceType.SEE,
479                    "running_in" if running_in or entry.depth > 1 else "entry",
480                )
481            )
482
483        # References (locators)
484        if has_refs:
485            text_pts.extend(
486                self._prepare_references(
487                    pdf,
488                    entry,
489                    const.CATEGORY_SEPARATOR
490                    if has_see_refs
491                    else const.FIELD_SEPARATOR,
492                )
493            )
494
495        # Run-in style
496        run_in_children = self._run_in_children(entry, max_depth)
497        if run_in_children and entry.children:
498            if has_refs:
499                separator: str = const.LIST_SEPARATOR
500            elif has_see_refs:  # and not has_refs
501                separator = const.CATEGORY_SEPARATOR
502            else:  # not has_see_refs
503                separator = const.PATH_SEPARATOR
504            text_pts.append(separator)
505
506            for i, child in enumerate(entry.children):
507                if i > 0:
508                    text_pts.append(const.LIST_SEPARATOR)
509                text_pts.extend(
510                    t
511                    for _, t in self._prepare_entry(
512                        pdf, child, max_depth, _run_in=True
513                    )
514                )
515
516        # Own SEE ALSO-ross references
517        # Check whether we lack children and thus potentially need to inline our
518        # own SEE ALSO-cross references. This provides run-in style for such
519        # cross references.
520        if has_also_refs and (not entry.children or run_in_children):
521            text_pts.extend(
522                self._prepare_cross_references(
523                    pdf,
524                    entry,
525                    CrossReferenceType.ALSO,
526                    "running_in" if running_in else "entry",
527                )
528            )
529
530        text = "".join(text_pts)
531        LOGGER.debug(
532            "%sEntry %r (Level%d): %r",
533            "  " * (entry.depth - 1),
534            entry.label,
535            entry.depth,
536            text,
537        )
538        yield entry, text
539
540        if not run_in_children:
541            for child in entry.children:
542                yield from self._prepare_entry(
543                    pdf, child, max_depth, _run_in=False
544                )
545
546        if (
547            not running_in
548            and entry.parent
549            and any(
550                cr.type == CrossReferenceType.ALSO
551                for cr in entry.parent.cross_references
552            )
553        ):
554            text = "".join(
555                self._prepare_cross_references(
556                    pdf,
557                    entry.parent,
558                    CrossReferenceType.ALSO,
559                    "sub_entry",
560                )
561            )
562            LOGGER.debug(
563                "%sEntry %r (Level%d): %r",
564                "  " * (entry.depth - 1),
565                entry.label,
566                entry.depth,
567                text,
568            )
569            yield _AlsoPseudoEntry(depth=entry.depth), text
570
571    def _prepare_cross_references(
572        self,
573        pdf: FPDF,
574        entry: TextIndexEntry,
575        cross_ref_type: CrossReferenceType,
576        mode: Literal["entry", "running_in", "sub_entry"],
577    ) -> Iterator[str]:
578        # Sort by type and label path
579        entry.cross_references.sort(key=lambda cr: (cr.type, *cr.label_path))
580
581        # See (also) under
582        under_mode = (
583            len(entry.cross_references) == 1
584            and sum(cr.type == cross_ref_type for cr in entry.cross_references)
585            == 1
586            and entry.label == entry.cross_references[-1].label_path[-1]
587        )
588
589        match mode:
590            case "entry":
591                yield const.CATEGORY_SEPARATOR
592            case "running_in":
593                yield " ("
594            case "sub_entry":
595                pass
596            case _:
597                msg = f"invalid mode: {mode!r}"
598                raise ValueError(msg)
599
600        cross_ref_type_str = str(cross_ref_type)
601        cross_ref_type_str = (
602            cross_ref_type_str.lower()
603            if mode == "running_in"
604            else cross_ref_type_str.capitalize()
605        )
606        if under_mode:
607            cross_ref_type_str = f"{cross_ref_type_str:s} under"
608        cross_ref_type_str = MDEmphasis.ITALICS.format(cross_ref_type_str)
609        yield f"{cross_ref_type_str:s} "
610
611        i = 0
612        for cross_ref in entry.cross_references:
613            if cross_ref.type != cross_ref_type:
614                continue
615
616            # Try to find cross referenced entry
617            cross_ref_entry = self._entry_at_label_path(
618                entry, cross_ref.label_path
619            )
620            if cross_ref_entry is None:
621                msg = "In entry %s, cross referenced entry %s does not exist"
622                log_level = (
623                    logging.WARNING
624                    if len(cross_ref.label_path) == 1
625                    else logging.ERROR
626                )
627                LOGGER.log(
628                    log_level,
629                    msg,
630                    entry.joined_label_path,
631                    cross_ref.joined_label_path,
632                )
633                if log_level == logging.ERROR:
634                    raise RuntimeError(
635                        msg
636                        % (entry.joined_label_path, cross_ref.joined_label_path)
637                    )
638            elif sum(len(e.references) for e in iter(cross_ref_entry)) == 0:
639                msg = (
640                    "In entry %s, cross referenced entry %s has no own "
641                    "reference(s) (blind cross reference)"
642                )
643                LOGGER.warning(
644                    msg, entry.joined_label_path, cross_ref.joined_label_path
645                )
646            elif len(cross_ref_entry.cross_references) > 0:
647                msg = (
648                    "In entry %s, cross referenced entry %s leads to other "
649                    "cross reference(s) (blind cross reference)"
650                )
651                LOGGER.warning(
652                    msg, entry.joined_label_path, cross_ref.joined_label_path
653                )
654
655            # Write delimiter
656            if i > 0:
657                yield f"{const.REFS_DELIMITER:s} "
658            i += 1
659
660            # Write cross reference
661            cross_link = None
662            if cross_ref_entry is not None:
663                cross_link = f"{const.ENTRY_ID_PREFIX:s}{cross_ref_entry.id:d}"
664                if cross_link not in self._link_locations:
665                    # Reserve link if not existing before
666                    pdf.set_link(name=cross_link)
667            label_path = cross_ref.label_path
668            if under_mode:
669                label_path = label_path[:-1]
670            content = const.PATH_SEPARATOR.join(label_path)
671            if cross_link:
672                content = md_link(content, f"#{cross_link}")
673            yield content
674
675        if mode == "running_in":
676            yield ")"
677
678    def _prepare_references(
679        self,
680        pdf: FPDF,
681        entry: TextIndexEntry,
682        first_separator: str,
683    ) -> Iterator[str]:
684        if len(entry.references) == 0:
685            return
686
687        # Respect emphasis-first option
688        refs = sorted(
689            entry.references,
690            key=(
691                (lambda r: (not r.locator_emphasis, r.start_id, r.end_id))
692                if self.sort_emph_first
693                else (lambda r: (r.start_id, r.end_id))
694            ),
695        )
696
697        # Warn about too many references
698        if len(refs) >= const.REFERENCES_LIMIT:
699            LOGGER.warning(
700                "Entry %r has %d locators, consider reorganising or being more "
701                "selective",
702                entry.joined_label_path,
703                len(refs),
704            )
705
706        self._last_page = -1
707        for i, ref in enumerate(refs):
708            # Render page of start id
709            if TYPE_CHECKING:
710                assert isinstance(ref.start_location, LinkLocation)
711            yield from self._prepare_referenced_page(
712                pdf,
713                ref.start_link,
714                ref.start_location,
715                ref.locator_emphasis,
716                first_separator if i == 0 else const.FIELD_SEPARATOR,
717            )
718
719            # Render page of end id
720            if isinstance(ref.end_link, str):
721                if TYPE_CHECKING:
722                    assert isinstance(ref.end_location, LinkLocation)
723                yield from self._prepare_referenced_page(
724                    pdf,
725                    ref.end_link,
726                    ref.end_location,
727                    ref.locator_emphasis,
728                    const.RANGE_SEPARATOR,
729                )
730
731            # Render suffix of start id
732            separator = ""
733            if isinstance(ref.start_suffix, str):
734                yield separator
735                yield md_link(ref.start_suffix, f"#{ref.start_link:s}")
736                separator = " "
737
738            # Render suffix of end id
739            if isinstance(ref.end_suffix, str):
740                if ref.end_link is None:
741                    msg = (
742                        f"entry's {entry.joined_label_path!r:s} "
743                        f"(id={entry.id:d}) reference with start id "
744                        f"{ref.start_id:d} has end suffix "
745                        f"{ref.end_suffix!r:s}, but no end id"
746                    )
747                    raise RuntimeError(msg)
748                yield separator
749                yield md_link(ref.end_suffix, f"#{ref.end_link:s}")
750
751    def _prepare_referenced_page(
752        self,
753        pdf: FPDF,
754        text_to_index_link: str,
755        link_loc: LinkLocation,
756        locator_emphasis: bool,
757        separator: str,
758    ) -> Iterator[str]:
759        # Ignore consecutive references to same page
760        if self.ignore_same_page_refs and link_loc.page == self._last_page:
761            return
762
763        # Catch that font does not support unicode characters
764        if separator == const.RANGE_SEPARATOR:
765            try:
766                pdf.normalize_text(separator)
767            except fpdf.errors.FPDFUnicodeEncodingException:
768                separator = "-"
769
770        # Write separator
771        yield separator
772
773        # Point link of page number in index to text page
774        index_to_text_link = f"{text_to_index_link:s}{const.TEXT_ID_SUFFIX:s}"
775        pdf.add_link(
776            name=index_to_text_link,
777            page=link_loc.page,
778            x=link_loc.x,
779            y=link_loc.y,
780        )
781
782        # Write page number
783        self._last_page = link_loc.page
784        content = pdf.pages[link_loc.page].get_label()
785        text = md_link(content, f"#{index_to_text_link:s}")
786        yield MDEmphasis.BOLD.format(text) if locator_emphasis else text
787
788    def _run_in_children(self, entry: TextIndexEntry, max_depth: int) -> bool:
789        """Returns whether the entry should render its children in run-in style.
790
791        Top-level entries are at level 1, and are considered children of the
792        index (root) itself. Depths 1 and 2 (top-level entries and their sub-
793        -entries) are always indented. Thereafter, for practical reasons, only
794        the deepest level is run-in.
795
796        Note:
797            Please don't make indexes deeper than 3 levels (sub-sub-entries)
798            though, for your readers' sake!
799        """
800        if self.run_in_style:
801            return entry.depth >= 2 and entry.depth == max_depth - 1
802        return False
803
804    def _set_links(
805        self,
806        pdf: FPDF,
807        entry: TextIndexEntry,
808        page_entry: int,
809        x_entry: float,
810        y_entry: float,
811        w_entry: float,
812        h_entry: float,
813    ) -> None:
814        # Add link to entry label into link locations
815        entry_link = f"{const.ENTRY_ID_PREFIX:s}{entry.id:d}"
816        assert entry_link not in self._link_locations, (
817            repr(entry),
818            self._link_locations[entry_link],
819        )
820        pdf.add_link(name=entry_link, x=x_entry, y=y_entry)
821        link_loc = LinkLocation(
822            page=page_entry,
823            x=x_entry,
824            y=y_entry,
825            w=w_entry,
826            h=h_entry,
827        )
828        self._link_locations[entry_link] = link_loc
829        LOGGER.debug(
830            "%sEntry %r (Level%d): %r",
831            "  " * (entry.depth - 1),
832            entry.label,
833            entry.depth,
834            link_loc,
835        )
836
837        # Point links on text page to index entry
838        # References
839        for ref in entry.references:
840            # dest = pdf.named_destinations[text_to_index_link]
841            # fpdf_link_idx = reverse_dict_items(pdf.links.items())[dest]
842            fpdf_link_idx = pdf._index_links[ref.start_link]
843            pdf.set_link(
844                link=fpdf_link_idx,
845                name=ref.start_link,
846                page=link_loc.page,
847                x=link_loc.x,
848                y=link_loc.y,
849            )
850
851            if isinstance(ref.end_link, str):
852                fpdf_link_idx = pdf._index_links[ref.end_link]
853                pdf.set_link(
854                    link=fpdf_link_idx,
855                    name=ref.end_link,
856                    page=link_loc.page,
857                    x=link_loc.x,
858                    y=link_loc.y,
859                )
860
861        # Cross references
862        for cross_ref in entry.cross_references:
863            fpdf_link_idx = pdf._index_links[cross_ref.link]
864            pdf.set_link(
865                link=fpdf_link_idx,
866                name=cross_ref.link,
867                page=link_loc.page,
868                x=link_loc.x,
869                y=link_loc.y,
870            )

Text Index (Writer).

A reference implementation of a Text Index to use with fpdf2.

This class provides a customizable Text Index that can be used directly or subclassed for additional functionality. To use this class, create an instance of TextIndexRenderer, configure it as needed, and pass its TextIndexRenderer.render_text_index()-method as render_index_function-argument to fpdf2_textindex.pdf.FPDF.insert_index_placeholder().

TextIndexRenderer( *, border: bool = False, ignore_same_page_refs: bool = True, level_indent: float | None = 7.5, line_spacing: float | None = None, max_outline_level: int | None = None, outline_level: int | None = None, run_in_style: bool = True, show_header: bool = False, sort_emph_first: bool = False, text_styles: Iterable[fpdf.fonts.TextStyle] | fpdf.fonts.TextStyle | None = None)
 85    def __init__(
 86        self,
 87        *,
 88        border: bool = False,
 89        ignore_same_page_refs: bool = True,
 90        level_indent: float | None = 7.5,
 91        line_spacing: float | None = None,
 92        max_outline_level: int | None = None,
 93        outline_level: int | None = None,
 94        run_in_style: bool = True,
 95        show_header: bool = False,
 96        sort_emph_first: bool = False,
 97        text_styles: Iterable[fpdf.TextStyle] | fpdf.TextStyle | None = None,
 98    ) -> None:
 99        """Initializes the renderer.
100
101        Args:
102            border: Whether to show borders around the entries and headers.
103                Mainly for debugging purposes. Defaults to `False`.
104            ignore_same_page_refs: Whether to ignore references (locators) to
105                the same PDF page (default), else same pages will be printed
106                multiple times.
107            level_indent: The indent to add per entry depth to the left of the
108                entry. Defaults to `7.5` times the
109                [fpdf.FPDF.unit](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF).
110            line_spacing: The spacing between lines as multiple of the font
111                size. Defaults to `None`, meaning `1.0`.
112            max_outline_level: If `outline_level` >= 0, `max_outline_level`
113                will decide how many deeper entries will be added to the PDF
114                outline. Defaults to `None`, meaning that no liimit is set.
115            outline_level: If `outline_level` >= 0, the first entry depth will
116                be added at this outline level to the PDF. If
117                `show_header=True`, the headers will be added at this outline
118                level to the PDF. Defaults to `None`, meaning to not show the
119                entries (or headers) in the PDF outline.
120            run_in_style: Whether to print the deepest entry levels at "run-in"-
121                style (>2). Defaults to `True`.
122            show_header: Whether to show the headers. Defaults to `False`.
123            sort_emph_first: Whether to show emphasized references (locators)
124                first. Defaults to `False`.
125            text_styles: The text styles to use to print the entries at the
126                different depths. If `show_header=True`, the first text style
127                refers to the style of the headers. If an entry is "deeper" than
128                there are text styles, the renderer will fall back to deepest
129                given text style. Defaults to `None`, meaning to take the
130                text style of the last PDF page.
131        """  # noqa: DOC501
132        self.border = border
133        self.ignore_same_page_refs = bool(ignore_same_page_refs)
134        self.level_indent = 0.0 if level_indent is None else float(level_indent)
135        self.line_spacing = 1.0 if line_spacing is None else float(line_spacing)
136        self.max_outline_level = (
137            -1 if max_outline_level is None else int(max_outline_level)
138        )
139        self.outline_level = -1 if outline_level is None else int(outline_level)
140        self.run_in_style = bool(run_in_style)
141        self.show_header = bool(show_header)
142        self.sort_emph_first = bool(sort_emph_first)
143
144        if text_styles is None:
145            self.text_styles = [fpdf.TextStyle()]
146        elif isinstance(text_styles, Iterable):
147            self.text_styles = list(text_styles)
148        elif isinstance(text_styles, fpdf.TextStyle):
149            self.text_styles = [text_styles]
150        else:
151            msg = f"invalid type of text_styles: {type(text_styles):__name__:s}"
152            raise TypeError(msg)
153
154        self._cur_header = None
155        self._h_header_min = None
156        self._link_locations = {}

Initializes the renderer.

Arguments:
  • border: Whether to show borders around the entries and headers. Mainly for debugging purposes. Defaults to False.
  • ignore_same_page_refs: Whether to ignore references (locators) to the same PDF page (default), else same pages will be printed multiple times.
  • level_indent: The indent to add per entry depth to the left of the entry. Defaults to 7.5 times the .fpdf.FPDF">fpdf.FPDF.unit.
  • line_spacing: The spacing between lines as multiple of the font size. Defaults to None, meaning 1.0.
  • max_outline_level: If outline_level >= 0, max_outline_level will decide how many deeper entries will be added to the PDF outline. Defaults to None, meaning that no liimit is set.
  • outline_level: If outline_level >= 0, the first entry depth will be added at this outline level to the PDF. If show_header=True, the headers will be added at this outline level to the PDF. Defaults to None, meaning to not show the entries (or headers) in the PDF outline.
  • run_in_style: Whether to print the deepest entry levels at "run-in"- style (>2). Defaults to True.
  • show_header: Whether to show the headers. Defaults to False.
  • sort_emph_first: Whether to show emphasized references (locators) first. Defaults to False.
  • text_styles: The text styles to use to print the entries at the different depths. If show_header=True, the first text style refers to the style of the headers. If an entry is "deeper" than there are text styles, the renderer will fall back to deepest given text style. Defaults to None, meaning to take the text style of the last PDF page.
border
ignore_same_page_refs
level_indent
line_spacing
max_outline_level
outline_level
run_in_style
show_header
sort_emph_first
def render_text_index( self, pdf: FPDF, entries: list[TextIndexEntry]) -> None:
158    def render_text_index(
159        self,
160        pdf: FPDF,
161        entries: list[TextIndexEntry],
162    ) -> None:
163        """Renders the text index.
164
165        Note:
166            Use this method as `render_index_function`-argument in
167            `fpdf2_textindex.pdf.FPDF.insert_index_placeholder`.
168
169        Args:
170            pdf: The `fpdf2_textindex.pdf.FPDF`-instance to render in.
171            entries: The list of entries to render.
172
173        Raises:
174            ValueError: If a textstyle has a [fpdf.Align](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.Align)
175                -value as left margin.
176        """  # noqa: DOC502
177        assert pdf.index_placeholder is not None
178
179        LOGGER.info("Rendering text index")
180        if not entries:
181            LOGGER.warning("No entries defined")
182            return
183
184        max_depth = max(e.depth for e in entries)
185        if max_depth > 2:
186            if self.run_in_style:
187                LOGGER.warning(
188                    "Deep index (>2 levels): Level %d entries will be run-in "
189                    "to level %d (see docs to disable)",
190                    max_depth,
191                    max_depth - 1,
192                )
193            else:
194                LOGGER.warning(
195                    "Deep index (>2 levels): Consider reducing depth, or "
196                    "enable run-in (see docs)"
197                )
198
199        # Reset section title styles to guarantee adding to outline without add
200        # section title
201        prev_section_title_styles = pdf.section_title_styles
202        pdf.section_title_styles = {}
203
204        for entry in entries:
205            if entry.depth > 1:
206                continue
207
208            prepared_entries: list[tuple[TextIndexEntryP, str]] = list(
209                self._prepare_entry(pdf, entry, max_depth)
210            )
211            self._render_header(pdf, entry, prepared_entries[0][1])
212            for e, text in prepared_entries:
213                # LOGGER.info("%d %r", pdf.page, e.label)
214                page_entry, x_entry, y_entry, w_entry, h_entry = (
215                    self._render_entry(pdf, e, text)
216                )
217                if isinstance(e, TextIndexEntry):
218                    self._set_links(
219                        pdf, e, page_entry, x_entry, y_entry, w_entry, h_entry
220                    )
221                    if self._run_in_children(e, max_depth):
222                        for c in e.children:
223                            self._set_links(
224                                pdf,
225                                c,
226                                page_entry,
227                                x_entry,
228                                y_entry,
229                                w_entry,
230                                h_entry,
231                            )
232
233        pdf.section_title_styles = prev_section_title_styles
234
235        LOGGER.info("Rendered text index")

Renders the text index.

Note:

Use this method as render_index_function-argument in fpdf2_textindex.pdf.FPDF.insert_index_placeholder.

Arguments:
Raises:
  • ValueError: If a textstyle has a fpdf.Align -value as left margin.
__license__ = 'GPL 3.0'
__version__ = '0.1.0'