Edit on GitHub

fpdf2_textindex

Python Support Pypi Latest Version Python Types Documentation License

CI pre-commit Coverage Pypi Trusted Publisher: enabled Ruff Checks: flake-8, isort, mypy

Commitizen Conventional Commits Keep a Changelog GitHub last commit


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")

The text index will have a single entry:

  • example, 1
Note


The reference index implementation fpdf2_textindex.TextIndexRenderer.render_text_index renders each index entry according to The Chicago Manual of Style - Indexes.

Installation

Using pip:

# Install
pip install fpdf2_textindex

# Keep it updated
pip upgrade fpdf2_textindex

Using uv:

# Install
uv install fpdf2_textindex

# Keep it updated
uv upgrade fpdf2_textindex

Adding Text Index Entries

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 the 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. At least, one 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

Text Index Directive Syntax

__example__{^foo>"\* text"#demo |bar;+baz>fiz [whiz] ~z !}
             1            2     3             4      5  6

The index directive in the example will:

  1. Create a reference from the "example text" subentry within the "foo" top-level entry that leads to the directive's location in the text on the corresponding PDF page. If the entry or subentry do not exist, they will be created.
  2. Define the alias "#demo" for the path to the subentry ("foo" > "example text").
  3. Adds cross-references to the entry:
    • A SEE-cross reference to the "bar" top-level entry,
    • A SEE ALSO-cross reference to the "fiz" subentry within the "baz" top-level entry.
  4. Apply the "whiz" suffix to the directive's reference locator.
  5. Sort the entry as if its heading starts with "z".
  6. Apply an emphasis (bold) to the mark's reference locator.

The resulting index with page numbers would look like:

  • bar, 3
  • baz
    • fiz, 5
  • foo
    • example text, 6 (_see_ bar). _See also_ baz: fiz

if index directives with page references (locators) for "bar" and "baz" > "fiz" have been added as well.

In a real index, this would provoke an error, because either you set a reference locator to a PDF page and a SEE ALSO-cross reference or a SEE- and a SEE ALSO-cross reference, but not all three at the same time.
Alternatively, you can deactivate strict-mode by setting FPDF.STRICT_INDEX_MODE = False which would automatically convert the SEE-cross reference into a SEE ALSO-cross reference, accompanied by a warning.

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 index implementation fpdf2_textindex.TextIndexRenderer.render_text_index would render the example as

"example, 3"

in the text index (assuming it has been written on page 3 in the PDF).

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

In the reference index 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, where the text index directive has been set.
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 interconnected in the same way but inside of the text index.


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

 1"""
 2.. include:: ../README.md
 3   :end-before: # fpdf2 Text Index
 4
 5---
 6
 7.. include:: ../README.md
 8   :start-after: # fpdf2 Text Index
 9
10---
11
12The module gives direct access to some classes defined in submodules:
13
14* :py:class:`fpdf2_textindex.Alias`
15* :py:class:`fpdf2_textindex.CrossReference`
16* :py:class:`fpdf2_textindex.CrossReferenceType`
17* :py:class:`fpdf2_textindex.FPDF`
18* :py:class:`fpdf2_textindex.FPDF2TextindexError`
19* :py:class:`fpdf2_textindex.LinkLocation`
20* :py:class:`fpdf2_textindex.Reference`
21* :py:class:`fpdf2_textindex.TextIndexEntry`
22* :py:class:`fpdf2_textindex.TextIndexRenderer`
23"""  # noqa: D212, D415
24
25# Monkey-patch fpdf bugs first
26import fpdf2_textindex._fpdf  # noqa: F401
27from fpdf2_textindex.errors import FPDF2TextindexError
28from fpdf2_textindex.interface import Alias
29from fpdf2_textindex.interface import CrossReference
30from fpdf2_textindex.interface import CrossReferenceType
31from fpdf2_textindex.interface import LinkLocation
32from fpdf2_textindex.interface import Reference
33from fpdf2_textindex.interface import TextIndexEntry
34from fpdf2_textindex.pdf import FPDF
35from fpdf2_textindex.renderer import TextIndexRenderer
36from fpdf2_textindex.version import FPDF2_TEXTINDEX_VERSION
37
38__docformat__ = "google"
39__license__ = "GPL 3.0"
40__version__ = FPDF2_TEXTINDEX_VERSION
41
42__all__ = (  # noqa: RUF022
43    "Alias",
44    "CrossReference",
45    "CrossReferenceType",
46    "FPDF",
47    "FPDF2TextindexError",
48    "LinkLocation",
49    "Reference",
50    "TextIndexEntry",
51    "TextIndexRenderer",
52    "__license__",
53    "__version__",
54)

API Documentation

@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
class Alias(fpdf2_textindex.interface.abc._LabelPathABC):
12@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
13class Alias(_LabelPathABC):
14    """Alias."""
15
16    name: str
17    """The name of the alias."""
18
19    label_path: LabelPath
20    """The label path of the alias."""
21
22    def __post_init__(self) -> None:
23        object.__setattr__(self, "label_path", LabelPath(self.label_path))
24
25    def __repr__(self) -> str:
26        return (
27            f"{type(self).__name__}"
28            f"(#{self.name:s} -> {self.joined_label_path!r:s})"
29        )

Alias.

Alias( *, name: str, label_path: fpdf2_textindex.interface.label_path.LabelPath = <property object>)
name: str

The name of the alias.

label_path: fpdf2_textindex.interface.label_path.LabelPath

The label path of the alias.

@dataclasses.dataclass(kw_only=True, slots=True)
class CrossReference(fpdf2_textindex.interface.abc._LabelPathABC):
15@dataclasses.dataclass(kw_only=True, slots=True)
16class CrossReference(_LabelPathABC):
17    """Cross Reference."""
18
19    id: int
20    """The id of the cross reference."""
21
22    type: CrossReferenceType
23    """The type of the cross reference."""
24
25    label_path: LabelPath
26    """The label path the cross reference points to."""
27
28    location: LinkLocation | None = dataclasses.field(default=None, init=False)
29    """The (link) location in the document the cross reference is set at."""
30
31    def __post_init__(self) -> None:
32        self.label_path = LabelPath(self.label_path)
33        self.type = CrossReferenceType(self.type)
34
35    def __str__(self) -> str:
36        return f"{self.type.capitalize():s} {self.joined_label_path:s}"
37
38    @property
39    def link(self) -> str:
40        """The link in the document that must be set in the text index to lead
41        from the text to the text index.
42        """
43        return f"{const.INDEX_ID_PREFIX:s}{self.id:d}"

Cross Reference.

CrossReference( *, id: int, type: CrossReferenceType, label_path: fpdf2_textindex.interface.label_path.LabelPath = <property object>)
id: int

The id of the cross reference.

The type of the cross reference.

label_path: fpdf2_textindex.interface.label_path.LabelPath

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

index_placeholder

Index placeholder. Defaults to None.

index_entries: list[TextIndexEntry]
251    @property
252    def index_entries(self) -> list[TextIndexEntry]:
253        """The (so far parsed) index entries."""
254        return self._index_parser.entries.copy()

The (so far parsed) index entries.

def add_index_entry( self, label_path: fpdf2_textindex.interface.label_path.LabelPath | Iterable[str] | str, sort_key: str | None = None) -> TextIndexEntry:
256    def add_index_entry(
257        self,
258        label_path: LabelPathT,
259        sort_key: str | None = None,
260    ) -> TextIndexEntry:
261        """Adds manually a text index entry.
262
263        Note: References (locators) to pages cannot be added manually, only
264            cross references.
265
266        Args:
267            label_path: The label path of the entry.
268            sort_key: The sort key of the entry. Defaults to `None`.
269
270        Returns:
271            The text index entry.
272        """
273        entry = self._index_parser.entry_at_label_path(label_path, create=True)
274        if TYPE_CHECKING:
275            assert isinstance(entry, TextIndexEntry)
276        entry.sort_key = sort_key
277        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.

def index_entry_at_label_path( self, label_path: fpdf2_textindex.interface.label_path.LabelPath | Iterable[str] | str) -> TextIndexEntry | None:
279    def index_entry_at_label_path(
280        self,
281        label_path: LabelPathT,
282    ) -> TextIndexEntry | None:
283        """Returns a text index entry by its label path.
284
285        Args:
286            label_path: The label path.
287
288        Returns:
289            The found :py:class:`fpdf2_textindex.TextIndexEntry` or `None` if it
290            does not exist.
291        """
292        return self._index_parser.entry_at_label_path(label_path)[0]

Returns a text index entry by its label path.

Arguments:
  • label_path: The label path.
Returns:

The found fpdf2_textindex.TextIndexEntry or None if it does not exist.

@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:
294    @check_page
295    def insert_index_placeholder(
296        self,
297        render_index_function: Callable[[FPDF, list[TextIndexEntry]], None],
298        *,
299        pages: int = 1,
300        allow_extra_pages: bool = False,
301        reset_page_indices: bool = True,
302    ) -> None:
303        """Configures Text Index rendering at the end of the document
304        generation, and reserves some vertical space right now in order to
305        insert it. At least one page break is triggered by this method.
306
307        Args:
308            render_index_function: A function that will be invoked to  render
309                the Index. This function will receive 2 parameters:
310                    `pdf`: an instance of :py:class:`fpdf2_textindex.pdf.FPDF`;
311                    `entries`: a list of
312                        :py:class:`fpdf2_textindex.interface.TextIndexEntry`s.
313            pages: The number of pages that the Index will span, including the
314                current one. As many page breaks as the value of this argument
315                will occur immediately after calling this method. Defaults to
316                `1`.
317            allow_extra_pages: If set to `True`, allows for an unlimited
318                number of extra pages in the Text Index, which may cause
319                discrepancies with pre-rendered page numbers.
320                For consistent numbering, using page labels to create a separate
321                numbering style for the Index is recommended. Defaults to
322                `False`.
323            reset_page_indices : Whether to reset the pages indices after the
324                Text Index. Defaults to `True`.
325
326        Raises:
327            FPDF2TextindexError: If an index placeholder has been inserted
328                before.
329            TypeError: If `render_index_function` is not callable.
330            ValueError: If ``pages`` is less than `1`.
331        """
332        if not callable(render_index_function):
333            msg = (
334                f"The first argument must be a callable, got: "
335                f"{type(render_index_function)!s:s}"
336            )
337            raise TypeError(msg)
338        if pages < 1:
339            msg = (
340                f"'pages' parameter must be equal or greater than 1: {pages:d}"
341            )
342            raise ValueError(msg)
343        if self.index_placeholder:
344            msg = (
345                "A placeholder for the index has already been defined on page "
346                f"{self.index_placeholder.start_page}"
347            )
348            raise FPDF2TextindexError(msg)
349        self.index_placeholder = IndexPlaceholder(
350            render_index_function,
351            self.page,
352            self.y,
353            self.cur_orientation,
354            pages,
355            reset_page_indices,
356        )
357        self._index_allow_page_insertion = allow_extra_pages
358        self._index_gstate = self._get_current_graphics_state()
359        for _ in range(pages):
360            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:
  • FPDF2TextindexError: If an index placeholder has been inserted before.
  • TypeError: If render_index_function is not callable.
  • ValueError: If pages is less than 1.
@check_page
@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]:
362    @check_page
363    @support_deprecated_txt_arg
364    def multi_cell(
365        self,
366        w: float,
367        h: float | None = None,
368        text: str = "",
369        border: Literal[0, 1] | str = 0,
370        align: Align | str = Align.J,
371        fill: bool = False,
372        split_only: bool = False,  # DEPRECATED
373        link: int | str | None = None,
374        ln: Literal["DEPRECATED"] = "DEPRECATED",
375        max_line_height: float | None = None,
376        markdown: bool = False,
377        print_sh: bool = False,
378        new_x: XPos | str = XPos.RIGHT,
379        new_y: YPos | str = YPos.NEXT,
380        wrapmode: WrapMode = WrapMode.WORD,
381        dry_run: bool = False,
382        output: MethodReturnValue | str = MethodReturnValue.PAGE_BREAK,
383        center: bool = False,
384        padding: Padding | Sequence[int] | int = 0,
385        first_line_indent: float = 0,
386    ) -> fpdf.FPDF.MultiCellResult:
387        r"""This method allows printing text with line breaks.
388
389        They can be automatic (breaking at the most recent space or soft-hyphen
390        character) as soon as the text reaches the right border of the cell, or
391        explicit (via the `"\\n"` character). As many cells as necessary are
392        stacked, one below the other. Text can be aligned, centered or
393        justified. The cell block can be framed and the background painted. A
394        cell has an horizontal padding, on the left & right sides, defined by
395        the
396        [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html)-property.
397
398        Note:
399            Using
400            `new_x=XPos.RIGHT, new_y=XPos.TOP, maximum height=pdf.font_size`
401            is useful to build tables with multiline text in cells.
402
403        Args:
404            w: Cell width. If `0`, they extend up to the right margin of the
405                page.
406            h: Height of a single line of text. Defaults to `None`, meaning to
407                use the current font size.
408            text: Text to print.
409            border: Indicates if borders must be drawn around the cell.
410                The value can be either a number (`0`: no border; `1`:
411                frame) or a string containing some or all of the following
412                characters (in any order):
413                `"L"`: left,
414                `"T"`: top,
415                `"R"`: right,
416                `"B"`: bottom.
417                Defaults to `0`.
418            align: Sets the text alignment inside the cell.
419                Possible values are:
420                `"J"`: justify (default value),
421                `"L"` / `""`: left align,
422                `"C"`: center,
423                `"X"`: center around current x-position, or
424                `"R"`: right align.
425            fill: Indicates if the cell background must be painted (`True`)
426                or transparent (`False`). Defaults to `False`.
427            split_only: **DEPRECATED since 2.7.4**: Use `dry_run=True` and
428                `output=("LINES",)` instead.
429            link: Optional link to add on the cell, internal (identifier
430                returned by [:py:meth:`fpdf.FPDF.add_link`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.add_link)
431                or external URL.
432            new_x: New current position in x after the call. Defaults to
433                [:py:attr:`fpdf.XPos.RIGHT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.XPos).
434            new_y: New current position in y after the call. Defaults to
435                [:py:attr:`fpdf.YPos.NEXT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.YPos).
436            ln: **DEPRECATED since 2.5.1**: Use `new_x` and `new_y` instead.
437            max_line_height: Optional maximum height of each sub-cell generated.
438                Defaults to `None`.
439            markdown: Enables minimal markdown-like markup to render part
440                of text as bold / italics / strikethrough / underlined.
441                Supports `"\\"` as escape character. Defaults to `False`.
442            print_sh: Treat a soft-hyphen (`"\\u00ad"`) as a normal printable
443                character, instead of a line breaking opportunity. Defaults to
444                `False`.
445            wrapmode: [:py:attr:`fpdf.enums.WrapMode.WORD`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode)
446                for word based line wrapping (default) or
447                [:py:attr:`fpdf.enums.WrapMode.CHAR`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode)
448                for character based line wrapping.
449            dry_run: If `True`, does not output anything in the document.
450                Can be useful when combined with `output`. Defaults to
451                `False`.
452            output: Defines what this method returns. If several enum values are
453                joined, the result will be a tuple.
454            txt: [**DEPRECATED since v2.7.6**] String to print.
455            center: Center the cell horizontally on the page. Defaults to
456                `False`.
457            padding: Padding to apply around the text. Defaults to `0`.
458                When one value is specified, it applies the same padding to all
459                four sides.
460                When two values are specified, the first padding applies to the
461                top and bottom, the second to the left and right.
462                When three values are specified, the first padding applies to
463                the top, the second to the right and left, the third to the
464                bottom.
465                When four values are specified, the paddings apply to the top,
466                right, bottom, and left in that order (clockwise)
467                If padding for left or right ends up being non-zero then the
468                respective [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html)
469                is ignored. Center overrides values for horizontal padding.
470            first_line_indent: The indent of the first line. Defaults to `0`.
471
472        Returns:
473            A single value or a tuple, depending on the `output` parameter
474            value.
475
476        Raises:
477            FPDFException: If no font has been set before.
478            ValueError: If `w` or `h` is a string.
479        """  # noqa: DOC102
480        padding = Padding.new(padding)
481        wrapmode = WrapMode.coerce(wrapmode)
482
483        if split_only:
484            warnings.warn(
485                (
486                    'The parameter "split_only" is deprecated since v2.7.4.'
487                    ' Use instead dry_run=True and output="LINES".'
488                ),
489                DeprecationWarning,
490                stacklevel=get_stack_level(),
491            )
492        if dry_run or split_only:
493            with self._disable_writing():
494                return self.multi_cell(
495                    w=w,
496                    h=h,
497                    text=text,
498                    border=border,
499                    align=align,
500                    fill=fill,
501                    link=link,
502                    ln=ln,
503                    max_line_height=max_line_height,
504                    markdown=markdown,
505                    print_sh=print_sh,
506                    new_x=new_x,
507                    new_y=new_y,
508                    wrapmode=wrapmode,
509                    dry_run=False,
510                    split_only=False,
511                    output=MethodReturnValue.LINES if split_only else output,
512                    center=center,
513                    padding=padding,
514                    # CHANGE
515                    first_line_indent=first_line_indent,
516                )
517        if not self.font_family:
518            raise FPDFException(
519                "No font set, you need to call set_font() beforehand"
520            )
521        if isinstance(w, str) or isinstance(h, str):
522            raise ValueError(
523                "Parameter 'w' and 'h' must be numbers, not strings."
524                " You can omit them by passing string content with text="
525            )
526        new_x = XPos.coerce(new_x)
527        new_y = YPos.coerce(new_y)
528        if ln != "DEPRECATED":
529            # For backwards compatibility, if "ln" is used we overwrite
530            # "new_[xy]".
531            if ln == 0:
532                new_x = XPos.RIGHT
533                new_y = YPos.NEXT
534            elif ln == 1:
535                new_x = XPos.LMARGIN
536                new_y = YPos.NEXT
537            elif ln == 2:
538                new_x = XPos.LEFT
539                new_y = YPos.NEXT
540            elif ln == 3:
541                new_x = XPos.RIGHT
542                new_y = YPos.TOP
543            else:
544                raise ValueError(
545                    f'Invalid value for parameter "ln" ({ln}),'
546                    " must be an int between 0 and 3."
547                )
548            warnings.warn(
549                (
550                    f'The parameter "ln" is deprecated since v2.5.2.'
551                    f" Instead of ln={ln} use new_x=XPos.{new_x.name}, "
552                    f"new_y=YPos.{new_y.name}."
553                ),
554                DeprecationWarning,
555                stacklevel=get_stack_level(),
556            )
557        align = Align.coerce(align)
558
559        page_break_triggered = False
560
561        if h is None:
562            h = self.font_size
563
564        # If width is 0, set width to available width between margins
565        if w == 0:
566            w = self.w - self.r_margin - self.x
567
568        # Store the starting position before applying padding
569        prev_x, prev_y = self.x, self.y
570
571        # Apply padding to contents
572        # decrease maximum allowed width by padding
573        # shift the starting point by padding
574        maximum_allowed_width = w = w - padding.right - padding.left
575        clearance_margins: list[float] = []
576        # If we don't have padding on either side, we need a clearance margin.
577        if not padding.left:
578            clearance_margins.append(self.c_margin)
579        if not padding.right:
580            clearance_margins.append(self.c_margin)
581        if align != Align.X:
582            self.x += padding.left
583        self.y += padding.top
584
585        # Center overrides padding
586        if center:
587            self.x = (
588                self.w / 2
589                if align == Align.X
590                else self.l_margin + (self.epw - w) / 2
591            )
592            prev_x = self.x
593
594        # Calculate text length
595        text = self.normalize_text(text)
596        normalized_string = text.replace("\r", "")
597        styled_text_fragments = (
598            self._preload_bidirectional_text(normalized_string, markdown)
599            if self.text_shaping
600            else self._preload_font_styles(normalized_string, markdown)
601        )
602
603        prev_current_font = self.current_font
604        prev_font_style = self.font_style
605        prev_underline = self.underline
606        total_height: float = 0
607
608        text_lines: list[TextLine] = []
609        multi_line_break = MultiLineBreak(
610            styled_text_fragments,
611            maximum_allowed_width,
612            clearance_margins,
613            align=align,
614            print_sh=print_sh,
615            wrapmode=wrapmode,
616            # CHANGE
617            first_line_indent=first_line_indent,
618        )
619        text_line = multi_line_break.get_line()
620        while (text_line) is not None:
621            text_lines.append(text_line)
622            text_line = multi_line_break.get_line()
623
624        if (
625            not text_lines
626        ):  # ensure we display at least one cell - cf. issue #349
627            text_lines = [
628                TextLine(
629                    [],
630                    text_width=0,
631                    number_of_spaces=0,
632                    align=align,
633                    height=h,
634                    max_width=w,
635                    trailing_nl=False,
636                )
637            ]
638
639        if max_line_height is None or len(text_lines) == 1:
640            line_height = h
641        else:
642            line_height = min(h, max_line_height)
643
644        box_required = fill or border
645        page_break_triggered = False
646
647        for text_line_index, text_line in enumerate(text_lines):
648            start_of_new_page = self._perform_page_break_if_need_be(
649                h + padding.bottom
650            )
651            if start_of_new_page:
652                page_break_triggered = True
653                self.y += padding.top
654            # CHANGE
655            if text_line_index == 0:
656                self.x += first_line_indent
657            # END CHANGE
658
659            if box_required and (text_line_index == 0 or start_of_new_page):
660                # estimate how many cells can fit on this page
661                top_gap = self.y  # Top padding has already been added
662                bottom_gap = padding.bottom + self.b_margin
663                lines_before_break = int(
664                    (self.h - top_gap - bottom_gap) // line_height
665                )
666                # check how many cells should be rendered
667                num_lines = min(
668                    lines_before_break, len(text_lines) - text_line_index
669                )
670                box_height = max(
671                    h - text_line_index * line_height, num_lines * line_height
672                )
673                # render the box
674                x = self.x - (w / 2 if align == Align.X else 0)
675                draw_box_borders(
676                    self,
677                    x - padding.left,
678                    self.y - padding.top,
679                    # CHANGE
680                    x + w + padding.right + max(0, -first_line_indent),
681                    # END CHANGE
682                    self.y + box_height + padding.bottom,
683                    border,
684                    self.fill_color if fill else None,
685                )
686            is_last_line = text_line_index == len(text_lines) - 1
687            self._render_styled_text_line(
688                text_line,
689                h=line_height,
690                new_x=new_x if is_last_line else XPos.LEFT,
691                new_y=new_y if is_last_line else YPos.NEXT,
692                border=0,  # already rendered
693                fill=False,  # already rendered
694                link=link,
695                padding=Padding(0, padding.right, 0, padding.left),
696                prevent_font_change=markdown,
697            )
698            total_height += line_height
699            if not is_last_line and align == Align.X:
700                # prevent cumulative shift to the left
701                self.x = prev_x
702            # CHANGE
703            if text_line_index == 0:
704                self.x -= first_line_indent
705            # END CHANGE
706
707        if total_height < h:
708            # Move to the bottom of the multi_cell
709            if new_y == YPos.NEXT:
710                self.y += h - total_height
711            total_height = h
712
713        if page_break_triggered and new_y == YPos.TOP:
714            # When a page jump is performed and the requested y is TOP,
715            # pretend we started at the top of the text block on the new page.
716            # cf. test_multi_cell_table_with_automatic_page_break
717            prev_y = self.y
718
719        last_line = text_lines[-1]
720        if (
721            last_line
722            and last_line.trailing_nl
723            and new_y in (YPos.LAST, YPos.NEXT)
724        ):
725            # The line renderer can't handle trailing newlines in the text.
726            self.ln()
727
728        if new_y == YPos.TOP:  # We may have jumped a few lines -> reset
729            self.y = prev_y
730        elif new_y == YPos.NEXT:  # move down by bottom padding
731            self.y += padding.bottom
732
733        if markdown:
734            self.font_style = prev_font_style
735            self.current_font = prev_current_font
736            self.underline = prev_underline
737
738        if (
739            new_x == XPos.RIGHT
740        ):  # move right by right padding to align outer RHS edge
741            self.x += padding.right
742        elif (
743            new_x == XPos.LEFT
744        ):  # move left by left padding to align outer LHS edge
745            self.x -= padding.left
746
747        output = MethodReturnValue.coerce(output)
748        return_value = ()
749        if output & MethodReturnValue.PAGE_BREAK:
750            return_value += (page_break_triggered,)  # type: ignore[assignment]
751        if output & MethodReturnValue.LINES:
752            output_lines = self._join_text_lines(text_lines, markdown=markdown)  # type: ignore[attr-defined]
753            return_value += (output_lines,)  # type: ignore[assignment]
754        if output & MethodReturnValue.HEIGHT:
755            return_value += (total_height + padding.top + padding.bottom,)  # type: ignore[assignment]
756        if len(return_value) == 1:
757            return return_value[0]
758        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:
778    def output(
779        self,
780        name: os.PathLike[str] | BinaryIO | str | Literal[""] | None = "",
781        *,
782        linearize: bool = False,
783        output_producer_class: type[OutputProducer] = OutputProducer,
784    ) -> bytearray | None:
785        """Output PDF to some destination.
786
787        By default the bytearray buffer is returned.
788        If a `name` is given, the PDF is written to a new file.
789
790        Args:
791            name: Optional file object or file path where to save the PDF under.
792                Defaults to `""`.
793            linearize: Whether to use the
794                :py:class:`fpdf.output.LinearizedOutputProducer`. Defaults to
795                `False`.
796            output_producer_class: Use a custom class for PDF file generation.
797                Defaults to :py:class:`fpdf.output.OutputProducer`.
798
799        Returns:
800            If a `name` is given, the PDF will be written to a new file and
801            `None` will be returned. Else, a bytearray buffer is returned,
802            comprising the PDF.
803
804        Raises:
805            PDFAComplianceError: If the compliance requires at least one
806                embedded file.
807        """
808        # Clear cache of cached functions to free up memory after output
809        get_unicode_script.cache_clear()
810        # Finish document if necessary:
811        if not self.buffer:
812            if self.page == 0:
813                self.add_page()
814            # Generating final page footer:
815            self._render_footer()
816            # Generating .buffer based on .pages:
817            if self.toc_placeholder:
818                self._insert_table_of_contents()
819            # CHANGE
820            if self.index_placeholder:
821                self._insert_index()
822            # CHANGE
823            if self.str_alias_nb_pages:
824                for page in self.pages.values():
825                    for substitution_item in page.get_text_substitutions():
826                        page.contents = page.contents.replace(  # type: ignore[union-attr]
827                            substitution_item.get_placeholder_string().encode(
828                                "latin-1"
829                            ),
830                            substitution_item.render_text_substitution(
831                                str(self.pages_count)
832                            ).encode("latin-1"),
833                        )
834            for _, font in self.fonts.items():
835                if isinstance(font, TTFFont) and font.color_font:
836                    font.color_font.load_glyphs()
837            if self._compliance and self._compliance.profile == "PDFA":
838                if len(self._output_intents) == 0:
839                    self.add_output_intent(
840                        OutputIntentSubType.PDFA,
841                        output_condition_identifier="sRGB",
842                        output_condition="IEC 61966-2-1:1999",
843                        registry_name="http://www.color.org",
844                        dest_output_profile=PDFICCProfile(
845                            contents=builtin_srgb2014_bytes(),
846                            n=3,
847                            alternate="DeviceRGB",
848                        ),
849                        info="sRGB2014 (v2)",
850                    )
851                if (
852                    self._compliance.part == 4
853                    and self._compliance.conformance == "F"
854                    and len(self.embedded_files) == 0
855                ):
856                    msg = (
857                        f"{self._compliance.label} requires at least one "
858                        "embedded file"
859                    )
860                    raise PDFAComplianceError(msg)
861            if linearize:
862                output_producer_class = LinearizedOutputProducer
863            output_producer = output_producer_class(self)
864            self.buffer = output_producer.bufferize()
865        if name:
866            if isinstance(name, (str, os.PathLike)):
867                pathlib.Path(name).write_bytes(self.buffer)
868            else:
869                name.write(self.buffer)
870            return None
871        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.
class FPDF2TextindexError(fpdf.errors.FPDFException):
 7class FPDF2TextindexError(FPDFException):
 8    """FPDF2 Textindex Error.
 9
10    Inherits from `fpdf.errors.FPDFException`.
11    """

FPDF2 Textindex Error.

Inherits from fpdf.errors.FPDFException.

@dataclasses.dataclass(kw_only=True, slots=True)
class LinkLocation:
 7@dataclasses.dataclass(kw_only=True, slots=True)
 8class LinkLocation:
 9    """Link Location."""
10
11    page: int
12    """The page the link is referened/used on."""
13
14    x: float
15    """The `x`-position on the page."""
16
17    y: float
18    """The `y`-position on the page."""
19
20    w: float
21    """The width the link has on the page."""
22
23    h: float
24    """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:
12@dataclasses.dataclass(kw_only=True, slots=True)
13class Reference:
14    """Reference."""
15
16    start_id: int
17    """The start id of the reference."""
18
19    start_suffix: str | None = None
20    """The start suffix of the reference or `None`."""
21
22    start_location: LinkLocation | None = dataclasses.field(
23        default=None, init=False
24    )
25    """The start (link) location in the document the reference is set at."""
26
27    end_id: int | None = dataclasses.field(default=None, init=False)
28    """The end id of the reference or `None`."""
29
30    end_suffix: str | None = dataclasses.field(default=None, init=False)
31    """The end suffix of the reference or `None`."""
32
33    end_location: LinkLocation | None = dataclasses.field(
34        default=None, init=False
35    )
36    """The end (link) location in the document the reference is set at."""
37
38    locator_emphasis: bool = False
39    """Whether to emphasize the locator (page number) of the reference in the
40    text index (`True`) or not (`False`)."""
41
42    @property
43    def start_link(self) -> str:
44        """The start link in the document that must be set in the text index to
45        lead from the text to the text index.
46        """
47        return f"{const.INDEX_ID_PREFIX:s}{self.start_id:d}"
48
49    @property
50    def end_link(self) -> str | None:
51        """The end link in the document that must be set in the text index to
52        lead from the text to the text index. In case of no end id, the end link
53        will be `None`.
54        """
55        if self.end_id is None:
56            return None
57        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.Node):
 41@dataclasses.dataclass(kw_only=True, repr=False, slots=True)
 42class TextIndexEntry(Node):
 43    """Text Index Entry."""
 44
 45    _references: list[Reference] = dataclasses.field(
 46        default_factory=list, init=False
 47    )
 48    """The references."""
 49
 50    _cross_references: list[CrossReference] = dataclasses.field(
 51        default_factory=list, init=False
 52    )
 53    """The cross references."""
 54
 55    sort_key: str | None = dataclasses.field(default=None, init=False)
 56    """The sort key."""
 57
 58    def __hash__(self) -> int:
 59        return hash((self.id, self.label))
 60
 61    @property
 62    def cross_references(self) -> list[CrossReference]:
 63        """The cross references."""
 64        return self._cross_references.copy()
 65
 66    @property
 67    def references(self) -> list[Reference]:
 68        """The references."""
 69        return self._references.copy()
 70
 71    @property
 72    def sort_label(self) -> str:
 73        """The sort label of the entry."""
 74        label = MDEmphasis.remove(self.label)
 75        if not label:
 76            label = const._LAST_SORT_LABEL
 77        if self.sort_key:
 78            label = self.sort_key + label
 79        return label.lower()
 80
 81    @property
 82    def sorted_children(self) -> list[TextIndexEntry]:
 83        """The child entries sorted by its sort label."""
 84        return sorted(self._children, key=lambda c: c.sort_label)
 85
 86    def add_cross_reference(
 87        self,
 88        id: int,
 89        cross_ref_type: CrossReferenceType,
 90        label_path: LabelPathT,
 91        *,
 92        strict: bool = True,
 93    ) -> None:
 94        """Adds a cross reference to the entry.
 95
 96        Args:
 97            id: The id of the cross reference.
 98            cross_ref_type: The type of the cross reference.
 99            label_path: The label path of the cross reference.
100            strict: Whether to raise a `FPDF2TextindexError` if adding a
101                SEE-cross reference to an entry with former "normal" reference
102                (locator). Else, it will just be a warning and the SEE-cross
103                reference will be automatically converted to SEE ALSO. Defaults
104                to `True`.
105
106        Raises:
107            FPDF2TextindexError: If `strict=True` and adding a SEE-cross
108                reference to an entry with former "normal" reference (locator).
109        """
110        cref = CrossReference(id=id, type=cross_ref_type, label_path=label_path)  # type: ignore[arg-type]
111        if self._references and cref.type == CrossReferenceType.SEE:
112            if strict:
113                msg = (
114                    f"cannot add a SEE-cross reference to entry "
115                    f"{self.joined_label_path!r} with former reference "
116                    f"(locator)"
117                )
118                raise FPDF2TextindexError(msg)
119            LOGGER.warning(
120                "Adding a SEE-cross reference to entry %r with former "
121                "reference (locator); cross reference will be converted to SEE "
122                "ALSO",
123                self.joined_label_path,
124            )
125            cref.type = CrossReferenceType.ALSO
126        bisect.insort(
127            self._cross_references,
128            cref,
129            key=lambda cr: (cr.type, *cr.label_path),
130        )
131
132    def add_reference(
133        self,
134        start_id: int,
135        *,
136        locator_emphasis: bool = False,
137        start_suffix: str | None = None,
138        strict: bool = True,
139    ) -> None:
140        """Adds a reference (locator) to the entry.
141
142        Args:
143            start_id: The start id of the reference.
144            locator_emphasis: Whether to emphasize the locator of the reference.
145                Defaults to `False`.
146            start_suffix: The start suffix of the reference. Defaults to
147                `None`.
148            strict: Whether to raise a `FPDF2TextindexError` if adding a
149                SEE-cross reference to an entry with former "normal" reference
150                (locator). Else, it will just be a warning and the SEE-cross
151                reference will be automatically converted to SEE ALSO. Defaults
152                to `True`.
153
154        Raises:
155            FPDF2TextindexError: If `strict=True` and adding a reference locator
156                to an entry with former SEE-cross reference.
157        """
158        ref = Reference(
159            start_id=start_id,
160            start_suffix=start_suffix,
161            locator_emphasis=locator_emphasis,
162        )
163        if any(
164            cref.type == CrossReferenceType.SEE
165            for cref in self._cross_references
166        ):
167            if strict:
168                msg = (
169                    f"cannot add a reference (locator) to entry "
170                    f"{self.joined_label_path!r} with former SEE-cross "
171                    f"reference"
172                )
173                raise FPDF2TextindexError(msg)
174            LOGGER.warning(
175                "Adding a reference (locator) to entry %r with former SEE-"
176                "cross reference(s); cross reference(s) will be converted to "
177                "SEE ALSO",
178                self.joined_label_path,
179            )
180            for cref in self._cross_references:
181                if cref.type == CrossReferenceType.SEE:
182                    cref.type = CrossReferenceType.ALSO
183            self._cross_references.sort(
184                key=lambda cr: (cr.type, *cr.label_path)
185            )
186        # Note: Not sorting here because order of insertion matters for
187        #       `update_latest_reference_end`
188        self._references.append(ref)
189
190    def update_latest_reference_end(
191        self,
192        end_id: int,
193        end_suffix: str | None = None,
194    ) -> None:
195        """Updates the end of the latest reference.
196
197        Args:
198            end_id: The end id of the latest reference.
199            end_suffix: The end suffix of the latest reference. Defaults to
200                `None`.
201
202        Raises:
203            FPDF2TextindexError: If there has been no reference started before.
204        """
205        if len(self._references) == 0:
206            msg = "cannot update latest reference end without reference"
207            raise FPDF2TextindexError(msg)
208        self._references[-1].end_id = end_id
209        self._references[-1].end_suffix = end_suffix

Text Index Entry.

TextIndexEntry(*, label: str, parent: 'Self | None' = None)
sort_key: str | None

The sort key.

cross_references: list[CrossReference]
61    @property
62    def cross_references(self) -> list[CrossReference]:
63        """The cross references."""
64        return self._cross_references.copy()

The cross references.

references: list[Reference]
66    @property
67    def references(self) -> list[Reference]:
68        """The references."""
69        return self._references.copy()

The references.

sort_label: str
71    @property
72    def sort_label(self) -> str:
73        """The sort label of the entry."""
74        label = MDEmphasis.remove(self.label)
75        if not label:
76            label = const._LAST_SORT_LABEL
77        if self.sort_key:
78            label = self.sort_key + label
79        return label.lower()

The sort label of the entry.

sorted_children: list[TextIndexEntry]
81    @property
82    def sorted_children(self) -> list[TextIndexEntry]:
83        """The child entries sorted by its sort label."""
84        return sorted(self._children, key=lambda c: c.sort_label)

The child entries sorted by its sort label.

def add_cross_reference( self, id: int, cross_ref_type: CrossReferenceType, label_path: fpdf2_textindex.interface.label_path.LabelPath | Iterable[str] | str, *, strict: bool = True) -> None:
 86    def add_cross_reference(
 87        self,
 88        id: int,
 89        cross_ref_type: CrossReferenceType,
 90        label_path: LabelPathT,
 91        *,
 92        strict: bool = True,
 93    ) -> None:
 94        """Adds a cross reference to the entry.
 95
 96        Args:
 97            id: The id of the cross reference.
 98            cross_ref_type: The type of the cross reference.
 99            label_path: The label path of the cross reference.
100            strict: Whether to raise a `FPDF2TextindexError` if adding a
101                SEE-cross reference to an entry with former "normal" reference
102                (locator). Else, it will just be a warning and the SEE-cross
103                reference will be automatically converted to SEE ALSO. Defaults
104                to `True`.
105
106        Raises:
107            FPDF2TextindexError: If `strict=True` and adding a SEE-cross
108                reference to an entry with former "normal" reference (locator).
109        """
110        cref = CrossReference(id=id, type=cross_ref_type, label_path=label_path)  # type: ignore[arg-type]
111        if self._references and cref.type == CrossReferenceType.SEE:
112            if strict:
113                msg = (
114                    f"cannot add a SEE-cross reference to entry "
115                    f"{self.joined_label_path!r} with former reference "
116                    f"(locator)"
117                )
118                raise FPDF2TextindexError(msg)
119            LOGGER.warning(
120                "Adding a SEE-cross reference to entry %r with former "
121                "reference (locator); cross reference will be converted to SEE "
122                "ALSO",
123                self.joined_label_path,
124            )
125            cref.type = CrossReferenceType.ALSO
126        bisect.insort(
127            self._cross_references,
128            cref,
129            key=lambda cr: (cr.type, *cr.label_path),
130        )

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 FPDF2TextindexError 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:
  • FPDF2TextindexError: 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:
132    def add_reference(
133        self,
134        start_id: int,
135        *,
136        locator_emphasis: bool = False,
137        start_suffix: str | None = None,
138        strict: bool = True,
139    ) -> None:
140        """Adds a reference (locator) to the entry.
141
142        Args:
143            start_id: The start id of the reference.
144            locator_emphasis: Whether to emphasize the locator of the reference.
145                Defaults to `False`.
146            start_suffix: The start suffix of the reference. Defaults to
147                `None`.
148            strict: Whether to raise a `FPDF2TextindexError` if adding a
149                SEE-cross reference to an entry with former "normal" reference
150                (locator). Else, it will just be a warning and the SEE-cross
151                reference will be automatically converted to SEE ALSO. Defaults
152                to `True`.
153
154        Raises:
155            FPDF2TextindexError: If `strict=True` and adding a reference locator
156                to an entry with former SEE-cross reference.
157        """
158        ref = Reference(
159            start_id=start_id,
160            start_suffix=start_suffix,
161            locator_emphasis=locator_emphasis,
162        )
163        if any(
164            cref.type == CrossReferenceType.SEE
165            for cref in self._cross_references
166        ):
167            if strict:
168                msg = (
169                    f"cannot add a reference (locator) to entry "
170                    f"{self.joined_label_path!r} with former SEE-cross "
171                    f"reference"
172                )
173                raise FPDF2TextindexError(msg)
174            LOGGER.warning(
175                "Adding a reference (locator) to entry %r with former SEE-"
176                "cross reference(s); cross reference(s) will be converted to "
177                "SEE ALSO",
178                self.joined_label_path,
179            )
180            for cref in self._cross_references:
181                if cref.type == CrossReferenceType.SEE:
182                    cref.type = CrossReferenceType.ALSO
183            self._cross_references.sort(
184                key=lambda cr: (cr.type, *cr.label_path)
185            )
186        # Note: Not sorting here because order of insertion matters for
187        #       `update_latest_reference_end`
188        self._references.append(ref)

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 FPDF2TextindexError 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:
  • FPDF2TextindexError: 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:
190    def update_latest_reference_end(
191        self,
192        end_id: int,
193        end_suffix: str | None = None,
194    ) -> None:
195        """Updates the end of the latest reference.
196
197        Args:
198            end_id: The end id of the latest reference.
199            end_suffix: The end suffix of the latest reference. Defaults to
200                `None`.
201
202        Raises:
203            FPDF2TextindexError: If there has been no reference started before.
204        """
205        if len(self._references) == 0:
206            msg = "cannot update latest reference end without reference"
207            raise FPDF2TextindexError(msg)
208        self._references[-1].end_id = end_id
209        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:
  • FPDF2TextindexError: If there has been no reference started before.
id: int

The id.

label: str

The label.

parent: Optional[typing_extensions.Self]

The parent.

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

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)
 74    def __init__(
 75        self,
 76        *,
 77        border: bool = False,
 78        ignore_same_page_refs: bool = True,
 79        level_indent: float | None = 7.5,
 80        line_spacing: float | None = None,
 81        max_outline_level: int | None = None,
 82        outline_level: int | None = None,
 83        run_in_style: bool = True,
 84        show_header: bool = False,
 85        sort_emph_first: bool = False,
 86        text_styles: Iterable[fpdf.TextStyle] | fpdf.TextStyle | None = None,
 87    ) -> None:
 88        """Initializes the renderer.
 89
 90        Args:
 91            border: Whether to show borders around the entries and headers.
 92                Mainly for debugging purposes. Defaults to `False`.
 93            ignore_same_page_refs: Whether to ignore references (locators) to
 94                the same PDF page (default), else same pages will be printed
 95                multiple times.
 96            level_indent: The indent to add per entry depth to the left of the
 97                entry. Defaults to `7.5` times the
 98                [fpdf.FPDF.unit](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF).
 99            line_spacing: The spacing between lines as multiple of the font
100                size. Defaults to `None`, meaning `1.0`.
101            max_outline_level: If `outline_level` >= 0, `max_outline_level`
102                will decide how many deeper entries will be added to the PDF
103                outline. Defaults to `None`, meaning that no liimit is set.
104            outline_level: If `outline_level` >= 0, the first entry depth will
105                be added at this outline level to the PDF. If
106                `show_header=True`, the headers will be added at this outline
107                level to the PDF. Defaults to `None`, meaning to not show the
108                entries (or headers) in the PDF outline.
109            run_in_style: Whether to print the deepest entry levels at "run-in"-
110                style (>2). Defaults to `True`.
111            show_header: Whether to show the headers. Defaults to `False`.
112            sort_emph_first: Whether to show emphasized references (locators)
113                first. Defaults to `False`.
114            text_styles: The text styles to use to print the entries at the
115                different depths. If `show_header=True`, the first text style
116                refers to the style of the headers. If an entry is "deeper" than
117                there are text styles, the renderer will fall back to deepest
118                given text style. Defaults to `None`, meaning to take the
119                text style of the last PDF page.
120        """  # noqa: DOC501
121        self.border = border
122        self.ignore_same_page_refs = bool(ignore_same_page_refs)
123        self.level_indent = 0.0 if level_indent is None else float(level_indent)
124        self.line_spacing = 1.0 if line_spacing is None else float(line_spacing)
125        self.max_outline_level = (
126            -1 if max_outline_level is None else int(max_outline_level)
127        )
128        self.outline_level = -1 if outline_level is None else int(outline_level)
129        self.run_in_style = bool(run_in_style)
130        self.show_header = bool(show_header)
131        self.sort_emph_first = bool(sort_emph_first)
132
133        if text_styles is None:
134            self.text_styles = [fpdf.TextStyle()]
135        elif isinstance(text_styles, Iterable):
136            self.text_styles = list(text_styles)
137        elif isinstance(text_styles, fpdf.TextStyle):
138            self.text_styles = [text_styles]
139        else:
140            msg = f"invalid type of text_styles: {type(text_styles):__name__:s}"
141            raise TypeError(msg)
142
143        self._cur_header = None
144        self._h_header_min = None
145        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:
147    def render_text_index(
148        self,
149        pdf: FPDF,
150        entries: list[TextIndexEntry],
151    ) -> None:
152        """Renders the text index.
153
154        Note:
155            Use this method as `render_index_function`-argument in
156            `fpdf2_textindex.pdf.FPDF.insert_index_placeholder`.
157
158        Args:
159            pdf: The `fpdf2_textindex.pdf.FPDF`-instance to render in.
160            entries: The list of entries to render.
161
162        Raises:
163            ValueError: If a textstyle has a
164                [fpdf.Align](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.Align)
165                -value as left margin.
166        """  # noqa: DOC502
167        assert pdf.index_placeholder is not None
168
169        LOGGER.info("Rendering text index")
170        if not entries:
171            LOGGER.warning("No entries defined")
172            return
173
174        max_depth = max(e.depth for e in entries)
175        if max_depth > 2:
176            if self.run_in_style:
177                LOGGER.warning(
178                    "Deep index (>2 levels): Level %d entries will be run-in "
179                    "to level %d (see docs to disable)",
180                    max_depth,
181                    max_depth - 1,
182                )
183            else:
184                LOGGER.warning(
185                    "Deep index (>2 levels): Consider reducing depth, or "
186                    "enable run-in (see docs)"
187                )
188
189        # Reset section title styles to guarantee adding to outline without add
190        # section title
191        prev_section_title_styles = pdf.section_title_styles
192        pdf.section_title_styles = {}
193
194        for entry in entries:
195            if entry.depth > 1:
196                continue
197
198            prepared_entries: list[tuple[TextIndexEntryP, str]] = list(
199                self._prepare_entry(pdf, entry, max_depth)
200            )
201            self._render_header(pdf, entry, prepared_entries[0][1])
202            for e, text in prepared_entries:
203                # LOGGER.info("%d %r", pdf.page, e.label)
204                page_entry, x_entry, y_entry, w_entry, h_entry = (
205                    self._render_entry(pdf, e, text)
206                )
207                if isinstance(e, TextIndexEntry):
208                    self._set_links(
209                        pdf, e, page_entry, x_entry, y_entry, w_entry, h_entry
210                    )
211                    if self._run_in_children(e, max_depth):
212                        for c in e.children:
213                            self._set_links(
214                                pdf,
215                                c,
216                                page_entry,
217                                x_entry,
218                                y_entry,
219                                w_entry,
220                                h_entry,
221                            )
222
223        pdf.section_title_styles = prev_section_title_styles
224
225        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.1'