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