fpdf2_textindex
fpdf2 Text Index
Adds a text index to fpdf2, based on the documentation and source code of Math Gemmell's Text Index:
from fpdf2_textindex import FPDF, TextIndexRenderer
pdf = FPDF()
pdf.add_page()
pdf.set_font('helvetica', size=12)
# Adding text index entry "example
pdf.cell(text="example{^}", markdown=True)
# Add the text index to a page
pdf.add_page()
pdf.insert_index_placeholder(TextIndexRenderer().render_text_index)
# Save as pdf
pdf.output("example.pdf")
Adding a Text Index Entry
Use the text index-syntax to define index directives in a text:
Most mechanical keyboard firmware{^} supports the use of [key combinations]{^}.
Print it in the PDF by enabling markdown in fpdf2.FPDF.cell or
fpdf2.FPDF.multi_cell:
pdf = FPDF()
pdf.add_page()
pdf.set_font('helvetica', size=12)
pdf.cell(
text="Most mechanical keyboard firmware{^} supports the use of [key combinations]{^}.",
markdown=True,
)
...
For a complete documentation of the supported text index directives, see the excellent documentation of Math Gemmell.
The only difference to this documentation is the adaption of the emphasis to the markdown style of fpdf2. So the text:
This entry will be **emphasised**{^} in the index.
This expanded entry will be **[not emphasised]{^"* (nope)"}** in the index but here in the text.
will be printed in the PDF as:
This entry will be emphasised in the index.
This expanded entry will be not emphasised in the index but here in the text.
Similarly, the marks for italics __, underline -- and strikethrough ~~ are
supported.
Inserting a Text Index
Use the adapted FPDF-class of this package that offers a
fpdf2_textindex.FPDF.insert_index_placeholder-method to define a placeholder
for the text index. A page break is triggered after inserting the text
index:
...
pdf.add_page()
pdf.insert_index_placeholder(render_index_function)
Parameters:
render_index_function: Function called to render the text index, receiving two parameters:pdf, an 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.
Note: Enabling allow_extra_pages may affect page numbering for headers or
footers. Since extra text index pages are added after the document content, they
might cause page numbers to appear out of sequence. To maintain consistent
numbering, use Page Labels to assign a specific numbering style to the index
pages. When using Page Labels, any extra text index pages will follow the
numbering style of the first text index page
Example
An example can be created by
example/textindex_figures.py
and produces
textindex_figures.pdf
with all the examples from
Math Gemmell's website.
Internals - Idea
For the curious reader:
This package adds a markdown parser to fpdf2
that intercepts markdown-styled strings to fpdf2.FPDF.cell or
fpdf2.FPDF.multi_cell and translates
Math Gemmell's Text Index-directives into
markdown-links with an unset internal PDF link as destination, while the created
index entries are internally saved:
"example{^}"
=
"[example](#idx0)"
+
TextIndexEntry(label="example", references=[Reference(start_id=0)])
When creating the actual text index in the PDF, all unset internal PDF link annotations that are related to the text index (identified by an unique id schema) are collected and its page, x/y-position on the page added to the entry's references:
{"idx0": LinkLocation(page=3, x=20.0, y=40.0, ...), ...}
->
TextIndexEntry.references[0].start_location = LinkLocation(page=3, x=20.0, y=40.0, ...)
Finally, a render_index_function similar to the
official TOC-implementation of fpdf2
is used to render the index. The package supports a reference implementation,
but the user can implement its own version if necessary.
The reference render_index_function renders each index entry according to
The Chicago Manual of Style - Indexes.
The references (locators) of each entry are matched by its id to an unset link
annotation in the PDF and, thus, to a page number/label, which is used to print
the reference (locator) in the index:
"example, 3"
The unset link annotation in the text is pointed to this entry in the index and, thus, is finally set.
In the reference implementation, inverted links are added as well: To create a
connection of the index entry to the text page, the printed page number will
point to the text page as well.
So clicking on "example" on the text page will lead to corresponding entry in
the text index. Clicking on the reference (locator) in the text index, page
"3", will return the reader to the text page. Cross-references are connected
in the same way but inside of the text index.
The module gives direct access to some classes defined in submodules:
1""" 2[](https://www.gnu.org/licenses/gpl-3.0) 3 4.. include:: ../README.md 5 :start-line: 8 6 7--- 8 9The module gives direct access to some classes defined in submodules: 10 11* :py:class:`fpdf2_textindex.Alias` 12* :py:class:`fpdf2_textindex.CrossReference` 13* :py:class:`fpdf2_textindex.CrossReferenceType` 14* :py:class:`fpdf2_textindex.FPDF` 15* :py:class:`fpdf2_textindex.LinkLocation` 16* :py:class:`fpdf2_textindex.Reference` 17* :py:class:`fpdf2_textindex.TextIndexEntry` 18* :py:class:`fpdf2_textindex.TextIndexRenderer` 19""" # noqa: D212, D415 20 21# Monkey-patch fpdf bugs first 22import fpdf2_textindex._fpdf # noqa: F401 23from fpdf2_textindex.interface import Alias 24from fpdf2_textindex.interface import CrossReference 25from fpdf2_textindex.interface import CrossReferenceType 26from fpdf2_textindex.interface import LinkLocation 27from fpdf2_textindex.interface import Reference 28from fpdf2_textindex.interface import TextIndexEntry 29from fpdf2_textindex.pdf import FPDF 30from fpdf2_textindex.renderer import TextIndexRenderer 31from fpdf2_textindex.version import FPDF2_TEXTINDEX_VERSION 32 33__docformat__ = "google" 34__license__ = "GPL 3.0" 35__version__ = FPDF2_TEXTINDEX_VERSION 36 37__all__ = ( # noqa: RUF022 38 "Alias", 39 "CrossReference", 40 "CrossReferenceType", 41 "FPDF", 42 "LinkLocation", 43 "Reference", 44 "TextIndexEntry", 45 "TextIndexRenderer", 46 "__license__", 47 "__version__", 48)
API Documentation
35@dataclasses.dataclass(frozen=True, kw_only=True, slots=True) 36class Alias(_LabelPathABC): 37 """Alias.""" 38 39 name: str 40 """The name of the alias.""" 41 42 label_path: tuple[str, ...] 43 """The label path of the alias.""" 44 45 def __repr__(self) -> str: 46 return ( 47 f"{type(self).__name__}" 48 f"(#{self.name:s} -> {self.joined_label_path!r:s})" 49 )
Alias.
72@dataclasses.dataclass(kw_only=True, slots=True) 73class CrossReference(_LabelPathABC): 74 """Cross Reference.""" 75 76 id: int 77 """The id of the cross reference.""" 78 79 type: CrossReferenceType 80 """The type of the cross reference.""" 81 82 label_path: tuple[str, ...] 83 """The label path the cross reference points to.""" 84 85 location: LinkLocation | None = dataclasses.field(default=None, init=False) 86 """The (link) location in the document the cross reference is set at.""" 87 88 def __str__(self) -> str: 89 return f"{self.type.capitalize():s} {self.joined_label_path:s}" 90 91 def __repr__(self) -> str: 92 return f"{type(self).__name__}('{self!s:s}')" 93 94 @property 95 def link(self) -> str: 96 """The link in the document that must be set in the text index to lead 97 from the text to the text index. 98 """ 99 return f"{const.INDEX_ID_PREFIX:s}{self.id:d}"
Cross Reference.
94 @property 95 def link(self) -> str: 96 """The link in the document that must be set in the text index to lead 97 from the text to the text index. 98 """ 99 return f"{const.INDEX_ID_PREFIX:s}{self.id:d}"
The link in the document that must be set in the text index to lead from the text to the text index.
102class CrossReferenceType(str, enum.Enum): 103 """Cross Reference Type.""" 104 105 NONE = "none" 106 """No cross reference.""" 107 108 SEE = "see" 109 """SEE-cross reference.""" 110 111 ALSO = "see also" 112 """SEE ALSO-cross reference.""" 113 114 def __str__(self) -> str: 115 return self.value 116 117 @classmethod 118 def _missing_(cls, value: Any) -> Self | None: # noqa: ANN401 119 if value is None: 120 return cls.NONE 121 if isinstance(value, str): 122 return cls(value.upper()) 123 return None
Cross Reference Type.
52class FPDF(fpdf.FPDF): 53 """PDF Generation Class.""" 54 55 if TYPE_CHECKING: 56 _index_allow_page_insertion: bool 57 _index_links: dict[str, int] 58 _index_parser: TextIndexParser 59 index_placeholder: IndexPlaceholder | None 60 61 CONCORDANCE_FILE: os.PathLike[str] | str | None = None 62 """The path to a concordance file. Defaults to `None`.""" 63 64 STRICT_INDEX_MODE: bool = True 65 """If `True` and an entry has a normal reference (locator) and a SEE-cross 66 reference, a `ValueError` will be raised. Else, it will just be a warning. 67 Defaults to `True`. 68 """ 69 70 def __init__( 71 self, 72 orientation: PageOrientation | str = PageOrientation.PORTRAIT, 73 unit: str | float = "mm", 74 format: str | tuple[float, float] = "A4", 75 font_cache_dir: Literal["DEPRECATED"] = "DEPRECATED", 76 *, 77 enforce_compliance: DocumentCompliance | str | None = None, 78 ) -> None: 79 """Initializes the :py:class:`FPDF`. 80 81 Args: 82 orientation: Page orientation. Possible values are `"portrait"` (can 83 be abbreviated `"P"`) or `"landscape"` (can be abbreviated 84 `"L"`). Defaults to `"portrait"`. 85 unit: Possible values are `"pt"`, `"mm"`, `"cm"`, `"in"`, or a 86 number. A point equals 1/72 of an inch, that is to say about 87 0.35 mm (an inch being 2.54 cm). This is a very common unit in 88 typography; font sizes are expressed in this unit. 89 If given a number, then it will be treated as the number of 90 points per unit (eg. 72 = 1 in). Default to `"mm"`. 91 format: Page format. Possible values are `"a3"`, `"a4"`, `"a5"`, 92 `"letter"`, `"legal"` or a tuple `(width, height)` expressed in 93 the given unit. Default to `"a4"`. 94 font_cache_dir: [**DEPRECATED since v2.5.1**] unused. 95 enforce_compliance: When enforce compliance is set, :py:class:`FPDF` 96 actively prevents non-compliant operations and will raise errors 97 if you try something forbidden for the selected profile. 98 Defaults to `None`. 99 """ 100 super().__init__( 101 orientation=orientation, 102 unit=unit, 103 format=format, 104 font_cache_dir=font_cache_dir, 105 enforce_compliance=enforce_compliance, 106 ) 107 self._concordance_list = None 108 if self.CONCORDANCE_FILE is not None: 109 self._concordance_list = ConcordanceList.from_file( 110 self.CONCORDANCE_FILE 111 ) 112 self._index_allow_page_insertion = False 113 self._index_links = {} 114 self._index_parser = TextIndexParser(strict=self.STRICT_INDEX_MODE) 115 self.index_placeholder: IndexPlaceholder | None = None 116 """Index placeholder. Defaults to ``None``.""" 117 118 def _set_index_link_locations(self) -> None: 119 link_locations = {} 120 121 # Collect index locations 122 for page_num, pdf_page in self.pages.items(): 123 if pdf_page.annots is None: 124 continue 125 126 h_page = pdf_page.dimensions()[1] / self.k 127 for a in pdf_page.annots: 128 link_name = str(a.dest) 129 if not ( 130 link_name.startswith(const.INDEX_ID_PREFIX) 131 or link_name.startswith(const.ENTRY_ID_PREFIX) 132 ): 133 continue 134 assert a.rect.startswith("[") and a.rect.endswith("]"), a.rect 135 x, y_h, x_w, y = map( 136 lambda x: float(x) / self.k, 137 a.rect[1:-1].split(" ", maxsplit=3), 138 ) 139 w = x_w - x 140 h = y - y_h 141 y = h_page - y 142 link_locations[link_name] = LinkLocation( 143 page=page_num, x=x, y=y, w=w, h=h 144 ) 145 146 # Add link locations to entries 147 for entry in self._index_parser.entries: 148 for ref in entry.references: 149 ref.start_location = link_locations[ref.start_link] 150 if ref.end_link: 151 ref.end_location = link_locations[ref.end_link] 152 for cross_ref in entry.cross_references: 153 cross_ref.location = link_locations[cross_ref.link] 154 155 def _insert_index(self) -> None: 156 # NOTE: Text Index reuses functionality of ToC 157 158 # Collect links locations and add them to entries 159 self._set_index_link_locations() 160 161 # Doc has been closed but we want to write to self.pages[self.page] 162 # instead of self.buffer: 163 indexp = self.index_placeholder 164 assert indexp is not None 165 prev_page, prev_y = self.page, self.y 166 prev_toc_placeholder = self.toc_placeholder 167 prev_toc_allow_page_insertion = self._toc_allow_page_insertion 168 169 self.page, self.y = indexp.start_page, indexp.y 170 self.toc_placeholder = fpdf.fpdf.ToCPlaceholder( 171 lambda pdf, outlines: None, 172 indexp.start_page, 173 indexp.y, 174 indexp.page_orientation, 175 pages=indexp.pages, 176 reset_page_indices=indexp.reset_page_indices, 177 ) 178 self._toc_allow_page_insertion = self._index_allow_page_insertion 179 # flag rendering ToC for page breaking function 180 self.in_toc_rendering = True 181 # Reset toc inserted counter to 0 182 self._toc_inserted_pages = 0 183 self._set_orientation(indexp.page_orientation, self.dw_pt, self.dh_pt) 184 indexp.render_function(self, self._index_parser.entries) 185 self.in_toc_rendering = False # set ToC rendering flag off 186 expected_final_page = indexp.start_page + indexp.pages - 1 187 if ( 188 self.page != expected_final_page 189 and not self._index_allow_page_insertion 190 ): 191 too = "many" if self.page > expected_final_page else "few" 192 error_msg = ( 193 f"The rendering function passed to " 194 f"'FPDF.insert_index_placeholder' triggered too {too:s} page " 195 f"breaks: ToC ended on page {self.page:d} while it was " 196 f"expected to span exactly {indexp.pages:d} pages" 197 ) 198 raise fpdf.errors.FPDFException(error_msg) 199 if self._toc_inserted_pages: 200 # Generating final page footer after more pages were inserted: 201 self._render_footer() 202 # We need to reorder the pages, because some new pages have been 203 # inserted in the Index, but they have been inserted at the end of 204 # self.pages: 205 new_pages = [ 206 self.pages.pop(len(self.pages)) 207 for _ in range(self._toc_inserted_pages) 208 ] 209 new_pages = list(reversed(new_pages)) 210 indices_remap: dict[int, int] = {} 211 for page_index in range( 212 indexp.start_page + 1, self.pages_count + len(new_pages) + 1 213 ): 214 if page_index in self.pages: 215 new_pages.append(self.pages.pop(page_index)) 216 page = self.pages[page_index] = new_pages.pop(0) 217 # Fix page indices: 218 indices_remap[page.index()] = page_index 219 page.set_index(page_index) 220 # Fix page labels: 221 if indexp.reset_page_indices is False: 222 page.get_page_label().st = page_index # type: ignore[union-attr] 223 assert len(new_pages) == 0, f"#new_pages: {len(new_pages)}" 224 # Fix links: 225 for dest in self.links.values(): 226 assert dest.page_number is not None 227 new_index = indices_remap.get(dest.page_number) 228 if new_index is not None: 229 dest.page_number = new_index 230 # Fix outline: 231 for section in self._outline: 232 new_index = indices_remap.get(section.page_number) 233 if new_index is not None: 234 section.dest = section.dest.replace(page=new_index) 235 section.page_number = new_index 236 if section.struct_elem: 237 # pylint: disable=protected-access 238 section.struct_elem._page_number = ( # pyright: ignore[reportPrivateUsage] 239 new_index 240 ) 241 # Fix resource catalog: 242 resources_per_page = self._resource_catalog.resources_per_page 243 new_resources_per_page: dict[ 244 tuple[int, PDFResourceType], set[ResourceTypes] 245 ] = defaultdict(set) 246 for ( 247 page_number, 248 resource_type, 249 ), resource in resources_per_page.items(): 250 key = ( 251 indices_remap.get(page_number, page_number), 252 resource_type, 253 ) 254 new_resources_per_page[key] = resource 255 self._resource_catalog.resources_per_page = new_resources_per_page 256 257 self._toc_allow_page_insertion = prev_toc_allow_page_insertion 258 self._toc_inserted_pages = 0 259 self.toc_placeholder = prev_toc_placeholder 260 self.page, self.y = prev_page, prev_y 261 262 def _preload_font_styles( 263 self, 264 text: str | None, 265 markdown: bool, 266 ) -> Sequence[Fragment]: 267 """Preloads the font styles by markdown parsing. 268 269 When Markdown styling is enabled, we require secondary fonts to 270 render text in bold & italics. This function ensure that those fonts are 271 available. It needs to perform Markdown parsing, so we return the 272 resulting `styled_txt_frags` tuple to avoid repeating this processing 273 later on. 274 275 Args: 276 text: The text to parse the markdown of. 277 markdown: Whether markdown is enabled. 278 279 Returns: 280 The preloaded text fragments. 281 """ 282 if not self.in_toc_rendering and text and markdown: 283 # Load concordance list if not done in init 284 if ( 285 self.CONCORDANCE_FILE is not None 286 and self._concordance_list is None 287 ): 288 self._concordance_list = ConcordanceList.from_file( 289 self.CONCORDANCE_FILE 290 ) 291 # Replace concordance entries by entry annotations 292 if self._concordance_list: 293 text = self._concordance_list.parse_text(text) 294 # Replace entry annotations by markdown link 295 first_id = self._index_parser.last_directive_id + 1 296 text = self._index_parser.parse_text(text) 297 last_id = self._index_parser.last_directive_id + 1 298 # Reserve the links (named destinations) 299 for text_to_index_id in range(first_id, last_id): 300 link_name = f"{const.INDEX_ID_PREFIX:s}{text_to_index_id:d}" 301 link_idx = self.add_link(name=link_name) 302 self._index_links[link_name] = link_idx 303 return super()._preload_font_styles(text, markdown) 304 305 @property 306 def index_entries(self) -> list[TextIndexEntry]: 307 """The (so far parsed) index entries.""" 308 return self._index_parser.entries 309 310 def add_index_entry( 311 self, 312 label_path: Iterable[str], 313 sort_key: str | None = None, 314 ) -> TextIndexEntry: 315 """Adds manually a text index entry. 316 317 Note: References (locators) to pages cannot be added manually, only 318 cross references. 319 320 Args: 321 label_path: The label path of the entry. 322 sort_key: The sort key of the entry. Defaults to `None`. 323 324 Returns: 325 The text index entry. 326 """ 327 entry = self._index_parser.entry_at_label_path(label_path, create=True) 328 assert isinstance(entry, TextIndexEntry) 329 entry.sort_key = sort_key 330 return entry 331 332 @fpdf.fpdf.check_page 333 def insert_index_placeholder( 334 self, 335 render_index_function: Callable[["FPDF", list[TextIndexEntry]], None], 336 *, 337 pages: int = 1, 338 allow_extra_pages: bool = False, 339 reset_page_indices: bool = True, 340 ) -> None: 341 """Configures Text Index rendering at the end of the document 342 generation, and reserves some vertical space right now in order to 343 insert it. At least one page break is triggered by this method. 344 345 Args: 346 render_index_function: A function that will be invoked to render 347 the Index. This function will receive 2 parameters: 348 `pdf`: an instance of :py:class:`fpdf2_textindex.pdf.FPDF`; 349 `entries`: a list of 350 :py:class:`fpdf2_textindex.interface.TextIndexEntry`s. 351 pages: The number of pages that the Index will span, including the 352 current one. As many page breaks as the value of this argument 353 will occur immediately after calling this method. Defaults to 354 `1`. 355 allow_extra_pages: If set to `True`, allows for an unlimited 356 number of extra pages in the Text Index, which may cause 357 discrepancies with pre-rendered page numbers. 358 For consistent numbering, using page labels to create a separate 359 numbering style for the Index is recommended. Defaults to 360 `False`. 361 reset_page_indices : Whether to reset the pages indices after the 362 Text Index. Defaults to `True`. 363 364 Raises: 365 FPDFException: If an index placeholder has been inserted before. 366 TypeError: If `render_index_function` is not callable. 367 ValueError: If ``pages`` is less than `1`. 368 """ 369 if not callable(render_index_function): 370 msg = ( 371 f"The first argument must be a callable, got: " 372 f"{type(render_index_function)!s:s}" 373 ) 374 raise TypeError(msg) 375 if pages < 1: 376 msg = ( 377 f"'pages' parameter must be equal or greater than 1: {pages:d}" 378 ) 379 raise ValueError(msg) 380 if self.index_placeholder: 381 msg = ( 382 "A placeholder for the index has already been defined on page " 383 f"{self.index_placeholder.start_page}" 384 ) 385 raise fpdf.errors.FPDFException(msg) 386 self.index_placeholder = IndexPlaceholder( 387 render_index_function, 388 self.page, 389 self.y, 390 self.cur_orientation, 391 pages, 392 reset_page_indices, 393 ) 394 self._index_allow_page_insertion = allow_extra_pages 395 for _ in range(pages): 396 self._perform_page_break() 397 398 @fpdf.fpdf.check_page 399 @fpdf.deprecation.support_deprecated_txt_arg 400 def multi_cell( 401 self, 402 w: float, 403 h: float | None = None, 404 text: str = "", 405 border: Literal[0, 1] | str = 0, 406 align: Align | str = Align.J, 407 fill: bool = False, 408 split_only: bool = False, # DEPRECATED 409 link: int | str | None = None, 410 ln: Literal["DEPRECATED"] = "DEPRECATED", 411 max_line_height: float | None = None, 412 markdown: bool = False, 413 print_sh: bool = False, 414 new_x: XPos | str = XPos.RIGHT, 415 new_y: YPos | str = YPos.NEXT, 416 wrapmode: WrapMode = WrapMode.WORD, 417 dry_run: bool = False, 418 output: MethodReturnValue | str = MethodReturnValue.PAGE_BREAK, 419 center: bool = False, 420 padding: Padding | Sequence[int] | int = 0, 421 first_line_indent: float = 0, 422 ) -> fpdf.FPDF.MultiCellResult: 423 r"""This method allows printing text with line breaks. 424 425 They can be automatic (breaking at the most recent space or soft-hyphen 426 character) as soon as the text reaches the right border of the cell, or 427 explicit (via the `"\\n"` character). As many cells as necessary are 428 stacked, one below the other. Text can be aligned, centered or 429 justified. The cell block can be framed and the background painted. A 430 cell has an horizontal padding, on the left & right sides, defined by 431 the 432 [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html)-property. 433 434 Note: 435 Using 436 `new_x=XPos.RIGHT, new_y=XPos.TOP, maximum height=pdf.font_size` 437 is useful to build tables with multiline text in cells. 438 439 Args: 440 w: Cell width. If `0`, they extend up to the right margin of the 441 page. 442 h: Height of a single line of text. Defaults to `None`, meaning to 443 use the current font size. 444 text: Text to print. 445 border: Indicates if borders must be drawn around the cell. 446 The value can be either a number (`0`: no border; `1`: 447 frame) or a string containing some or all of the following 448 characters (in any order): 449 `"L"`: left, 450 `"T"`: top, 451 `"R"`: right, 452 `"B"`: bottom. 453 Defaults to `0`. 454 align: Sets the text alignment inside the cell. 455 Possible values are: 456 `"J"`: justify (default value), 457 `"L"` / `""`: left align, 458 `"C"`: center, 459 `"X"`: center around current x-position, or 460 `"R"`: right align. 461 fill: Indicates if the cell background must be painted (`True`) 462 or transparent (`False`). Defaults to `False`. 463 split_only: **DEPRECATED since 2.7.4**: Use `dry_run=True` and 464 `output=("LINES",)` instead. 465 link: Optional link to add on the cell, internal (identifier 466 returned by [:py:meth:`fpdf.FPDF.add_link`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.add_link) 467 or external URL. 468 new_x: New current position in x after the call. Defaults to 469 [:py:attr:`fpdf.XPos.RIGHT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.XPos). 470 new_y: New current position in y after the call. Defaults to 471 [:py:attr:`fpdf.YPos.NEXT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.YPos). 472 ln: **DEPRECATED since 2.5.1**: Use `new_x` and `new_y` instead. 473 max_line_height: Optional maximum height of each sub-cell generated. 474 Defaults to `None`. 475 markdown: Enables minimal markdown-like markup to render part 476 of text as bold / italics / strikethrough / underlined. 477 Supports `"\\"` as escape character. Defaults to `False`. 478 print_sh: Treat a soft-hyphen (`"\\u00ad"`) as a normal printable 479 character, instead of a line breaking opportunity. Defaults to 480 `False`. 481 wrapmode: [:py:attr:`fpdf.enums.WrapMode.WORD`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode) 482 for word based line wrapping (default) or 483 [:py:attr:`fpdf.enums.WrapMode.CHAR`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode) 484 for character based line wrapping. 485 dry_run: If `True`, does not output anything in the document. 486 Can be useful when combined with `output`. Defaults to 487 `False`. 488 output: Defines what this method returns. If several enum values are 489 joined, the result will be a tuple. 490 txt: [**DEPRECATED since v2.7.6**] string to print. 491 center: Center the cell horizontally on the page. Defaults to 492 `False`. 493 padding: Padding to apply around the text. Defaults to `0`. 494 When one value is specified, it applies the same padding to all 495 four sides. 496 When two values are specified, the first padding applies to the 497 top and bottom, the second to the left and right. 498 When three values are specified, the first padding applies to 499 the top, the second to the right and left, the third to the 500 bottom. 501 When four values are specified, the paddings apply to the top, 502 right, bottom, and left in that order (clockwise) 503 If padding for left or right ends up being non-zero then the 504 respective [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html) 505 is ignored. Center overrides values for horizontal padding. 506 first_line_indent: The indent of the first line. Defaults to `0`. 507 508 Returns: 509 A single value or a tuple, depending on the `output` parameter 510 value. 511 512 Raises: 513 FPDFException: If no font has been set before. 514 ValueError: If `w` or `h` is a string. 515 """ # noqa: DOC102 516 padding = Padding.new(padding) 517 wrapmode = WrapMode.coerce(wrapmode) 518 519 if split_only: 520 warnings.warn( 521 ( 522 'The parameter "split_only" is deprecated since v2.7.4.' 523 ' Use instead dry_run=True and output="LINES".' 524 ), 525 DeprecationWarning, 526 stacklevel=fpdf.deprecation.get_stack_level(), 527 ) 528 if dry_run or split_only: 529 with self._disable_writing(): 530 return self.multi_cell( 531 w=w, 532 h=h, 533 text=text, 534 border=border, 535 align=align, 536 fill=fill, 537 link=link, 538 ln=ln, 539 max_line_height=max_line_height, 540 markdown=markdown, 541 print_sh=print_sh, 542 new_x=new_x, 543 new_y=new_y, 544 wrapmode=wrapmode, 545 dry_run=False, 546 split_only=False, 547 output=MethodReturnValue.LINES if split_only else output, 548 center=center, 549 padding=padding, 550 # CHANGE 551 first_line_indent=first_line_indent, 552 ) 553 if not self.font_family: 554 raise fpdf.errors.FPDFException( 555 "No font set, you need to call set_font() beforehand" 556 ) 557 if isinstance(w, str) or isinstance(h, str): 558 raise ValueError( 559 "Parameter 'w' and 'h' must be numbers, not strings." 560 " You can omit them by passing string content with text=" 561 ) 562 new_x = XPos.coerce(new_x) 563 new_y = YPos.coerce(new_y) 564 if ln != "DEPRECATED": 565 # For backwards compatibility, if "ln" is used we overwrite 566 # "new_[xy]". 567 if ln == 0: 568 new_x = XPos.RIGHT 569 new_y = YPos.NEXT 570 elif ln == 1: 571 new_x = XPos.LMARGIN 572 new_y = YPos.NEXT 573 elif ln == 2: 574 new_x = XPos.LEFT 575 new_y = YPos.NEXT 576 elif ln == 3: 577 new_x = XPos.RIGHT 578 new_y = YPos.TOP 579 else: 580 raise ValueError( 581 f'Invalid value for parameter "ln" ({ln}),' 582 " must be an int between 0 and 3." 583 ) 584 warnings.warn( 585 ( 586 f'The parameter "ln" is deprecated since v2.5.2.' 587 f" Instead of ln={ln} use new_x=XPos.{new_x.name}, " 588 f"new_y=YPos.{new_y.name}." 589 ), 590 DeprecationWarning, 591 stacklevel=fpdf.errors.get_stack_level(), 592 ) 593 align = Align.coerce(align) 594 595 page_break_triggered = False 596 597 if h is None: 598 h = self.font_size 599 600 # If width is 0, set width to available width between margins 601 if w == 0: 602 w = self.w - self.r_margin - self.x 603 604 # Store the starting position before applying padding 605 prev_x, prev_y = self.x, self.y 606 607 # Apply padding to contents 608 # decrease maximum allowed width by padding 609 # shift the starting point by padding 610 maximum_allowed_width = w = w - padding.right - padding.left 611 clearance_margins: list[float] = [] 612 # If we don't have padding on either side, we need a clearance margin. 613 if not padding.left: 614 clearance_margins.append(self.c_margin) 615 if not padding.right: 616 clearance_margins.append(self.c_margin) 617 if align != Align.X: 618 self.x += padding.left 619 self.y += padding.top 620 621 # Center overrides padding 622 if center: 623 self.x = ( 624 self.w / 2 625 if align == Align.X 626 else self.l_margin + (self.epw - w) / 2 627 ) 628 prev_x = self.x 629 630 # Calculate text length 631 text = self.normalize_text(text) 632 normalized_string = text.replace("\r", "") 633 styled_text_fragments = ( 634 self._preload_bidirectional_text(normalized_string, markdown) 635 if self.text_shaping 636 else self._preload_font_styles(normalized_string, markdown) 637 ) 638 639 prev_current_font = self.current_font 640 prev_font_style = self.font_style 641 prev_underline = self.underline 642 total_height: float = 0 643 644 text_lines: list[TextLine] = [] 645 multi_line_break = MultiLineBreak( 646 styled_text_fragments, 647 maximum_allowed_width, 648 clearance_margins, 649 align=align, 650 print_sh=print_sh, 651 wrapmode=wrapmode, 652 # CHANGE 653 first_line_indent=first_line_indent, 654 ) 655 text_line = multi_line_break.get_line() 656 while (text_line) is not None: 657 text_lines.append(text_line) 658 text_line = multi_line_break.get_line() 659 660 if ( 661 not text_lines 662 ): # ensure we display at least one cell - cf. issue #349 663 text_lines = [ 664 TextLine( 665 [], 666 text_width=0, 667 number_of_spaces=0, 668 align=align, 669 height=h, 670 max_width=w, 671 trailing_nl=False, 672 ) 673 ] 674 675 if max_line_height is None or len(text_lines) == 1: 676 line_height = h 677 else: 678 line_height = min(h, max_line_height) 679 680 box_required = fill or border 681 page_break_triggered = False 682 683 for text_line_index, text_line in enumerate(text_lines): 684 start_of_new_page = self._perform_page_break_if_need_be( 685 h + padding.bottom 686 ) 687 if start_of_new_page: 688 page_break_triggered = True 689 self.y += padding.top 690 # CHANGE 691 if text_line_index == 0: 692 self.x += first_line_indent 693 # END CHANGE 694 695 if box_required and (text_line_index == 0 or start_of_new_page): 696 # estimate how many cells can fit on this page 697 top_gap = self.y # Top padding has already been added 698 bottom_gap = padding.bottom + self.b_margin 699 lines_before_break = int( 700 (self.h - top_gap - bottom_gap) // line_height 701 ) 702 # check how many cells should be rendered 703 num_lines = min( 704 lines_before_break, len(text_lines) - text_line_index 705 ) 706 box_height = max( 707 h - text_line_index * line_height, num_lines * line_height 708 ) 709 # render the box 710 x = self.x - (w / 2 if align == Align.X else 0) 711 draw_box_borders( 712 self, 713 x - padding.left, 714 self.y - padding.top, 715 # CHANGE 716 x + w + padding.right + max(0, -first_line_indent), 717 # END CHANGE 718 self.y + box_height + padding.bottom, 719 border, 720 self.fill_color if fill else None, 721 ) 722 is_last_line = text_line_index == len(text_lines) - 1 723 self._render_styled_text_line( 724 text_line, 725 h=line_height, 726 new_x=new_x if is_last_line else XPos.LEFT, 727 new_y=new_y if is_last_line else YPos.NEXT, 728 border=0, # already rendered 729 fill=False, # already rendered 730 link=link, 731 padding=Padding(0, padding.right, 0, padding.left), 732 prevent_font_change=markdown, 733 ) 734 total_height += line_height 735 if not is_last_line and align == Align.X: 736 # prevent cumulative shift to the left 737 self.x = prev_x 738 # CHANGE 739 if text_line_index == 0: 740 self.x -= first_line_indent 741 # END CHANGE 742 743 if total_height < h: 744 # Move to the bottom of the multi_cell 745 if new_y == YPos.NEXT: 746 self.y += h - total_height 747 total_height = h 748 749 if page_break_triggered and new_y == YPos.TOP: 750 # When a page jump is performed and the requested y is TOP, 751 # pretend we started at the top of the text block on the new page. 752 # cf. test_multi_cell_table_with_automatic_page_break 753 prev_y = self.y 754 755 last_line = text_lines[-1] 756 if ( 757 last_line 758 and last_line.trailing_nl 759 and new_y in (YPos.LAST, YPos.NEXT) 760 ): 761 # The line renderer can't handle trailing newlines in the text. 762 self.ln() 763 764 if new_y == YPos.TOP: # We may have jumped a few lines -> reset 765 self.y = prev_y 766 elif new_y == YPos.NEXT: # move down by bottom padding 767 self.y += padding.bottom 768 769 if markdown: 770 self.font_style = prev_font_style 771 self.current_font = prev_current_font 772 self.underline = prev_underline 773 774 if ( 775 new_x == XPos.RIGHT 776 ): # move right by right padding to align outer RHS edge 777 self.x += padding.right 778 elif ( 779 new_x == XPos.LEFT 780 ): # move left by left padding to align outer LHS edge 781 self.x -= padding.left 782 783 output = MethodReturnValue.coerce(output) 784 return_value = () 785 if output & MethodReturnValue.PAGE_BREAK: 786 return_value += (page_break_triggered,) # type: ignore[assignment] 787 if output & MethodReturnValue.LINES: 788 output_lines: list[str] = [] 789 for text_line in text_lines: 790 characters: list[str] = [] 791 for frag in text_line.fragments: 792 characters.extend(frag.characters) 793 output_lines.append("".join(characters)) 794 return_value += (output_lines,) # type: ignore[assignment] 795 if output & MethodReturnValue.HEIGHT: 796 return_value += (total_height + padding.top + padding.bottom,) # type: ignore[assignment] 797 if len(return_value) == 1: 798 return return_value[0] 799 return return_value # type: ignore[return-value] 800 801 @overload 802 def output( # type: ignore[overload-overlap] 803 self, 804 name: Literal[""] | None = "", 805 *, 806 linearize: bool = False, 807 output_producer_class: type[OutputProducer] = OutputProducer, 808 ) -> bytearray: ... 809 810 @overload 811 def output( 812 self, 813 name: os.PathLike[str] | str | BinaryIO, 814 *, 815 linearize: bool = False, 816 output_producer_class: type[OutputProducer] = OutputProducer, 817 ) -> None: ... 818 819 def output( 820 self, 821 name: os.PathLike[str] | BinaryIO | str | Literal[""] | None = "", 822 *, 823 linearize: bool = False, 824 output_producer_class: type[OutputProducer] = OutputProducer, 825 ) -> bytearray | None: 826 """Output PDF to some destination. 827 828 By default the bytearray buffer is returned. 829 If a `name` is given, the PDF is written to a new file. 830 831 Args: 832 name: Optional file object or file path where to save the PDF under. 833 Defaults to `""`. 834 linearize: Whether to use the 835 :py:class:`fpdf.output.LinearizedOutputProducer`. Defaults to 836 `False`. 837 output_producer_class: Use a custom class for PDF file generation. 838 Defaults to :py:class:`fpdf.output.OutputProducer`. 839 840 Returns: 841 If a `name` is given, the PDF will be written to a new file and 842 `None` will be returned. Else, a bytearray buffer is returned, 843 comprising the PDF. 844 845 Raises: 846 PDFAComplianceError: If the compliance requires at least one 847 embedded file. 848 """ 849 # Clear cache of cached functions to free up memory after output 850 get_unicode_script.cache_clear() 851 # Finish document if necessary: 852 if not self.buffer: 853 if self.page == 0: 854 self.add_page() 855 # Generating final page footer: 856 self._render_footer() 857 # Generating .buffer based on .pages: 858 if self.toc_placeholder: 859 self._insert_table_of_contents() 860 # CHANGE 861 if self.index_placeholder: 862 self._insert_index() 863 # CHANGE 864 if self.str_alias_nb_pages: 865 for page in self.pages.values(): 866 for substitution_item in page.get_text_substitutions(): 867 page.contents = page.contents.replace( # type: ignore[union-attr] 868 substitution_item.get_placeholder_string().encode( 869 "latin-1" 870 ), 871 substitution_item.render_text_substitution( 872 str(self.pages_count) 873 ).encode("latin-1"), 874 ) 875 for _, font in self.fonts.items(): 876 if isinstance(font, TTFFont) and font.color_font: 877 font.color_font.load_glyphs() 878 if self._compliance and self._compliance.profile == "PDFA": 879 if len(self._output_intents) == 0: 880 self.add_output_intent( 881 OutputIntentSubType.PDFA, 882 output_condition_identifier="sRGB", 883 output_condition="IEC 61966-2-1:1999", 884 registry_name="http://www.color.org", 885 dest_output_profile=PDFICCProfile( 886 contents=builtin_srgb2014_bytes(), 887 n=3, 888 alternate="DeviceRGB", 889 ), 890 info="sRGB2014 (v2)", 891 ) 892 if ( 893 self._compliance.part == 4 894 and self._compliance.conformance == "F" 895 and len(self.embedded_files) == 0 896 ): 897 msg = ( 898 f"{self._compliance.label} requires at least one " 899 "embedded file" 900 ) 901 raise fpdf.errors.PDFAComplianceError(msg) 902 if linearize: 903 output_producer_class = LinearizedOutputProducer 904 output_producer = output_producer_class(self) 905 self.buffer = output_producer.bufferize() 906 if name: 907 if isinstance(name, (str, os.PathLike)): 908 pathlib.Path(name).write_bytes(self.buffer) 909 else: 910 name.write(self.buffer) 911 return None 912 return self.buffer
PDF Generation Class.
70 def __init__( 71 self, 72 orientation: PageOrientation | str = PageOrientation.PORTRAIT, 73 unit: str | float = "mm", 74 format: str | tuple[float, float] = "A4", 75 font_cache_dir: Literal["DEPRECATED"] = "DEPRECATED", 76 *, 77 enforce_compliance: DocumentCompliance | str | None = None, 78 ) -> None: 79 """Initializes the :py:class:`FPDF`. 80 81 Args: 82 orientation: Page orientation. Possible values are `"portrait"` (can 83 be abbreviated `"P"`) or `"landscape"` (can be abbreviated 84 `"L"`). Defaults to `"portrait"`. 85 unit: Possible values are `"pt"`, `"mm"`, `"cm"`, `"in"`, or a 86 number. A point equals 1/72 of an inch, that is to say about 87 0.35 mm (an inch being 2.54 cm). This is a very common unit in 88 typography; font sizes are expressed in this unit. 89 If given a number, then it will be treated as the number of 90 points per unit (eg. 72 = 1 in). Default to `"mm"`. 91 format: Page format. Possible values are `"a3"`, `"a4"`, `"a5"`, 92 `"letter"`, `"legal"` or a tuple `(width, height)` expressed in 93 the given unit. Default to `"a4"`. 94 font_cache_dir: [**DEPRECATED since v2.5.1**] unused. 95 enforce_compliance: When enforce compliance is set, :py:class:`FPDF` 96 actively prevents non-compliant operations and will raise errors 97 if you try something forbidden for the selected profile. 98 Defaults to `None`. 99 """ 100 super().__init__( 101 orientation=orientation, 102 unit=unit, 103 format=format, 104 font_cache_dir=font_cache_dir, 105 enforce_compliance=enforce_compliance, 106 ) 107 self._concordance_list = None 108 if self.CONCORDANCE_FILE is not None: 109 self._concordance_list = ConcordanceList.from_file( 110 self.CONCORDANCE_FILE 111 ) 112 self._index_allow_page_insertion = False 113 self._index_links = {} 114 self._index_parser = TextIndexParser(strict=self.STRICT_INDEX_MODE) 115 self.index_placeholder: IndexPlaceholder | None = None 116 """Index placeholder. Defaults to ``None``."""
Initializes the FPDF.
Arguments:
- orientation: Page orientation. Possible values are
"portrait"(can be abbreviated"P") or"landscape"(can be abbreviated"L"). Defaults to"portrait". - unit: Possible values are
"pt","mm","cm","in", or a number. A point equals 1/72 of an inch, that is to say about 0.35 mm (an inch being 2.54 cm). This is a very common unit in typography; font sizes are expressed in this unit. If given a number, then it will be treated as the number of points per unit (eg. 72 = 1 in). Default to"mm". - format: Page format. Possible values are
"a3","a4","a5","letter","legal"or a tuple(width, height)expressed in the given unit. Default to"a4". - font_cache_dir: [DEPRECATED since v2.5.1] unused.
- enforce_compliance: When enforce compliance is set,
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 ValueError will be raised. Else, it will just be a warning.
Defaults to True.
305 @property 306 def index_entries(self) -> list[TextIndexEntry]: 307 """The (so far parsed) index entries.""" 308 return self._index_parser.entries
The (so far parsed) index entries.
310 def add_index_entry( 311 self, 312 label_path: Iterable[str], 313 sort_key: str | None = None, 314 ) -> TextIndexEntry: 315 """Adds manually a text index entry. 316 317 Note: References (locators) to pages cannot be added manually, only 318 cross references. 319 320 Args: 321 label_path: The label path of the entry. 322 sort_key: The sort key of the entry. Defaults to `None`. 323 324 Returns: 325 The text index entry. 326 """ 327 entry = self._index_parser.entry_at_label_path(label_path, create=True) 328 assert isinstance(entry, TextIndexEntry) 329 entry.sort_key = sort_key 330 return entry
Adds manually a text index entry.
Note: References (locators) to pages cannot be added manually, only cross references.
Arguments:
- label_path: The label path of the entry.
- sort_key: The sort key of the entry. Defaults to
None.
Returns:
The text index entry.
332 @fpdf.fpdf.check_page 333 def insert_index_placeholder( 334 self, 335 render_index_function: Callable[["FPDF", list[TextIndexEntry]], None], 336 *, 337 pages: int = 1, 338 allow_extra_pages: bool = False, 339 reset_page_indices: bool = True, 340 ) -> None: 341 """Configures Text Index rendering at the end of the document 342 generation, and reserves some vertical space right now in order to 343 insert it. At least one page break is triggered by this method. 344 345 Args: 346 render_index_function: A function that will be invoked to render 347 the Index. This function will receive 2 parameters: 348 `pdf`: an instance of :py:class:`fpdf2_textindex.pdf.FPDF`; 349 `entries`: a list of 350 :py:class:`fpdf2_textindex.interface.TextIndexEntry`s. 351 pages: The number of pages that the Index will span, including the 352 current one. As many page breaks as the value of this argument 353 will occur immediately after calling this method. Defaults to 354 `1`. 355 allow_extra_pages: If set to `True`, allows for an unlimited 356 number of extra pages in the Text Index, which may cause 357 discrepancies with pre-rendered page numbers. 358 For consistent numbering, using page labels to create a separate 359 numbering style for the Index is recommended. Defaults to 360 `False`. 361 reset_page_indices : Whether to reset the pages indices after the 362 Text Index. Defaults to `True`. 363 364 Raises: 365 FPDFException: If an index placeholder has been inserted before. 366 TypeError: If `render_index_function` is not callable. 367 ValueError: If ``pages`` is less than `1`. 368 """ 369 if not callable(render_index_function): 370 msg = ( 371 f"The first argument must be a callable, got: " 372 f"{type(render_index_function)!s:s}" 373 ) 374 raise TypeError(msg) 375 if pages < 1: 376 msg = ( 377 f"'pages' parameter must be equal or greater than 1: {pages:d}" 378 ) 379 raise ValueError(msg) 380 if self.index_placeholder: 381 msg = ( 382 "A placeholder for the index has already been defined on page " 383 f"{self.index_placeholder.start_page}" 384 ) 385 raise fpdf.errors.FPDFException(msg) 386 self.index_placeholder = IndexPlaceholder( 387 render_index_function, 388 self.page, 389 self.y, 390 self.cur_orientation, 391 pages, 392 reset_page_indices, 393 ) 394 self._index_allow_page_insertion = allow_extra_pages 395 for _ in range(pages): 396 self._perform_page_break()
Configures Text Index rendering at the end of the document generation, and reserves some vertical space right now in order to insert it. At least one page break is triggered by this method.
Arguments:
- render_index_function: A function that will be invoked to render
the Index. This function will receive 2 parameters:
pdf: an instance 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:
- FPDFException: If an index placeholder has been inserted before.
- TypeError: If
render_index_functionis not callable. - ValueError: If
pagesis less than1.
398 @fpdf.fpdf.check_page 399 @fpdf.deprecation.support_deprecated_txt_arg 400 def multi_cell( 401 self, 402 w: float, 403 h: float | None = None, 404 text: str = "", 405 border: Literal[0, 1] | str = 0, 406 align: Align | str = Align.J, 407 fill: bool = False, 408 split_only: bool = False, # DEPRECATED 409 link: int | str | None = None, 410 ln: Literal["DEPRECATED"] = "DEPRECATED", 411 max_line_height: float | None = None, 412 markdown: bool = False, 413 print_sh: bool = False, 414 new_x: XPos | str = XPos.RIGHT, 415 new_y: YPos | str = YPos.NEXT, 416 wrapmode: WrapMode = WrapMode.WORD, 417 dry_run: bool = False, 418 output: MethodReturnValue | str = MethodReturnValue.PAGE_BREAK, 419 center: bool = False, 420 padding: Padding | Sequence[int] | int = 0, 421 first_line_indent: float = 0, 422 ) -> fpdf.FPDF.MultiCellResult: 423 r"""This method allows printing text with line breaks. 424 425 They can be automatic (breaking at the most recent space or soft-hyphen 426 character) as soon as the text reaches the right border of the cell, or 427 explicit (via the `"\\n"` character). As many cells as necessary are 428 stacked, one below the other. Text can be aligned, centered or 429 justified. The cell block can be framed and the background painted. A 430 cell has an horizontal padding, on the left & right sides, defined by 431 the 432 [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html)-property. 433 434 Note: 435 Using 436 `new_x=XPos.RIGHT, new_y=XPos.TOP, maximum height=pdf.font_size` 437 is useful to build tables with multiline text in cells. 438 439 Args: 440 w: Cell width. If `0`, they extend up to the right margin of the 441 page. 442 h: Height of a single line of text. Defaults to `None`, meaning to 443 use the current font size. 444 text: Text to print. 445 border: Indicates if borders must be drawn around the cell. 446 The value can be either a number (`0`: no border; `1`: 447 frame) or a string containing some or all of the following 448 characters (in any order): 449 `"L"`: left, 450 `"T"`: top, 451 `"R"`: right, 452 `"B"`: bottom. 453 Defaults to `0`. 454 align: Sets the text alignment inside the cell. 455 Possible values are: 456 `"J"`: justify (default value), 457 `"L"` / `""`: left align, 458 `"C"`: center, 459 `"X"`: center around current x-position, or 460 `"R"`: right align. 461 fill: Indicates if the cell background must be painted (`True`) 462 or transparent (`False`). Defaults to `False`. 463 split_only: **DEPRECATED since 2.7.4**: Use `dry_run=True` and 464 `output=("LINES",)` instead. 465 link: Optional link to add on the cell, internal (identifier 466 returned by [:py:meth:`fpdf.FPDF.add_link`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.add_link) 467 or external URL. 468 new_x: New current position in x after the call. Defaults to 469 [:py:attr:`fpdf.XPos.RIGHT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.XPos). 470 new_y: New current position in y after the call. Defaults to 471 [:py:attr:`fpdf.YPos.NEXT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.YPos). 472 ln: **DEPRECATED since 2.5.1**: Use `new_x` and `new_y` instead. 473 max_line_height: Optional maximum height of each sub-cell generated. 474 Defaults to `None`. 475 markdown: Enables minimal markdown-like markup to render part 476 of text as bold / italics / strikethrough / underlined. 477 Supports `"\\"` as escape character. Defaults to `False`. 478 print_sh: Treat a soft-hyphen (`"\\u00ad"`) as a normal printable 479 character, instead of a line breaking opportunity. Defaults to 480 `False`. 481 wrapmode: [:py:attr:`fpdf.enums.WrapMode.WORD`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode) 482 for word based line wrapping (default) or 483 [:py:attr:`fpdf.enums.WrapMode.CHAR`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode) 484 for character based line wrapping. 485 dry_run: If `True`, does not output anything in the document. 486 Can be useful when combined with `output`. Defaults to 487 `False`. 488 output: Defines what this method returns. If several enum values are 489 joined, the result will be a tuple. 490 txt: [**DEPRECATED since v2.7.6**] string to print. 491 center: Center the cell horizontally on the page. Defaults to 492 `False`. 493 padding: Padding to apply around the text. Defaults to `0`. 494 When one value is specified, it applies the same padding to all 495 four sides. 496 When two values are specified, the first padding applies to the 497 top and bottom, the second to the left and right. 498 When three values are specified, the first padding applies to 499 the top, the second to the right and left, the third to the 500 bottom. 501 When four values are specified, the paddings apply to the top, 502 right, bottom, and left in that order (clockwise) 503 If padding for left or right ends up being non-zero then the 504 respective [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html) 505 is ignored. Center overrides values for horizontal padding. 506 first_line_indent: The indent of the first line. Defaults to `0`. 507 508 Returns: 509 A single value or a tuple, depending on the `output` parameter 510 value. 511 512 Raises: 513 FPDFException: If no font has been set before. 514 ValueError: If `w` or `h` is a string. 515 """ # noqa: DOC102 516 padding = Padding.new(padding) 517 wrapmode = WrapMode.coerce(wrapmode) 518 519 if split_only: 520 warnings.warn( 521 ( 522 'The parameter "split_only" is deprecated since v2.7.4.' 523 ' Use instead dry_run=True and output="LINES".' 524 ), 525 DeprecationWarning, 526 stacklevel=fpdf.deprecation.get_stack_level(), 527 ) 528 if dry_run or split_only: 529 with self._disable_writing(): 530 return self.multi_cell( 531 w=w, 532 h=h, 533 text=text, 534 border=border, 535 align=align, 536 fill=fill, 537 link=link, 538 ln=ln, 539 max_line_height=max_line_height, 540 markdown=markdown, 541 print_sh=print_sh, 542 new_x=new_x, 543 new_y=new_y, 544 wrapmode=wrapmode, 545 dry_run=False, 546 split_only=False, 547 output=MethodReturnValue.LINES if split_only else output, 548 center=center, 549 padding=padding, 550 # CHANGE 551 first_line_indent=first_line_indent, 552 ) 553 if not self.font_family: 554 raise fpdf.errors.FPDFException( 555 "No font set, you need to call set_font() beforehand" 556 ) 557 if isinstance(w, str) or isinstance(h, str): 558 raise ValueError( 559 "Parameter 'w' and 'h' must be numbers, not strings." 560 " You can omit them by passing string content with text=" 561 ) 562 new_x = XPos.coerce(new_x) 563 new_y = YPos.coerce(new_y) 564 if ln != "DEPRECATED": 565 # For backwards compatibility, if "ln" is used we overwrite 566 # "new_[xy]". 567 if ln == 0: 568 new_x = XPos.RIGHT 569 new_y = YPos.NEXT 570 elif ln == 1: 571 new_x = XPos.LMARGIN 572 new_y = YPos.NEXT 573 elif ln == 2: 574 new_x = XPos.LEFT 575 new_y = YPos.NEXT 576 elif ln == 3: 577 new_x = XPos.RIGHT 578 new_y = YPos.TOP 579 else: 580 raise ValueError( 581 f'Invalid value for parameter "ln" ({ln}),' 582 " must be an int between 0 and 3." 583 ) 584 warnings.warn( 585 ( 586 f'The parameter "ln" is deprecated since v2.5.2.' 587 f" Instead of ln={ln} use new_x=XPos.{new_x.name}, " 588 f"new_y=YPos.{new_y.name}." 589 ), 590 DeprecationWarning, 591 stacklevel=fpdf.errors.get_stack_level(), 592 ) 593 align = Align.coerce(align) 594 595 page_break_triggered = False 596 597 if h is None: 598 h = self.font_size 599 600 # If width is 0, set width to available width between margins 601 if w == 0: 602 w = self.w - self.r_margin - self.x 603 604 # Store the starting position before applying padding 605 prev_x, prev_y = self.x, self.y 606 607 # Apply padding to contents 608 # decrease maximum allowed width by padding 609 # shift the starting point by padding 610 maximum_allowed_width = w = w - padding.right - padding.left 611 clearance_margins: list[float] = [] 612 # If we don't have padding on either side, we need a clearance margin. 613 if not padding.left: 614 clearance_margins.append(self.c_margin) 615 if not padding.right: 616 clearance_margins.append(self.c_margin) 617 if align != Align.X: 618 self.x += padding.left 619 self.y += padding.top 620 621 # Center overrides padding 622 if center: 623 self.x = ( 624 self.w / 2 625 if align == Align.X 626 else self.l_margin + (self.epw - w) / 2 627 ) 628 prev_x = self.x 629 630 # Calculate text length 631 text = self.normalize_text(text) 632 normalized_string = text.replace("\r", "") 633 styled_text_fragments = ( 634 self._preload_bidirectional_text(normalized_string, markdown) 635 if self.text_shaping 636 else self._preload_font_styles(normalized_string, markdown) 637 ) 638 639 prev_current_font = self.current_font 640 prev_font_style = self.font_style 641 prev_underline = self.underline 642 total_height: float = 0 643 644 text_lines: list[TextLine] = [] 645 multi_line_break = MultiLineBreak( 646 styled_text_fragments, 647 maximum_allowed_width, 648 clearance_margins, 649 align=align, 650 print_sh=print_sh, 651 wrapmode=wrapmode, 652 # CHANGE 653 first_line_indent=first_line_indent, 654 ) 655 text_line = multi_line_break.get_line() 656 while (text_line) is not None: 657 text_lines.append(text_line) 658 text_line = multi_line_break.get_line() 659 660 if ( 661 not text_lines 662 ): # ensure we display at least one cell - cf. issue #349 663 text_lines = [ 664 TextLine( 665 [], 666 text_width=0, 667 number_of_spaces=0, 668 align=align, 669 height=h, 670 max_width=w, 671 trailing_nl=False, 672 ) 673 ] 674 675 if max_line_height is None or len(text_lines) == 1: 676 line_height = h 677 else: 678 line_height = min(h, max_line_height) 679 680 box_required = fill or border 681 page_break_triggered = False 682 683 for text_line_index, text_line in enumerate(text_lines): 684 start_of_new_page = self._perform_page_break_if_need_be( 685 h + padding.bottom 686 ) 687 if start_of_new_page: 688 page_break_triggered = True 689 self.y += padding.top 690 # CHANGE 691 if text_line_index == 0: 692 self.x += first_line_indent 693 # END CHANGE 694 695 if box_required and (text_line_index == 0 or start_of_new_page): 696 # estimate how many cells can fit on this page 697 top_gap = self.y # Top padding has already been added 698 bottom_gap = padding.bottom + self.b_margin 699 lines_before_break = int( 700 (self.h - top_gap - bottom_gap) // line_height 701 ) 702 # check how many cells should be rendered 703 num_lines = min( 704 lines_before_break, len(text_lines) - text_line_index 705 ) 706 box_height = max( 707 h - text_line_index * line_height, num_lines * line_height 708 ) 709 # render the box 710 x = self.x - (w / 2 if align == Align.X else 0) 711 draw_box_borders( 712 self, 713 x - padding.left, 714 self.y - padding.top, 715 # CHANGE 716 x + w + padding.right + max(0, -first_line_indent), 717 # END CHANGE 718 self.y + box_height + padding.bottom, 719 border, 720 self.fill_color if fill else None, 721 ) 722 is_last_line = text_line_index == len(text_lines) - 1 723 self._render_styled_text_line( 724 text_line, 725 h=line_height, 726 new_x=new_x if is_last_line else XPos.LEFT, 727 new_y=new_y if is_last_line else YPos.NEXT, 728 border=0, # already rendered 729 fill=False, # already rendered 730 link=link, 731 padding=Padding(0, padding.right, 0, padding.left), 732 prevent_font_change=markdown, 733 ) 734 total_height += line_height 735 if not is_last_line and align == Align.X: 736 # prevent cumulative shift to the left 737 self.x = prev_x 738 # CHANGE 739 if text_line_index == 0: 740 self.x -= first_line_indent 741 # END CHANGE 742 743 if total_height < h: 744 # Move to the bottom of the multi_cell 745 if new_y == YPos.NEXT: 746 self.y += h - total_height 747 total_height = h 748 749 if page_break_triggered and new_y == YPos.TOP: 750 # When a page jump is performed and the requested y is TOP, 751 # pretend we started at the top of the text block on the new page. 752 # cf. test_multi_cell_table_with_automatic_page_break 753 prev_y = self.y 754 755 last_line = text_lines[-1] 756 if ( 757 last_line 758 and last_line.trailing_nl 759 and new_y in (YPos.LAST, YPos.NEXT) 760 ): 761 # The line renderer can't handle trailing newlines in the text. 762 self.ln() 763 764 if new_y == YPos.TOP: # We may have jumped a few lines -> reset 765 self.y = prev_y 766 elif new_y == YPos.NEXT: # move down by bottom padding 767 self.y += padding.bottom 768 769 if markdown: 770 self.font_style = prev_font_style 771 self.current_font = prev_current_font 772 self.underline = prev_underline 773 774 if ( 775 new_x == XPos.RIGHT 776 ): # move right by right padding to align outer RHS edge 777 self.x += padding.right 778 elif ( 779 new_x == XPos.LEFT 780 ): # move left by left padding to align outer LHS edge 781 self.x -= padding.left 782 783 output = MethodReturnValue.coerce(output) 784 return_value = () 785 if output & MethodReturnValue.PAGE_BREAK: 786 return_value += (page_break_triggered,) # type: ignore[assignment] 787 if output & MethodReturnValue.LINES: 788 output_lines: list[str] = [] 789 for text_line in text_lines: 790 characters: list[str] = [] 791 for frag in text_line.fragments: 792 characters.extend(frag.characters) 793 output_lines.append("".join(characters)) 794 return_value += (output_lines,) # type: ignore[assignment] 795 if output & MethodReturnValue.HEIGHT: 796 return_value += (total_height + padding.top + padding.bottom,) # type: ignore[assignment] 797 if len(return_value) == 1: 798 return return_value[0] 799 return return_value # type: ignore[return-value]
This method allows printing text with line breaks.
They can be automatic (breaking at the most recent space or soft-hyphen
character) as soon as the text reaches the right border of the cell, or
explicit (via the "\\n" character). As many cells as necessary are
stacked, one below the other. Text can be aligned, centered or
justified. The cell block can be framed and the background painted. A
cell has an horizontal padding, on the left & right sides, defined by
the
fpdf.FPDF.c_margin-property.
Note:
Using
new_x=XPos.RIGHT, new_y=XPos.TOP, maximum height=pdf.font_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.
819 def output( 820 self, 821 name: os.PathLike[str] | BinaryIO | str | Literal[""] | None = "", 822 *, 823 linearize: bool = False, 824 output_producer_class: type[OutputProducer] = OutputProducer, 825 ) -> bytearray | None: 826 """Output PDF to some destination. 827 828 By default the bytearray buffer is returned. 829 If a `name` is given, the PDF is written to a new file. 830 831 Args: 832 name: Optional file object or file path where to save the PDF under. 833 Defaults to `""`. 834 linearize: Whether to use the 835 :py:class:`fpdf.output.LinearizedOutputProducer`. Defaults to 836 `False`. 837 output_producer_class: Use a custom class for PDF file generation. 838 Defaults to :py:class:`fpdf.output.OutputProducer`. 839 840 Returns: 841 If a `name` is given, the PDF will be written to a new file and 842 `None` will be returned. Else, a bytearray buffer is returned, 843 comprising the PDF. 844 845 Raises: 846 PDFAComplianceError: If the compliance requires at least one 847 embedded file. 848 """ 849 # Clear cache of cached functions to free up memory after output 850 get_unicode_script.cache_clear() 851 # Finish document if necessary: 852 if not self.buffer: 853 if self.page == 0: 854 self.add_page() 855 # Generating final page footer: 856 self._render_footer() 857 # Generating .buffer based on .pages: 858 if self.toc_placeholder: 859 self._insert_table_of_contents() 860 # CHANGE 861 if self.index_placeholder: 862 self._insert_index() 863 # CHANGE 864 if self.str_alias_nb_pages: 865 for page in self.pages.values(): 866 for substitution_item in page.get_text_substitutions(): 867 page.contents = page.contents.replace( # type: ignore[union-attr] 868 substitution_item.get_placeholder_string().encode( 869 "latin-1" 870 ), 871 substitution_item.render_text_substitution( 872 str(self.pages_count) 873 ).encode("latin-1"), 874 ) 875 for _, font in self.fonts.items(): 876 if isinstance(font, TTFFont) and font.color_font: 877 font.color_font.load_glyphs() 878 if self._compliance and self._compliance.profile == "PDFA": 879 if len(self._output_intents) == 0: 880 self.add_output_intent( 881 OutputIntentSubType.PDFA, 882 output_condition_identifier="sRGB", 883 output_condition="IEC 61966-2-1:1999", 884 registry_name="http://www.color.org", 885 dest_output_profile=PDFICCProfile( 886 contents=builtin_srgb2014_bytes(), 887 n=3, 888 alternate="DeviceRGB", 889 ), 890 info="sRGB2014 (v2)", 891 ) 892 if ( 893 self._compliance.part == 4 894 and self._compliance.conformance == "F" 895 and len(self.embedded_files) == 0 896 ): 897 msg = ( 898 f"{self._compliance.label} requires at least one " 899 "embedded file" 900 ) 901 raise fpdf.errors.PDFAComplianceError(msg) 902 if linearize: 903 output_producer_class = LinearizedOutputProducer 904 output_producer = output_producer_class(self) 905 self.buffer = output_producer.bufferize() 906 if name: 907 if isinstance(name, (str, os.PathLike)): 908 pathlib.Path(name).write_bytes(self.buffer) 909 else: 910 name.write(self.buffer) 911 return None 912 return self.buffer
Output PDF to some destination.
By default the bytearray buffer is returned.
If a name is given, the PDF is written to a new file.
Arguments:
- name: Optional file object or file path where to save the PDF under.
Defaults to
"". - linearize: Whether to use the
fpdf.output.LinearizedOutputProducer. Defaults 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.
52@dataclasses.dataclass(kw_only=True, slots=True) 53class LinkLocation: 54 """Link Location.""" 55 56 page: int 57 """The page the link is referened/used on.""" 58 59 x: float 60 """The `x`-position on the page.""" 61 62 y: float 63 """The `y`-position on the page.""" 64 65 w: float 66 """The width the link has on the page.""" 67 68 h: float 69 """The height the link has on the page."""
Link Location.
258@dataclasses.dataclass(kw_only=True, slots=True) 259class Reference: 260 """Reference.""" 261 262 start_id: int 263 """The start id of the reference.""" 264 265 start_suffix: str | None = None 266 """The start suffix of the reference or `None`.""" 267 268 start_location: LinkLocation | None = dataclasses.field( 269 default=None, init=False 270 ) 271 """The start (link) location in the document the reference is set at.""" 272 273 end_id: int | None = dataclasses.field(default=None, init=False) 274 """The end id of the reference or `None`.""" 275 276 end_suffix: str | None = dataclasses.field(default=None, init=False) 277 """The end suffix of the reference or `None`.""" 278 279 end_location: LinkLocation | None = dataclasses.field( 280 default=None, init=False 281 ) 282 """The end (link) location in the document the reference is set at.""" 283 284 locator_emphasis: bool = False 285 """Whether to emphasize the locator (page number) of the reference in the 286 text index (`True`) or not (`False`).""" 287 288 @property 289 def start_link(self) -> str: 290 """The start link in the document that must be set in the text index to 291 lead from the text to the text index. 292 """ 293 return f"{const.INDEX_ID_PREFIX:s}{self.start_id:d}" 294 295 @property 296 def end_link(self) -> str | None: 297 """The end link in the document that must be set in the text index to 298 lead from the text to the text index. In case of no end id, the end link 299 will be `None`. 300 """ 301 if self.end_id is None: 302 return None 303 return f"{const.INDEX_ID_PREFIX:s}{self.end_id:d}"
Reference.
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).
288 @property 289 def start_link(self) -> str: 290 """The start link in the document that must be set in the text index to 291 lead from the text to the text index. 292 """ 293 return f"{const.INDEX_ID_PREFIX:s}{self.start_id:d}"
The start link in the document that must be set in the text index to lead from the text to the text index.
295 @property 296 def end_link(self) -> str | None: 297 """The end link in the document that must be set in the text index to 298 lead from the text to the text index. In case of no end id, the end link 299 will be `None`. 300 """ 301 if self.end_id is None: 302 return None 303 return f"{const.INDEX_ID_PREFIX:s}{self.end_id:d}"
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.
306@dataclasses.dataclass(kw_only=True, repr=False, slots=True) 307class TextIndexEntry(Node): 308 """Text Index Entry.""" 309 310 references: list[Reference] = dataclasses.field( 311 default_factory=list, init=False 312 ) 313 """The references.""" 314 315 cross_references: list[CrossReference] = dataclasses.field( 316 default_factory=list, init=False 317 ) 318 """The cross references.""" 319 320 sort_key: str | None = dataclasses.field(default=None, init=False) 321 """The sort key.""" 322 323 def __hash__(self) -> int: 324 return hash((self.id, self.label)) 325 326 @property 327 def children(self) -> list[TextIndexEntry]: 328 """The child entries.""" 329 return sorted(self._children, key=lambda c: c.sort_label) 330 331 @property 332 def sort_label(self) -> str: 333 """The sort label of the entry.""" 334 label = self.label 335 label = MDEmphasis.remove(self.label) if self.label else "\uffff" 336 if self.sort_key: 337 label = self.sort_key + label 338 return label.lower() 339 340 def add_cross_reference( 341 self, 342 id: int, 343 cross_ref_type: CrossReferenceType, 344 label_path: Iterable[str], 345 *, 346 strict: bool = True, 347 ) -> None: 348 """Adds a cross reference to the entry. 349 350 Args: 351 id: The id of the cross reference. 352 cross_ref_type: The type of the cross reference. 353 label_path: The label path of the cross reference. 354 strict: Whether to raise a `ValueError` if adding a SEE-cross 355 reference to an entry with former "normal" reference (locator). 356 Else, it will just be a warning and the SEE-cross reference will 357 be automatically converted to SEE ALSO. Defaults to `True`. 358 359 Raises: 360 ValueError: If `strict=True` and adding a SEE-cross reference to 361 an entry with former "normal" reference (locator). 362 """ 363 if self.references and cross_ref_type == CrossReferenceType.SEE: 364 if strict: 365 msg = ( 366 f"cannot add a SEE-cross reference to entry " 367 f"{self.joined_label_path!r} with former reference " 368 f"(locator)" 369 ) 370 raise ValueError(msg) 371 LOGGER.warning( 372 "Adding a SEE-cross reference to entry %r with former " 373 "reference (locator); cross reference will be converted to SEE " 374 "ALSO", 375 self.joined_label_path, 376 ) 377 cross_ref_type = CrossReferenceType.ALSO 378 label_path = tuple(label_path) 379 if len(self.cross_references) > 0: 380 for cr in self.cross_references: 381 if cr.type == cross_ref_type and cr.label_path == label_path: 382 return 383 self.cross_references.append( 384 CrossReference(id=id, type=cross_ref_type, label_path=label_path) 385 ) 386 387 def add_reference( 388 self, 389 start_id: int, 390 *, 391 locator_emphasis: bool = False, 392 start_suffix: str | None = None, 393 strict: bool = True, 394 ) -> None: 395 """Adds a reference (locator) to the entry. 396 397 Args: 398 start_id: The start id of the reference. 399 locator_emphasis: Whether to emphasize the locator of the reference. 400 Defaults to `False`. 401 start_suffix: The start suffix of the reference. Defaults to 402 `None`. 403 strict: Whether to raise a `ValueError` if adding a SEE-cross 404 reference to an entry with former "normal" reference (locator). 405 Else, it will just be a warning and the SEE-cross reference will 406 be automatically converted to SEE ALSO. Defaults to `True`. 407 408 Raises: 409 ValueError: If `strict=True` and adding a reference locator to an 410 entry with former SEE-cross reference. 411 """ 412 if any( 413 cr.type == CrossReferenceType.SEE for cr in self.cross_references 414 ): 415 if strict: 416 msg = ( 417 f"cannot add a reference (locator) to entry " 418 f"{self.joined_label_path!r} with former SEE-cross " 419 f"reference" 420 ) 421 raise ValueError(msg) 422 LOGGER.warning( 423 "Adding a reference (locator) to entry %r with former SEE-" 424 "cross reference(s); cross reference(s) will be converted to " 425 "SEE ALSO", 426 self.joined_label_path, 427 ) 428 for cr in self.cross_references: 429 if cr.type == CrossReferenceType.SEE: 430 cr.type = CrossReferenceType.ALSO 431 432 self.references.append( 433 Reference( 434 start_id=start_id, 435 start_suffix=start_suffix, 436 locator_emphasis=locator_emphasis, 437 ) 438 ) 439 440 def update_latest_reference_end( 441 self, 442 end_id: int, 443 end_suffix: str | None = None, 444 ) -> None: 445 """Updates the end of the latest reference. 446 447 Args: 448 end_id: The end id of the latest reference. 449 end_suffix: The end suffix of the latest reference. Defaults to 450 `None`. 451 452 Raises: 453 RuntimeError: If there has been no reference before. 454 """ 455 if len(self.references) == 0: 456 msg = "cannot update latest reference end without reference" 457 raise RuntimeError(msg) 458 self.references[-1].end_id = end_id 459 if end_suffix: 460 self.references[-1].end_suffix = end_suffix
Text Index Entry.
326 @property 327 def children(self) -> list[TextIndexEntry]: 328 """The child entries.""" 329 return sorted(self._children, key=lambda c: c.sort_label)
The child entries.
331 @property 332 def sort_label(self) -> str: 333 """The sort label of the entry.""" 334 label = self.label 335 label = MDEmphasis.remove(self.label) if self.label else "\uffff" 336 if self.sort_key: 337 label = self.sort_key + label 338 return label.lower()
The sort label of the entry.
340 def add_cross_reference( 341 self, 342 id: int, 343 cross_ref_type: CrossReferenceType, 344 label_path: Iterable[str], 345 *, 346 strict: bool = True, 347 ) -> None: 348 """Adds a cross reference to the entry. 349 350 Args: 351 id: The id of the cross reference. 352 cross_ref_type: The type of the cross reference. 353 label_path: The label path of the cross reference. 354 strict: Whether to raise a `ValueError` if adding a SEE-cross 355 reference to an entry with former "normal" reference (locator). 356 Else, it will just be a warning and the SEE-cross reference will 357 be automatically converted to SEE ALSO. Defaults to `True`. 358 359 Raises: 360 ValueError: If `strict=True` and adding a SEE-cross reference to 361 an entry with former "normal" reference (locator). 362 """ 363 if self.references and cross_ref_type == CrossReferenceType.SEE: 364 if strict: 365 msg = ( 366 f"cannot add a SEE-cross reference to entry " 367 f"{self.joined_label_path!r} with former reference " 368 f"(locator)" 369 ) 370 raise ValueError(msg) 371 LOGGER.warning( 372 "Adding a SEE-cross reference to entry %r with former " 373 "reference (locator); cross reference will be converted to SEE " 374 "ALSO", 375 self.joined_label_path, 376 ) 377 cross_ref_type = CrossReferenceType.ALSO 378 label_path = tuple(label_path) 379 if len(self.cross_references) > 0: 380 for cr in self.cross_references: 381 if cr.type == cross_ref_type and cr.label_path == label_path: 382 return 383 self.cross_references.append( 384 CrossReference(id=id, type=cross_ref_type, label_path=label_path) 385 )
Adds a cross reference to the entry.
Arguments:
- id: The id of the cross reference.
- cross_ref_type: The type of the cross reference.
- label_path: The label path of the cross reference.
- strict: Whether to raise a
ValueErrorif 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:
- ValueError: If
strict=Trueand adding a SEE-cross reference to an entry with former "normal" reference (locator).
387 def add_reference( 388 self, 389 start_id: int, 390 *, 391 locator_emphasis: bool = False, 392 start_suffix: str | None = None, 393 strict: bool = True, 394 ) -> None: 395 """Adds a reference (locator) to the entry. 396 397 Args: 398 start_id: The start id of the reference. 399 locator_emphasis: Whether to emphasize the locator of the reference. 400 Defaults to `False`. 401 start_suffix: The start suffix of the reference. Defaults to 402 `None`. 403 strict: Whether to raise a `ValueError` if adding a SEE-cross 404 reference to an entry with former "normal" reference (locator). 405 Else, it will just be a warning and the SEE-cross reference will 406 be automatically converted to SEE ALSO. Defaults to `True`. 407 408 Raises: 409 ValueError: If `strict=True` and adding a reference locator to an 410 entry with former SEE-cross reference. 411 """ 412 if any( 413 cr.type == CrossReferenceType.SEE for cr in self.cross_references 414 ): 415 if strict: 416 msg = ( 417 f"cannot add a reference (locator) to entry " 418 f"{self.joined_label_path!r} with former SEE-cross " 419 f"reference" 420 ) 421 raise ValueError(msg) 422 LOGGER.warning( 423 "Adding a reference (locator) to entry %r with former SEE-" 424 "cross reference(s); cross reference(s) will be converted to " 425 "SEE ALSO", 426 self.joined_label_path, 427 ) 428 for cr in self.cross_references: 429 if cr.type == CrossReferenceType.SEE: 430 cr.type = CrossReferenceType.ALSO 431 432 self.references.append( 433 Reference( 434 start_id=start_id, 435 start_suffix=start_suffix, 436 locator_emphasis=locator_emphasis, 437 ) 438 )
Adds a reference (locator) to the entry.
Arguments:
- start_id: The start id of the reference.
- locator_emphasis: Whether to emphasize the locator of the reference.
Defaults to
False. - start_suffix: The start suffix of the reference. Defaults to
None. - strict: Whether to raise a
ValueErrorif 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:
- ValueError: If
strict=Trueand adding a reference locator to an entry with former SEE-cross reference.
440 def update_latest_reference_end( 441 self, 442 end_id: int, 443 end_suffix: str | None = None, 444 ) -> None: 445 """Updates the end of the latest reference. 446 447 Args: 448 end_id: The end id of the latest reference. 449 end_suffix: The end suffix of the latest reference. Defaults to 450 `None`. 451 452 Raises: 453 RuntimeError: If there has been no reference before. 454 """ 455 if len(self.references) == 0: 456 msg = "cannot update latest reference end without reference" 457 raise RuntimeError(msg) 458 self.references[-1].end_id = end_id 459 if end_suffix: 460 self.references[-1].end_suffix = end_suffix
Updates the end of the latest reference.
Arguments:
- end_id: The end id of the latest reference.
- end_suffix: The end suffix of the latest reference. Defaults to
None.
Raises:
- RuntimeError: If there has been no reference before.
57class TextIndexRenderer: 58 """Text Index (Writer). 59 60 A reference implementation of a Text Index to use with [fpdf2](https://py-pdf.github.io/fpdf2/index.html). 61 62 This class provides a customizable Text Index that can be used directly or 63 subclassed for additional functionality. 64 To use this class, create an instance of :py:class:`TextIndexRenderer`, 65 configure it as needed, and pass its 66 :py:meth:`TextIndexRenderer.render_text_index`-method as 67 `render_index_function`-argument to 68 :py:meth:`fpdf2_textindex.pdf.FPDF.insert_index_placeholder`. 69 """ 70 71 if TYPE_CHECKING: 72 _cur_header: str | None 73 _link_locations: dict[str, LinkLocation] 74 border: bool 75 ignore_same_page_refs: bool 76 level_indent: float 77 line_spacing: float 78 max_outline_level: int 79 outline_level: int 80 run_in_style: bool 81 show_header: bool 82 sort_emph_first: bool 83 text_styles: list[fpdf.TextStyle] 84 85 def __init__( 86 self, 87 *, 88 border: bool = False, 89 ignore_same_page_refs: bool = True, 90 level_indent: float | None = 7.5, 91 line_spacing: float | None = None, 92 max_outline_level: int | None = None, 93 outline_level: int | None = None, 94 run_in_style: bool = True, 95 show_header: bool = False, 96 sort_emph_first: bool = False, 97 text_styles: Iterable[fpdf.TextStyle] | fpdf.TextStyle | None = None, 98 ) -> None: 99 """Initializes the renderer. 100 101 Args: 102 border: Whether to show borders around the entries and headers. 103 Mainly for debugging purposes. Defaults to `False`. 104 ignore_same_page_refs: Whether to ignore references (locators) to 105 the same PDF page (default), else same pages will be printed 106 multiple times. 107 level_indent: The indent to add per entry depth to the left of the 108 entry. Defaults to `7.5` times the 109 [fpdf.FPDF.unit](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF). 110 line_spacing: The spacing between lines as multiple of the font 111 size. Defaults to `None`, meaning `1.0`. 112 max_outline_level: If `outline_level` >= 0, `max_outline_level` 113 will decide how many deeper entries will be added to the PDF 114 outline. Defaults to `None`, meaning that no liimit is set. 115 outline_level: If `outline_level` >= 0, the first entry depth will 116 be added at this outline level to the PDF. If 117 `show_header=True`, the headers will be added at this outline 118 level to the PDF. Defaults to `None`, meaning to not show the 119 entries (or headers) in the PDF outline. 120 run_in_style: Whether to print the deepest entry levels at "run-in"- 121 style (>2). Defaults to `True`. 122 show_header: Whether to show the headers. Defaults to `False`. 123 sort_emph_first: Whether to show emphasized references (locators) 124 first. Defaults to `False`. 125 text_styles: The text styles to use to print the entries at the 126 different depths. If `show_header=True`, the first text style 127 refers to the style of the headers. If an entry is "deeper" than 128 there are text styles, the renderer will fall back to deepest 129 given text style. Defaults to `None`, meaning to take the 130 text style of the last PDF page. 131 """ # noqa: DOC501 132 self.border = border 133 self.ignore_same_page_refs = bool(ignore_same_page_refs) 134 self.level_indent = 0.0 if level_indent is None else float(level_indent) 135 self.line_spacing = 1.0 if line_spacing is None else float(line_spacing) 136 self.max_outline_level = ( 137 -1 if max_outline_level is None else int(max_outline_level) 138 ) 139 self.outline_level = -1 if outline_level is None else int(outline_level) 140 self.run_in_style = bool(run_in_style) 141 self.show_header = bool(show_header) 142 self.sort_emph_first = bool(sort_emph_first) 143 144 if text_styles is None: 145 self.text_styles = [fpdf.TextStyle()] 146 elif isinstance(text_styles, Iterable): 147 self.text_styles = list(text_styles) 148 elif isinstance(text_styles, fpdf.TextStyle): 149 self.text_styles = [text_styles] 150 else: 151 msg = f"invalid type of text_styles: {type(text_styles):__name__:s}" 152 raise TypeError(msg) 153 154 self._cur_header = None 155 self._h_header_min = None 156 self._link_locations = {} 157 158 def render_text_index( 159 self, 160 pdf: FPDF, 161 entries: list[TextIndexEntry], 162 ) -> None: 163 """Renders the text index. 164 165 Note: 166 Use this method as `render_index_function`-argument in 167 `fpdf2_textindex.pdf.FPDF.insert_index_placeholder`. 168 169 Args: 170 pdf: The `fpdf2_textindex.pdf.FPDF`-instance to render in. 171 entries: The list of entries to render. 172 173 Raises: 174 ValueError: If a textstyle has a [fpdf.Align](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.Align) 175 -value as left margin. 176 """ # noqa: DOC502 177 assert pdf.index_placeholder is not None 178 179 LOGGER.info("Rendering text index") 180 if not entries: 181 LOGGER.warning("No entries defined") 182 return 183 184 max_depth = max(e.depth for e in entries) 185 if max_depth > 2: 186 if self.run_in_style: 187 LOGGER.warning( 188 "Deep index (>2 levels): Level %d entries will be run-in " 189 "to level %d (see docs to disable)", 190 max_depth, 191 max_depth - 1, 192 ) 193 else: 194 LOGGER.warning( 195 "Deep index (>2 levels): Consider reducing depth, or " 196 "enable run-in (see docs)" 197 ) 198 199 # Reset section title styles to guarantee adding to outline without add 200 # section title 201 prev_section_title_styles = pdf.section_title_styles 202 pdf.section_title_styles = {} 203 204 for entry in entries: 205 if entry.depth > 1: 206 continue 207 208 prepared_entries: list[tuple[TextIndexEntryP, str]] = list( 209 self._prepare_entry(pdf, entry, max_depth) 210 ) 211 self._render_header(pdf, entry, prepared_entries[0][1]) 212 for e, text in prepared_entries: 213 # LOGGER.info("%d %r", pdf.page, e.label) 214 page_entry, x_entry, y_entry, w_entry, h_entry = ( 215 self._render_entry(pdf, e, text) 216 ) 217 if isinstance(e, TextIndexEntry): 218 self._set_links( 219 pdf, e, page_entry, x_entry, y_entry, w_entry, h_entry 220 ) 221 if self._run_in_children(e, max_depth): 222 for c in e.children: 223 self._set_links( 224 pdf, 225 c, 226 page_entry, 227 x_entry, 228 y_entry, 229 w_entry, 230 h_entry, 231 ) 232 233 pdf.section_title_styles = prev_section_title_styles 234 235 LOGGER.info("Rendered text index") 236 237 def _render_entry( 238 self, 239 pdf: FPDF, 240 entry: TextIndexEntryP, 241 entry_text: str, 242 ) -> tuple[int, float, float, float, float]: 243 # Do not fit half an entry 244 text_style = self._get_text_style(entry.depth) 245 w_entry, h_entry = self._calc_entry_size(pdf, entry.depth, entry_text) 246 pdf._perform_page_break_if_need_be(h_entry) 247 248 x_entry, y_entry = pdf.x, pdf.y 249 # Consider level indent 250 if TYPE_CHECKING: 251 assert not isinstance(text_style.l_margin, fpdf.Align) 252 l_margin = ( 253 text_style.l_margin or pdf.l_margin 254 ) + self.level_indent * entry.depth 255 with ( 256 self._add_to_outline(pdf, entry.depth, entry.label), 257 pdf.use_text_style(text_style.replace(l_margin=l_margin)), 258 ): 259 page_entry = pdf.page 260 pdf.multi_cell( 261 w=0, 262 h=pdf.font_size * self.line_spacing, 263 text=entry_text, 264 align=fpdf.Align.L, 265 border=int(self.border), # type: ignore[arg-type] 266 first_line_indent=-self.level_indent, 267 markdown=True, 268 new_x=fpdf.XPos.LMARGIN, 269 new_y=fpdf.YPos.NEXT, 270 ) 271 x_entry += self.level_indent * (entry.depth - 1) 272 assert fpdf.util.FloatTolerance.equal(pdf.y - y_entry, h_entry), ( 273 pdf.y - y_entry, 274 h_entry, 275 ) 276 return page_entry, x_entry, y_entry, w_entry, h_entry 277 278 def _render_header( 279 self, 280 pdf: FPDF, 281 entry: TextIndexEntryP, 282 first_entry_text: str, 283 ) -> None: 284 if not self.show_header or entry.depth > 1: 285 return 286 287 if entry.sort_label == "\uffff": # Empty label and sort key 288 return 289 290 next_header = entry.sort_label[0].upper() 291 if next_header == self._cur_header: 292 return 293 294 # Do not fit a single header without an entry at page bottom 295 h_header_min = self._calc_min_header_height(pdf, first_entry_text) 296 pdf._perform_page_break_if_need_be(h_header_min) 297 298 with ( 299 self._add_to_outline(pdf, entry.depth, next_header, header=True), 300 pdf.use_text_style(self._get_text_style(0)), 301 ): 302 h = pdf.font_size * self.line_spacing 303 pdf.cell( 304 h=h, 305 text=next_header, 306 border=int(self.border), # type: ignore[arg-type] 307 new_x=fpdf.XPos.LMARGIN, 308 new_y=fpdf.YPos.NEXT, 309 ) 310 311 self._cur_header = next_header 312 313 @contextlib.contextmanager 314 def _add_to_outline( 315 self, 316 pdf: FPDF, 317 entry_depth: int, 318 entry_label: str | None, 319 *, 320 header: bool = False, 321 ) -> Iterator[None]: 322 if entry_label is None or self.outline_level < 0: 323 yield 324 return 325 326 level = ( 327 self.outline_level 328 + int(self.show_header and not header) 329 + entry_depth 330 - 1 331 ) 332 if self.max_outline_level > -1 and level > self.max_outline_level: 333 yield 334 return 335 336 name = MDEmphasis.remove(entry_label) 337 pdf.start_section(name, level=level) 338 with pdf._marked_sequence(title=name) as struct_elem: 339 outline_struct_elem = struct_elem 340 yield 341 pdf._outline[-1].struct_elem = outline_struct_elem 342 343 def _calc_entry_size( 344 self, 345 pdf: FPDF, 346 entry_depth: int, 347 entry_text: str, 348 ) -> tuple[float, float]: 349 text_style = self._get_text_style(entry_depth) 350 if isinstance(text_style.l_margin, (fpdf.Align | str)): 351 align = fpdf.Align.coerce(text_style.l_margin) 352 msg = ( 353 f"TextStyle with l_margin as align value {align!r} cannot be " 354 f"used in {type(self).__name__:s}" 355 ) 356 raise ValueError(msg) 357 358 prev_x, prev_y = pdf.x, pdf.y 359 # Consider level indent 360 l_margin = ( 361 text_style.l_margin or pdf.l_margin 362 ) + self.level_indent * entry_depth 363 364 with pdf.use_text_style( 365 text_style.replace(t_margin=0, l_margin=l_margin, b_margin=0) 366 ): 367 if TYPE_CHECKING: 368 lines: list[str] 369 h: float 370 lines, h = pdf.multi_cell( # type: ignore[assignment, misc] 371 w=0, 372 h=pdf.font_size * self.line_spacing, 373 text=entry_text, 374 align=fpdf.Align.L, 375 dry_run=True, 376 first_line_indent=-self.level_indent, 377 markdown=True, 378 output=fpdf.enums.MethodReturnValue.LINES 379 | fpdf.enums.MethodReturnValue.HEIGHT, 380 padding=fpdf.util.Padding( 381 top=text_style.t_margin or 0, 382 bottom=text_style.b_margin or 0, 383 ), 384 ) 385 w = max( 386 pdf.get_string_width( 387 line, 388 normalized=True, 389 markdown=True, 390 ) 391 for line in lines 392 ) 393 w += 2 * pdf.c_margin + self.level_indent 394 395 assert pdf.x == prev_x and pdf.y == prev_y, ( 396 "position changed during calculation of entry height" 397 ) 398 return w, h 399 400 def _calc_min_header_height( 401 self, 402 pdf: FPDF, 403 entry_text: str, 404 ) -> float: 405 # Header 406 text_style = self.text_styles[0] 407 h_min = text_style.t_margin 408 h_min += ( 409 (text_style.size_pt or pdf.font_size_pt) * self.line_spacing / pdf.k 410 ) 411 h_min += text_style.b_margin 412 413 # First entry 414 text_style = self.text_styles[min(1, len(self.text_styles) - 1)] 415 h_min += self._calc_entry_size(pdf, 1, entry_text)[1] 416 return h_min 417 418 @staticmethod 419 def _entry_at_label_path( 420 entry: TextIndexEntry, 421 label_path: Iterable[str], 422 ) -> TextIndexEntry | None: 423 # Go to root 424 d = deque(entry.iter_parents(), maxlen=1) 425 node: TextIndexEntry | None = (d[0] if d else entry).parent # root 426 if node is None: 427 return None 428 429 # Iterate down according to label path 430 for label in label_path: 431 node = node.get_child(label) 432 if node is None: 433 return None 434 return node 435 436 def _get_text_style(self, entry_depth: int) -> fpdf.TextStyle: 437 d = min( 438 int(self.show_header) + entry_depth - 1, 439 len(self.text_styles) - 1, 440 ) 441 return self.text_styles[d] 442 443 def _prepare_entry( 444 self, 445 pdf: FPDF, 446 entry: TextIndexEntry, 447 max_depth: int, 448 *, 449 _run_in: bool = False, 450 ) -> Iterator[tuple[TextIndexEntryP, str]]: 451 running_in = entry.parent and self._run_in_children( 452 entry.parent, max_depth 453 ) 454 if running_in and not _run_in: 455 return 456 457 has_refs = len(entry.references) > 0 458 has_see_refs = any( 459 cr.type == CrossReferenceType.SEE for cr in entry.cross_references 460 ) 461 assert not (has_see_refs and has_refs), ( 462 f"Entry {entry.joined_label_path!r} has a reference (locator) " 463 f"and a SEE-ross reference" 464 ) 465 has_also_refs = any( 466 cr.type == CrossReferenceType.ALSO for cr in entry.cross_references 467 ) 468 469 # Label 470 text_pts = [entry.label] 471 472 # SEE-cross references 473 if has_see_refs: 474 text_pts.extend( 475 self._prepare_cross_references( 476 pdf, 477 entry, 478 CrossReferenceType.SEE, 479 "running_in" if running_in or entry.depth > 1 else "entry", 480 ) 481 ) 482 483 # References (locators) 484 if has_refs: 485 text_pts.extend( 486 self._prepare_references( 487 pdf, 488 entry, 489 const.CATEGORY_SEPARATOR 490 if has_see_refs 491 else const.FIELD_SEPARATOR, 492 ) 493 ) 494 495 # Run-in style 496 run_in_children = self._run_in_children(entry, max_depth) 497 if run_in_children and entry.children: 498 if has_refs: 499 separator: str = const.LIST_SEPARATOR 500 elif has_see_refs: # and not has_refs 501 separator = const.CATEGORY_SEPARATOR 502 else: # not has_see_refs 503 separator = const.PATH_SEPARATOR 504 text_pts.append(separator) 505 506 for i, child in enumerate(entry.children): 507 if i > 0: 508 text_pts.append(const.LIST_SEPARATOR) 509 text_pts.extend( 510 t 511 for _, t in self._prepare_entry( 512 pdf, child, max_depth, _run_in=True 513 ) 514 ) 515 516 # Own SEE ALSO-ross references 517 # Check whether we lack children and thus potentially need to inline our 518 # own SEE ALSO-cross references. This provides run-in style for such 519 # cross references. 520 if has_also_refs and (not entry.children or run_in_children): 521 text_pts.extend( 522 self._prepare_cross_references( 523 pdf, 524 entry, 525 CrossReferenceType.ALSO, 526 "running_in" if running_in else "entry", 527 ) 528 ) 529 530 text = "".join(text_pts) 531 LOGGER.debug( 532 "%sEntry %r (Level%d): %r", 533 " " * (entry.depth - 1), 534 entry.label, 535 entry.depth, 536 text, 537 ) 538 yield entry, text 539 540 if not run_in_children: 541 for child in entry.children: 542 yield from self._prepare_entry( 543 pdf, child, max_depth, _run_in=False 544 ) 545 546 if ( 547 not running_in 548 and entry.parent 549 and any( 550 cr.type == CrossReferenceType.ALSO 551 for cr in entry.parent.cross_references 552 ) 553 ): 554 text = "".join( 555 self._prepare_cross_references( 556 pdf, 557 entry.parent, 558 CrossReferenceType.ALSO, 559 "sub_entry", 560 ) 561 ) 562 LOGGER.debug( 563 "%sEntry %r (Level%d): %r", 564 " " * (entry.depth - 1), 565 entry.label, 566 entry.depth, 567 text, 568 ) 569 yield _AlsoPseudoEntry(depth=entry.depth), text 570 571 def _prepare_cross_references( 572 self, 573 pdf: FPDF, 574 entry: TextIndexEntry, 575 cross_ref_type: CrossReferenceType, 576 mode: Literal["entry", "running_in", "sub_entry"], 577 ) -> Iterator[str]: 578 # Sort by type and label path 579 entry.cross_references.sort(key=lambda cr: (cr.type, *cr.label_path)) 580 581 # See (also) under 582 under_mode = ( 583 len(entry.cross_references) == 1 584 and sum(cr.type == cross_ref_type for cr in entry.cross_references) 585 == 1 586 and entry.label == entry.cross_references[-1].label_path[-1] 587 ) 588 589 match mode: 590 case "entry": 591 yield const.CATEGORY_SEPARATOR 592 case "running_in": 593 yield " (" 594 case "sub_entry": 595 pass 596 case _: 597 msg = f"invalid mode: {mode!r}" 598 raise ValueError(msg) 599 600 cross_ref_type_str = str(cross_ref_type) 601 cross_ref_type_str = ( 602 cross_ref_type_str.lower() 603 if mode == "running_in" 604 else cross_ref_type_str.capitalize() 605 ) 606 if under_mode: 607 cross_ref_type_str = f"{cross_ref_type_str:s} under" 608 cross_ref_type_str = MDEmphasis.ITALICS.format(cross_ref_type_str) 609 yield f"{cross_ref_type_str:s} " 610 611 i = 0 612 for cross_ref in entry.cross_references: 613 if cross_ref.type != cross_ref_type: 614 continue 615 616 # Try to find cross referenced entry 617 cross_ref_entry = self._entry_at_label_path( 618 entry, cross_ref.label_path 619 ) 620 if cross_ref_entry is None: 621 msg = "In entry %s, cross referenced entry %s does not exist" 622 log_level = ( 623 logging.WARNING 624 if len(cross_ref.label_path) == 1 625 else logging.ERROR 626 ) 627 LOGGER.log( 628 log_level, 629 msg, 630 entry.joined_label_path, 631 cross_ref.joined_label_path, 632 ) 633 if log_level == logging.ERROR: 634 raise RuntimeError( 635 msg 636 % (entry.joined_label_path, cross_ref.joined_label_path) 637 ) 638 elif sum(len(e.references) for e in iter(cross_ref_entry)) == 0: 639 msg = ( 640 "In entry %s, cross referenced entry %s has no own " 641 "reference(s) (blind cross reference)" 642 ) 643 LOGGER.warning( 644 msg, entry.joined_label_path, cross_ref.joined_label_path 645 ) 646 elif len(cross_ref_entry.cross_references) > 0: 647 msg = ( 648 "In entry %s, cross referenced entry %s leads to other " 649 "cross reference(s) (blind cross reference)" 650 ) 651 LOGGER.warning( 652 msg, entry.joined_label_path, cross_ref.joined_label_path 653 ) 654 655 # Write delimiter 656 if i > 0: 657 yield f"{const.REFS_DELIMITER:s} " 658 i += 1 659 660 # Write cross reference 661 cross_link = None 662 if cross_ref_entry is not None: 663 cross_link = f"{const.ENTRY_ID_PREFIX:s}{cross_ref_entry.id:d}" 664 if cross_link not in self._link_locations: 665 # Reserve link if not existing before 666 pdf.set_link(name=cross_link) 667 label_path = cross_ref.label_path 668 if under_mode: 669 label_path = label_path[:-1] 670 content = const.PATH_SEPARATOR.join(label_path) 671 if cross_link: 672 content = md_link(content, f"#{cross_link}") 673 yield content 674 675 if mode == "running_in": 676 yield ")" 677 678 def _prepare_references( 679 self, 680 pdf: FPDF, 681 entry: TextIndexEntry, 682 first_separator: str, 683 ) -> Iterator[str]: 684 if len(entry.references) == 0: 685 return 686 687 # Respect emphasis-first option 688 refs = sorted( 689 entry.references, 690 key=( 691 (lambda r: (not r.locator_emphasis, r.start_id, r.end_id)) 692 if self.sort_emph_first 693 else (lambda r: (r.start_id, r.end_id)) 694 ), 695 ) 696 697 # Warn about too many references 698 if len(refs) >= const.REFERENCES_LIMIT: 699 LOGGER.warning( 700 "Entry %r has %d locators, consider reorganising or being more " 701 "selective", 702 entry.joined_label_path, 703 len(refs), 704 ) 705 706 self._last_page = -1 707 for i, ref in enumerate(refs): 708 # Render page of start id 709 if TYPE_CHECKING: 710 assert isinstance(ref.start_location, LinkLocation) 711 yield from self._prepare_referenced_page( 712 pdf, 713 ref.start_link, 714 ref.start_location, 715 ref.locator_emphasis, 716 first_separator if i == 0 else const.FIELD_SEPARATOR, 717 ) 718 719 # Render page of end id 720 if isinstance(ref.end_link, str): 721 if TYPE_CHECKING: 722 assert isinstance(ref.end_location, LinkLocation) 723 yield from self._prepare_referenced_page( 724 pdf, 725 ref.end_link, 726 ref.end_location, 727 ref.locator_emphasis, 728 const.RANGE_SEPARATOR, 729 ) 730 731 # Render suffix of start id 732 separator = "" 733 if isinstance(ref.start_suffix, str): 734 yield separator 735 yield md_link(ref.start_suffix, f"#{ref.start_link:s}") 736 separator = " " 737 738 # Render suffix of end id 739 if isinstance(ref.end_suffix, str): 740 if ref.end_link is None: 741 msg = ( 742 f"entry's {entry.joined_label_path!r:s} " 743 f"(id={entry.id:d}) reference with start id " 744 f"{ref.start_id:d} has end suffix " 745 f"{ref.end_suffix!r:s}, but no end id" 746 ) 747 raise RuntimeError(msg) 748 yield separator 749 yield md_link(ref.end_suffix, f"#{ref.end_link:s}") 750 751 def _prepare_referenced_page( 752 self, 753 pdf: FPDF, 754 text_to_index_link: str, 755 link_loc: LinkLocation, 756 locator_emphasis: bool, 757 separator: str, 758 ) -> Iterator[str]: 759 # Ignore consecutive references to same page 760 if self.ignore_same_page_refs and link_loc.page == self._last_page: 761 return 762 763 # Catch that font does not support unicode characters 764 if separator == const.RANGE_SEPARATOR: 765 try: 766 pdf.normalize_text(separator) 767 except fpdf.errors.FPDFUnicodeEncodingException: 768 separator = "-" 769 770 # Write separator 771 yield separator 772 773 # Point link of page number in index to text page 774 index_to_text_link = f"{text_to_index_link:s}{const.TEXT_ID_SUFFIX:s}" 775 pdf.add_link( 776 name=index_to_text_link, 777 page=link_loc.page, 778 x=link_loc.x, 779 y=link_loc.y, 780 ) 781 782 # Write page number 783 self._last_page = link_loc.page 784 content = pdf.pages[link_loc.page].get_label() 785 text = md_link(content, f"#{index_to_text_link:s}") 786 yield MDEmphasis.BOLD.format(text) if locator_emphasis else text 787 788 def _run_in_children(self, entry: TextIndexEntry, max_depth: int) -> bool: 789 """Returns whether the entry should render its children in run-in style. 790 791 Top-level entries are at level 1, and are considered children of the 792 index (root) itself. Depths 1 and 2 (top-level entries and their sub- 793 -entries) are always indented. Thereafter, for practical reasons, only 794 the deepest level is run-in. 795 796 Note: 797 Please don't make indexes deeper than 3 levels (sub-sub-entries) 798 though, for your readers' sake! 799 """ 800 if self.run_in_style: 801 return entry.depth >= 2 and entry.depth == max_depth - 1 802 return False 803 804 def _set_links( 805 self, 806 pdf: FPDF, 807 entry: TextIndexEntry, 808 page_entry: int, 809 x_entry: float, 810 y_entry: float, 811 w_entry: float, 812 h_entry: float, 813 ) -> None: 814 # Add link to entry label into link locations 815 entry_link = f"{const.ENTRY_ID_PREFIX:s}{entry.id:d}" 816 assert entry_link not in self._link_locations, ( 817 repr(entry), 818 self._link_locations[entry_link], 819 ) 820 pdf.add_link(name=entry_link, x=x_entry, y=y_entry) 821 link_loc = LinkLocation( 822 page=page_entry, 823 x=x_entry, 824 y=y_entry, 825 w=w_entry, 826 h=h_entry, 827 ) 828 self._link_locations[entry_link] = link_loc 829 LOGGER.debug( 830 "%sEntry %r (Level%d): %r", 831 " " * (entry.depth - 1), 832 entry.label, 833 entry.depth, 834 link_loc, 835 ) 836 837 # Point links on text page to index entry 838 # References 839 for ref in entry.references: 840 # dest = pdf.named_destinations[text_to_index_link] 841 # fpdf_link_idx = reverse_dict_items(pdf.links.items())[dest] 842 fpdf_link_idx = pdf._index_links[ref.start_link] 843 pdf.set_link( 844 link=fpdf_link_idx, 845 name=ref.start_link, 846 page=link_loc.page, 847 x=link_loc.x, 848 y=link_loc.y, 849 ) 850 851 if isinstance(ref.end_link, str): 852 fpdf_link_idx = pdf._index_links[ref.end_link] 853 pdf.set_link( 854 link=fpdf_link_idx, 855 name=ref.end_link, 856 page=link_loc.page, 857 x=link_loc.x, 858 y=link_loc.y, 859 ) 860 861 # Cross references 862 for cross_ref in entry.cross_references: 863 fpdf_link_idx = pdf._index_links[cross_ref.link] 864 pdf.set_link( 865 link=fpdf_link_idx, 866 name=cross_ref.link, 867 page=link_loc.page, 868 x=link_loc.x, 869 y=link_loc.y, 870 )
Text Index (Writer).
A reference implementation of a Text Index to use with fpdf2.
This class provides a customizable Text Index that can be used directly or
subclassed for additional functionality.
To use this class, create an instance of TextIndexRenderer,
configure it as needed, and pass its
TextIndexRenderer.render_text_index()-method as
render_index_function-argument to
fpdf2_textindex.pdf.FPDF.insert_index_placeholder().
85 def __init__( 86 self, 87 *, 88 border: bool = False, 89 ignore_same_page_refs: bool = True, 90 level_indent: float | None = 7.5, 91 line_spacing: float | None = None, 92 max_outline_level: int | None = None, 93 outline_level: int | None = None, 94 run_in_style: bool = True, 95 show_header: bool = False, 96 sort_emph_first: bool = False, 97 text_styles: Iterable[fpdf.TextStyle] | fpdf.TextStyle | None = None, 98 ) -> None: 99 """Initializes the renderer. 100 101 Args: 102 border: Whether to show borders around the entries and headers. 103 Mainly for debugging purposes. Defaults to `False`. 104 ignore_same_page_refs: Whether to ignore references (locators) to 105 the same PDF page (default), else same pages will be printed 106 multiple times. 107 level_indent: The indent to add per entry depth to the left of the 108 entry. Defaults to `7.5` times the 109 [fpdf.FPDF.unit](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF). 110 line_spacing: The spacing between lines as multiple of the font 111 size. Defaults to `None`, meaning `1.0`. 112 max_outline_level: If `outline_level` >= 0, `max_outline_level` 113 will decide how many deeper entries will be added to the PDF 114 outline. Defaults to `None`, meaning that no liimit is set. 115 outline_level: If `outline_level` >= 0, the first entry depth will 116 be added at this outline level to the PDF. If 117 `show_header=True`, the headers will be added at this outline 118 level to the PDF. Defaults to `None`, meaning to not show the 119 entries (or headers) in the PDF outline. 120 run_in_style: Whether to print the deepest entry levels at "run-in"- 121 style (>2). Defaults to `True`. 122 show_header: Whether to show the headers. Defaults to `False`. 123 sort_emph_first: Whether to show emphasized references (locators) 124 first. Defaults to `False`. 125 text_styles: The text styles to use to print the entries at the 126 different depths. If `show_header=True`, the first text style 127 refers to the style of the headers. If an entry is "deeper" than 128 there are text styles, the renderer will fall back to deepest 129 given text style. Defaults to `None`, meaning to take the 130 text style of the last PDF page. 131 """ # noqa: DOC501 132 self.border = border 133 self.ignore_same_page_refs = bool(ignore_same_page_refs) 134 self.level_indent = 0.0 if level_indent is None else float(level_indent) 135 self.line_spacing = 1.0 if line_spacing is None else float(line_spacing) 136 self.max_outline_level = ( 137 -1 if max_outline_level is None else int(max_outline_level) 138 ) 139 self.outline_level = -1 if outline_level is None else int(outline_level) 140 self.run_in_style = bool(run_in_style) 141 self.show_header = bool(show_header) 142 self.sort_emph_first = bool(sort_emph_first) 143 144 if text_styles is None: 145 self.text_styles = [fpdf.TextStyle()] 146 elif isinstance(text_styles, Iterable): 147 self.text_styles = list(text_styles) 148 elif isinstance(text_styles, fpdf.TextStyle): 149 self.text_styles = [text_styles] 150 else: 151 msg = f"invalid type of text_styles: {type(text_styles):__name__:s}" 152 raise TypeError(msg) 153 154 self._cur_header = None 155 self._h_header_min = None 156 self._link_locations = {}
Initializes the renderer.
Arguments:
- border: Whether to show borders around the entries and headers.
Mainly for debugging purposes. Defaults to
False. - ignore_same_page_refs: Whether to ignore references (locators) to the same PDF page (default), else same pages will be printed multiple times.
- level_indent: The indent to add per entry depth to the left of the
entry. Defaults to
7.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.
158 def render_text_index( 159 self, 160 pdf: FPDF, 161 entries: list[TextIndexEntry], 162 ) -> None: 163 """Renders the text index. 164 165 Note: 166 Use this method as `render_index_function`-argument in 167 `fpdf2_textindex.pdf.FPDF.insert_index_placeholder`. 168 169 Args: 170 pdf: The `fpdf2_textindex.pdf.FPDF`-instance to render in. 171 entries: The list of entries to render. 172 173 Raises: 174 ValueError: If a textstyle has a [fpdf.Align](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.Align) 175 -value as left margin. 176 """ # noqa: DOC502 177 assert pdf.index_placeholder is not None 178 179 LOGGER.info("Rendering text index") 180 if not entries: 181 LOGGER.warning("No entries defined") 182 return 183 184 max_depth = max(e.depth for e in entries) 185 if max_depth > 2: 186 if self.run_in_style: 187 LOGGER.warning( 188 "Deep index (>2 levels): Level %d entries will be run-in " 189 "to level %d (see docs to disable)", 190 max_depth, 191 max_depth - 1, 192 ) 193 else: 194 LOGGER.warning( 195 "Deep index (>2 levels): Consider reducing depth, or " 196 "enable run-in (see docs)" 197 ) 198 199 # Reset section title styles to guarantee adding to outline without add 200 # section title 201 prev_section_title_styles = pdf.section_title_styles 202 pdf.section_title_styles = {} 203 204 for entry in entries: 205 if entry.depth > 1: 206 continue 207 208 prepared_entries: list[tuple[TextIndexEntryP, str]] = list( 209 self._prepare_entry(pdf, entry, max_depth) 210 ) 211 self._render_header(pdf, entry, prepared_entries[0][1]) 212 for e, text in prepared_entries: 213 # LOGGER.info("%d %r", pdf.page, e.label) 214 page_entry, x_entry, y_entry, w_entry, h_entry = ( 215 self._render_entry(pdf, e, text) 216 ) 217 if isinstance(e, TextIndexEntry): 218 self._set_links( 219 pdf, e, page_entry, x_entry, y_entry, w_entry, h_entry 220 ) 221 if self._run_in_children(e, max_depth): 222 for c in e.children: 223 self._set_links( 224 pdf, 225 c, 226 page_entry, 227 x_entry, 228 y_entry, 229 w_entry, 230 h_entry, 231 ) 232 233 pdf.section_title_styles = prev_section_title_styles 234 235 LOGGER.info("Rendered text index")
Renders the text index.
Note:
Use this method as
render_index_function-argument 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.