fpdf2_textindex
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
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 adaptedFPDFinstance, andentries, a list offpdf2_textindex.TextIndexEntrys. A reference implementation is supported throughfpdf2_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: IfTrue, 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.
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:
- 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. - Define the alias
"#demo"for the path to the subentry ("foo">"example text"). - 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.
- A SEE-cross reference to the
- Apply the
"whiz"suffix to the directive's reference locator. - Sort the entry as if its heading starts with
"z". - 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
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.
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.
The label path the cross reference points to.
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}"
The link in the document that must be set in the text index to lead from the text to the text index.
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.
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.
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,
FPDFactively prevents non-compliant operations and will raise errors if you try something forbidden for the selected profile. Defaults toNone.
The path to a concordance file. Defaults to None.
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.
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.
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.
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.TextIndexEntryorNoneif it does not exist.
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 offpdf2_textindex.pdf.FPDF;entries: a list offpdf2_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 toFalse. - 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_functionis not callable. - ValueError: If
pagesis less than1.
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_sizeis 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 to0. - 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 toFalse. - split_only: DEPRECATED since 2.7.4: Use
dry_run=Trueandoutput=("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_xandnew_yinstead. - 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 toFalse. - print_sh: Treat a soft-hyphen (
"\\u00ad") as a normal printable character, instead of a line breaking opportunity. Defaults toFalse. - wrapmode:
fpdf.enums.WrapMode.WORDfor word based line wrapping (default) orfpdf.enums.WrapMode.CHARfor character based line wrapping. - dry_run: If
True, does not output anything in the document. Can be useful when combined withoutput. Defaults toFalse. - 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 respectivefpdf.FPDF.c_marginis 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
outputparameter value.
Raises:
- FPDFException: If no font has been set before.
- ValueError: If
worhis a string.
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 toFalse. - output_producer_class: Use a custom class for PDF file generation.
Defaults to
fpdf.output.OutputProducer.
Returns:
If a
nameis given, the PDF will be written to a new file andNonewill be returned. Else, a bytearray buffer is returned, comprising the PDF.
Raises:
- PDFAComplianceError: If the compliance requires at least one embedded file.
7class FPDF2TextindexError(FPDFException): 8 """FPDF2 Textindex Error. 9 10 Inherits from `fpdf.errors.FPDFException`. 11 """
FPDF2 Textindex Error.
Inherits from fpdf.errors.FPDFException.
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.
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.
The start (link) location in the document the reference is set at.
Whether to emphasize the locator (page number) of the reference in the
text index (True) or not (False).
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}"
The start link in the document that must be set in the text index to lead from the text to the text index.
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}"
The end link in the document that must be set in the text index to
lead from the text to the text index. In case of no end id, the end link
will be None.
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.
61 @property 62 def cross_references(self) -> list[CrossReference]: 63 """The cross references.""" 64 return self._cross_references.copy()
The cross references.
66 @property 67 def references(self) -> list[Reference]: 68 """The references.""" 69 return self._references.copy()
The references.
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.
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.
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
FPDF2TextindexErrorif 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 toTrue.
Raises:
- FPDF2TextindexError: If
strict=Trueand adding a SEE-cross reference to an entry with former "normal" reference (locator).
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
FPDF2TextindexErrorif 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 toTrue.
Raises:
- FPDF2TextindexError: If
strict=Trueand adding a reference locator to an entry with former SEE-cross reference.
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.
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().
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.5times the .fpdf.FPDF">fpdf.FPDF.unit. - line_spacing: The spacing between lines as multiple of the font
size. Defaults to
None, meaning1.0. - max_outline_level: If
outline_level>= 0,max_outline_levelwill decide how many deeper entries will be added to the PDF outline. Defaults toNone, 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. Ifshow_header=True, the headers will be added at this outline level to the PDF. Defaults toNone, 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 toNone, meaning to take the text style of the last PDF page.
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 infpdf2_textindex.pdf.FPDF.insert_index_placeholder.
Arguments:
- pdf: The
fpdf2_textindex.pdf.FPDF-instance to render in. - entries: The list of entries to render.
Raises:
- ValueError: If a textstyle has a fpdf.Align -value as left margin.