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