Coverage for fpdf2_textindex/_fpdf/_fpdf.py: 55.58%
633 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"""Fixes bugs in :py:class:`fpdf.FPDF`."""
3# ruff: noqa: E501, E713, RUF069, SIM102, SIM108, UP007, UP045
5from collections import defaultdict
6from collections.abc import Callable, Iterator, Sequence
7from contextlib import contextmanager
8import re
9import types
10from typing import Literal, Optional, Union
11import warnings
13from fpdf.deprecation import get_stack_level
14from fpdf.deprecation import support_deprecated_txt_arg
15from fpdf.drawing_primitives import DeviceCMYK
16from fpdf.drawing_primitives import DeviceGray
17from fpdf.drawing_primitives import DeviceRGB
18from fpdf.drawing_primitives import convert_to_device_color
19from fpdf.enums import Align
20from fpdf.enums import CharVPos
21from fpdf.enums import DocumentCompliance
22from fpdf.enums import MethodReturnValue
23from fpdf.enums import PDFResourceType
24from fpdf.enums import PageOrientation
25from fpdf.enums import TextEmphasis
26from fpdf.enums import TextMode
27from fpdf.enums import WrapMode
28from fpdf.enums import XPos
29from fpdf.enums import YPos
30from fpdf.errors import FPDFException
31from fpdf.fonts import CoreFont
32from fpdf.fonts import TTFFont
33import fpdf.fpdf
34from fpdf.fpdf import ToCPlaceholder
35from fpdf.fpdf import check_page
36from fpdf.graphics_state import StateStackType
37from fpdf.line_break import Fragment
38from fpdf.line_break import MultiLineBreak
39from fpdf.line_break import TextLine
40from fpdf.line_break import TotalPagesSubstitutionFragment
41from fpdf.outline import OutlineSection
42from fpdf.output import ResourceTypes
43from fpdf.syntax import PDFArray
44from fpdf.table import draw_box_borders
45from fpdf.unicode_script import UnicodeScript
46from fpdf.unicode_script import get_unicode_script
47from fpdf.util import FloatTolerance
48from fpdf.util import Padding
51class FPDF(fpdf.fpdf.FPDF):
52 __PATCHED__: bool = True
53 # BUGFIX: Support of empty md links and escaped square brackets in link
54 MARKDOWN_ESCAPE_CHARACTER = "\\"
55 MARKDOWN_LINK_REGEX = re.compile(
56 rf"^(?<!{MARKDOWN_ESCAPE_CHARACTER * 2:s})"
57 rf"\[((?:{MARKDOWN_ESCAPE_CHARACTER * 2:s}[\[\]]|[^\[\]])*)\]"
58 r"\(([^()]+)\)(.*)$",
59 re.DOTALL,
60 )
61 _MARKDOWN_LINK_TEXT_UNESCAPE = re.compile(r"\\([\[\]])")
63 def __init__(
64 self,
65 orientation: str | PageOrientation = PageOrientation.PORTRAIT,
66 unit: str | float = "mm",
67 format: str | tuple[float, float] = "A4",
68 font_cache_dir: Literal["DEPRECATED"] = "DEPRECATED",
69 *,
70 enforce_compliance: str | DocumentCompliance | None = None,
71 ) -> None:
72 super().__init__(
73 orientation=orientation,
74 unit=unit,
75 format=format,
76 font_cache_dir=font_cache_dir,
77 enforce_compliance=enforce_compliance,
78 )
79 # BUGFIX: https://github.com/py-pdf/fpdf2/issues/1837, bug-toc-rendering
80 self._toc_gstate: Optional[StateStackType] = None
82 # BUGFIX: https://github.com/py-pdf/fpdf2/issues/1807, dry-run-in-toc
83 @contextmanager
84 def _disable_writing(self) -> Iterator[None]:
85 if not isinstance(self._out, types.MethodType): 85 ↛ 88line 85 didn't jump to line 88 because the condition on line 85 was never true
86 # This mean that self._out has already been redefined.
87 # This is the case of a nested call to this method: we do nothing
88 yield
89 return
90 self._out = lambda *args, **kwargs: None # type: ignore[method-assign]
91 prev_page, prev_pages_count, prev_x, prev_y, prev_toc_inserted_pages = (
92 self.page,
93 self.pages_count,
94 self.x,
95 self.y,
96 self._toc_inserted_pages,
97 )
98 annots = PDFArray(self.pages[self.page].annots or [])
99 self._push_local_stack()
100 try:
101 yield
102 finally:
103 self._pop_local_stack()
104 # restore location:
105 for p in range(prev_pages_count + 1, self.pages_count + 1):
106 del self.pages[p]
107 self.page = prev_page
108 self.pages[self.page].annots = annots
109 self.set_xy(prev_x, prev_y)
110 # restore inserted pages in toc
111 self._toc_inserted_pages = prev_toc_inserted_pages
112 # restore writing function:
113 del self._out
115 def _insert_table_of_contents(self) -> None:
116 # Doc has been closed but we want to write to self.pages[self.page] instead of self.buffer:
117 tocp = self.toc_placeholder
118 assert tocp is not None
119 prev_page, prev_y = self.page, self.y
120 self.page, self.y = tocp.start_page, tocp.y
121 # BUGFIX: https://github.com/py-pdf/fpdf2/issues/1807, bug-toc-rendering
122 # Set gstate to toc page
123 assert self._toc_gstate is not None
124 assert not self._is_current_graphics_state_nested()
125 cur_gstate = self._pop_local_stack()
126 self._push_local_stack(new=self._toc_gstate)
127 # flag rendering ToC for page breaking function
128 self.in_toc_rendering = True
129 self._set_orientation(tocp.page_orientation, self.dw_pt, self.dh_pt)
130 tocp.render_function(self, self._outline)
131 self.in_toc_rendering = False # set ToC rendering flag off
132 expected_final_page = tocp.start_page + tocp.pages - 1
133 if ( 133 ↛ 137line 133 didn't jump to line 137 because the condition on line 133 was never true
134 self.page != expected_final_page
135 and not self._toc_allow_page_insertion
136 ):
137 too = "many" if self.page > expected_final_page else "few"
138 error_msg = f"The rendering function passed to FPDF.insert_toc_placeholder triggered too {too} page breaks: "
139 error_msg += f"ToC ended on page {self.page} while it was expected to span exactly {tocp.pages} pages"
140 raise FPDFException(error_msg)
141 if self._toc_inserted_pages:
142 # Generating final page footer after more pages were inserted:
143 self._render_footer()
144 # We need to reorder the pages, because some new pages have been inserted in the ToC,
145 # but they have been inserted at the end of self.pages:
146 new_pages = [
147 self.pages.pop(len(self.pages))
148 for _ in range(self._toc_inserted_pages)
149 ]
150 new_pages = list(reversed(new_pages))
151 indices_remap: dict[int, int] = {}
152 for page_index in range(
153 tocp.start_page + 1, self.pages_count + len(new_pages) + 1
154 ):
155 if page_index in self.pages:
156 new_pages.append(self.pages.pop(page_index))
157 page = self.pages[page_index] = new_pages.pop(0)
158 # Fix page indices:
159 indices_remap[page.index()] = page_index
160 page.set_index(page_index)
161 # Fix page labels:
162 if tocp.reset_page_indices is False: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 page.get_page_label().st = page_index # type: ignore[union-attr]
164 assert len(new_pages) == 0, f"#new_pages: {len(new_pages)}"
165 # Fix links:
166 for dest in self.links.values():
167 assert dest.page_number is not None
168 new_index = indices_remap.get(dest.page_number)
169 if new_index is not None:
170 dest.page_number = new_index
171 # Fix outline:
172 for section in self._outline: 172 ↛ 173line 172 didn't jump to line 173 because the loop on line 172 never started
173 new_index = indices_remap.get(section.page_number)
174 if new_index is not None:
175 section.dest = section.dest.replace(page=new_index)
176 section.page_number = new_index
177 if section.struct_elem:
178 # pylint: disable=protected-access
179 section.struct_elem._page_number = ( # pyright: ignore[reportPrivateUsage]
180 new_index
181 )
182 # Fix resource catalog:
183 resources_per_page = self._resource_catalog.resources_per_page
184 new_resources_per_page: dict[
185 tuple[int, PDFResourceType], set[ResourceTypes]
186 ] = defaultdict(set)
187 for (
188 page_number,
189 resource_type,
190 ), resource in resources_per_page.items():
191 key = (
192 indices_remap.get(page_number, page_number),
193 resource_type,
194 )
195 new_resources_per_page[key] = resource
196 self._resource_catalog.resources_per_page = new_resources_per_page
197 # BUGFIX: https://github.com/py-pdf/fpdf2/issues/1807, bug-toc-rendering
198 # Reset gstate (after rendering of footer)
199 while self._is_current_graphics_state_nested(): 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 self._pop_local_stack()
201 self._pop_local_stack()
202 self._push_local_stack(cur_gstate)
203 # Reset page and y
204 self.page, self.y = prev_page, prev_y
206 # Bugfix: https://github.com/py-pdf/fpdf2/issues/1840, multi-cell-md-result
207 def _join_text_lines(
208 self,
209 text_lines: list[TextLine],
210 markdown: bool = False,
211 ) -> list[str]:
212 output_lines: list[str] = []
213 if not markdown: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true
214 for text_line in text_lines:
215 characters: list[str] = []
216 for frag in text_line.fragments:
217 characters.extend(frag.characters)
218 output_lines.append("".join(characters))
219 else:
220 emphasis_markers: dict[TextEmphasis, str] = {
221 TextEmphasis.NONE: "",
222 TextEmphasis.B: self.MARKDOWN_BOLD_MARKER,
223 TextEmphasis.I: self.MARKDOWN_ITALICS_MARKER,
224 TextEmphasis.U: self.MARKDOWN_UNDERLINE_MARKER,
225 TextEmphasis.S: self.MARKDOWN_STRIKETHROUGH_MARKER,
226 }
227 marker_pattern: str = "|".join(
228 re.escape(m)
229 for te, m in emphasis_markers.items()
230 if te != TextEmphasis.NONE
231 )
232 escape_pattern: re.Pattern[str] = re.compile(
233 rf"({marker_pattern:s})"
234 )
236 def escape(text: str) -> str:
237 return escape_pattern.sub(
238 rf"{self.MARKDOWN_ESCAPE_CHARACTER:s}\\1", text
239 )
241 for text_line in text_lines:
242 text_parts: list[str] = []
243 last_emphasis: TextEmphasis = TextEmphasis.NONE
244 for frag in text_line.fragments:
245 if markdown: 245 ↛ 264line 245 didn't jump to line 264 because the condition on line 245 was always true
246 next_emphasis = TextEmphasis.coerce(
247 frag.font_style
248 + ("U" if frag.underline else "")
249 + ("S" if frag.strikethrough else "")
250 )
251 # If fragment has a link and link underline is true,
252 # the underline marker must not be added
253 if frag.link and self.MARKDOWN_LINK_UNDERLINE:
254 next_emphasis &= ~TextEmphasis.U
255 removed_emphasis = last_emphasis & ~next_emphasis
256 for te in reversed(TextEmphasis):
257 if removed_emphasis & te:
258 text_parts.append(emphasis_markers[te])
259 added_emphasis = next_emphasis & ~last_emphasis
260 for te in TextEmphasis:
261 if added_emphasis & te:
262 text_parts.append(emphasis_markers[te])
263 last_emphasis = next_emphasis
264 text = "".join(frag.characters)
265 # NOTE: Currently, markdown format inside of links is not handled
266 # so only escape markdown markers outside of links
267 text_parts.append(
268 f"[{text:s}]({frag.link!s:s})"
269 if frag.link
270 else escape(text)
271 )
272 next_emphasis = TextEmphasis.NONE
273 removed_emphasis = last_emphasis & ~next_emphasis
274 for te in reversed(TextEmphasis):
275 if removed_emphasis & te:
276 text_parts.append(emphasis_markers[te])
277 output_lines.append("".join(text_parts))
278 return output_lines
280 def _parse_chars(self, text: str, markdown: bool) -> Iterator[Fragment]:
281 if (
282 not markdown
283 and not self.text_shaping
284 and not self._fallback_font_ids
285 ):
286 if self.str_alias_nb_pages: 286 ↛ 304line 286 didn't jump to line 304 because the condition on line 286 was always true
287 for seq, fragment_text in enumerate(
288 text.split(self.str_alias_nb_pages)
289 ):
290 if seq > 0: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true
291 yield TotalPagesSubstitutionFragment(
292 self.str_alias_nb_pages,
293 self._get_current_graphics_state(),
294 self.k,
295 )
296 if fragment_text: 296 ↛ 287line 296 didn't jump to line 287 because the condition on line 296 was always true
297 yield Fragment(
298 fragment_text,
299 self._get_current_graphics_state(),
300 self.k,
301 )
302 return
304 yield Fragment(text, self._get_current_graphics_state(), self.k)
305 return
306 txt_frag: list[str] = []
307 in_bold: bool = "B" in self.font_style
308 in_italics: bool = "I" in self.font_style
309 in_strikethrough: bool = bool(self.strikethrough)
310 in_underline: bool = bool(self.underline)
311 current_fallback_font = None
312 current_text_script = None
314 def frag() -> Fragment:
315 nonlocal txt_frag, current_fallback_font, current_text_script
316 gstate = self._get_current_graphics_state()
317 gstate.font_style = ("B" if in_bold else "") + (
318 "I" if in_italics else ""
319 )
320 gstate.strikethrough = in_strikethrough
321 gstate.underline = in_underline
322 if current_fallback_font: 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true
323 style = "".join(c for c in current_fallback_font if c in ("BI"))
324 family = current_fallback_font.replace("B", "").replace("I", "")
325 gstate.font_family = family
326 gstate.font_style = style
327 gstate.current_font = self.fonts[current_fallback_font]
328 current_fallback_font = None
329 current_text_script = None
330 fragment = Fragment(
331 txt_frag,
332 gstate,
333 self.k,
334 )
335 txt_frag = []
336 return fragment
338 if self.is_ttf_font: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true
339 font_glyphs = self.current_font.cmap # type: ignore[union-attr]
340 else:
341 font_glyphs = []
343 escape_next_marker = 0
344 escape_run = 0
346 while text:
347 if markdown and text[0] == self.MARKDOWN_ESCAPE_CHARACTER: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 escape_run += 1
349 text = text[1:]
350 continue
352 if markdown and escape_run: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true
353 is_escape_target = text[:2] in (
354 self.MARKDOWN_BOLD_MARKER,
355 self.MARKDOWN_ITALICS_MARKER,
356 self.MARKDOWN_STRIKETHROUGH_MARKER,
357 self.MARKDOWN_UNDERLINE_MARKER,
358 )
359 if is_escape_target and escape_run % 2 == 1:
360 for _ in range(escape_run - 1):
361 txt_frag.append(self.MARKDOWN_ESCAPE_CHARACTER)
362 if current_fallback_font:
363 if txt_frag:
364 yield frag()
365 current_fallback_font = None
366 escape_next_marker = 2
367 escape_run = 0
368 continue
369 for _ in range(escape_run):
370 txt_frag.append(self.MARKDOWN_ESCAPE_CHARACTER)
371 escape_run = 0
373 is_marker = text[:2] in (
374 self.MARKDOWN_BOLD_MARKER,
375 self.MARKDOWN_ITALICS_MARKER,
376 self.MARKDOWN_STRIKETHROUGH_MARKER,
377 self.MARKDOWN_UNDERLINE_MARKER,
378 )
379 if markdown and escape_next_marker: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true
380 is_marker = False
381 half_marker = text[0]
382 text_script = get_unicode_script(text[0])
383 if text_script not in (
384 UnicodeScript.COMMON,
385 UnicodeScript.UNKNOWN,
386 current_text_script,
387 ):
388 if txt_frag and current_text_script: 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true
389 yield frag()
390 current_text_script = text_script
392 if self.str_alias_nb_pages: 392 ↛ 414line 392 didn't jump to line 414 because the condition on line 392 was always true
393 if ( 393 ↛ 397line 393 didn't jump to line 397 because the condition on line 393 was never true
394 text[: len(self.str_alias_nb_pages)]
395 == self.str_alias_nb_pages
396 ):
397 if txt_frag:
398 yield frag()
399 gstate = self._get_current_graphics_state()
400 gstate.font_style = ("B" if in_bold else "") + (
401 "I" if in_italics else ""
402 )
403 gstate.strikethrough = in_strikethrough
404 gstate.underline = in_underline
405 yield TotalPagesSubstitutionFragment(
406 self.str_alias_nb_pages,
407 gstate,
408 self.k,
409 )
410 text = text[len(self.str_alias_nb_pages) :]
411 continue
413 # Check that previous & next characters are not identical to the marker:
414 if markdown: 414 ↛ 467line 414 didn't jump to line 467 because the condition on line 414 was always true
415 if (
416 is_marker
417 and (not txt_frag or txt_frag[-1] != half_marker)
418 and (len(text) < 3 or text[2] != half_marker)
419 ):
420 if txt_frag:
421 yield frag()
422 if text[:2] == self.MARKDOWN_BOLD_MARKER:
423 in_bold = not in_bold
424 if text[:2] == self.MARKDOWN_ITALICS_MARKER:
425 in_italics = not in_italics
426 if text[:2] == self.MARKDOWN_STRIKETHROUGH_MARKER:
427 in_strikethrough = not in_strikethrough
428 if text[:2] == self.MARKDOWN_UNDERLINE_MARKER:
429 in_underline = not in_underline
430 text = text[2:]
431 continue
433 is_link = self.MARKDOWN_LINK_REGEX.match(text)
434 if is_link:
435 link_text, link_dest, text = is_link.groups()
436 # BUGFIX: enable escaped square brackets in links
437 link_text = self._MARKDOWN_LINK_TEXT_UNESCAPE.sub(
438 r"\1", link_text
439 )
440 if txt_frag:
441 yield frag()
442 gstate = self._get_current_graphics_state()
443 # BUGFIX: https://github.com/py-pdf/fpdf2/issues/1826
444 gstate.font_style = ("B" if in_bold else "") + (
445 "I" if in_italics else ""
446 )
447 gstate.strikethrough = in_strikethrough
448 gstate.underline = (
449 self.MARKDOWN_LINK_UNDERLINE or in_underline
450 )
451 if self.MARKDOWN_LINK_COLOR:
452 gstate.text_color = convert_to_device_color(
453 self.MARKDOWN_LINK_COLOR
454 )
455 try:
456 page = int(link_dest)
457 link_dest = self.add_link(page=page)
458 except ValueError:
459 pass
460 yield Fragment(
461 list(link_text),
462 gstate,
463 self.k,
464 link=link_dest,
465 )
466 continue
467 if ( 467 ↛ 472line 467 didn't jump to line 472 because the condition on line 467 was never true
468 self.is_ttf_font
469 and text[0] != "\n"
470 and not ord(text[0]) in font_glyphs
471 ):
472 style = ("B" if in_bold else "") + ("I" if in_italics else "")
473 fallback_font = self.get_fallback_font(text[0], style)
474 if fallback_font:
475 if fallback_font == current_fallback_font:
476 txt_frag.append(text[0])
477 text = text[1:]
478 continue
479 if txt_frag:
480 yield frag()
481 current_fallback_font = fallback_font
482 txt_frag.append(text[0])
483 text = text[1:]
484 continue
485 if current_fallback_font: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true
486 if txt_frag:
487 yield frag()
488 current_fallback_font = None
489 txt_frag.append(text[0])
490 text = text[1:]
491 if markdown and escape_next_marker: 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true
492 escape_next_marker -= 1
493 if escape_next_marker == 0:
494 yield frag()
495 if markdown and escape_run: 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true
496 for _ in range(escape_run):
497 txt_frag.append(self.MARKDOWN_ESCAPE_CHARACTER)
498 escape_run = 0
499 if txt_frag:
500 yield frag()
502 # BUGFIX: https://github.com/py-pdf/fpdf2/issues/1826
503 def _render_styled_text_line(
504 self,
505 text_line: TextLine,
506 h: Optional[float] = None,
507 border: Union[str, int] = 0,
508 new_x: XPos = XPos.RIGHT,
509 new_y: YPos = YPos.TOP,
510 fill: bool = False,
511 link: Optional[str | int] = "",
512 center: bool = False,
513 padding: Optional[Padding] = None,
514 prevent_font_change: bool = False,
515 ) -> bool:
516 if isinstance(border, int) and border not in (0, 1): 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true
517 warnings.warn(
518 'Integer values for "border" parameter other than 1 are currently ignored',
519 stacklevel=get_stack_level(),
520 )
521 border = 1
522 elif isinstance(border, str) and set(border).issuperset("LTRB"): 522 ↛ 523line 522 didn't jump to line 523 because the condition on line 522 was never true
523 border = 1
525 if padding is None:
526 padding = Padding(0, 0, 0, 0)
527 l_c_margin = r_c_margin = float(0)
528 if padding.left == 0: 528 ↛ 530line 528 didn't jump to line 530 because the condition on line 528 was always true
529 l_c_margin = self.c_margin
530 if padding.right == 0: 530 ↛ 533line 530 didn't jump to line 533 because the condition on line 530 was always true
531 r_c_margin = self.c_margin
533 styled_txt_width = text_line.text_width
534 if not styled_txt_width:
535 for i, frag in enumerate(text_line.fragments):
536 unscaled_width = frag.get_width(initial_cs=i != 0)
537 styled_txt_width += unscaled_width
539 w = text_line.max_width
540 if w is None:
541 if not text_line.fragments: 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true
542 raise ValueError(
543 "'text_line' must have fragments if 'text_line.text_width' is None"
544 )
545 w = styled_txt_width + l_c_margin + r_c_margin
546 elif w == 0: 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true
547 w = self.w - self.r_margin - self.x
548 if center: 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true
549 self.x = self.l_margin + (self.epw - w) / 2
550 elif text_line.align == Align.X: 550 ↛ 551line 550 didn't jump to line 551 because the condition on line 550 was never true
551 self.x -= w / 2
553 max_font_size: float = 0 # how much height we need to accommodate.
554 # currently all font sizes within a line are vertically aligned on the baseline.
555 fragments = text_line.get_ordered_fragments()
556 for frag in fragments:
557 if FloatTolerance.greater_than(frag.font_size, max_font_size):
558 max_font_size = frag.font_size
559 if h is None: 559 ↛ 560line 559 didn't jump to line 560 because the condition on line 559 was never true
560 h = max_font_size
561 page_break_triggered = self._perform_page_break_if_need_be(h)
562 sl: list[str] = []
564 k = self.k
566 # pre-calc border edges with padding
568 left = (self.x - padding.left) * k
569 right = (self.x + w + padding.right) * k
570 top = (self.h - self.y + padding.top) * k
571 bottom = (self.h - (self.y + h) - padding.bottom) * k
573 if fill: 573 ↛ 574line 573 didn't jump to line 574 because the condition on line 573 was never true
574 op = "B" if border == 1 else "f"
575 sl.append(
576 f"{left:.2f} {top:.2f} {right - left:.2f} {bottom - top:.2f} re {op}"
577 )
578 elif border == 1: 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true
579 sl.append(
580 f"{left:.2f} {top:.2f} {right - left:.2f} {bottom - top:.2f} re S"
581 )
582 # pylint: enable=invalid-unary-operand-type
584 if isinstance(border, str): 584 ↛ 585line 584 didn't jump to line 585 because the condition on line 584 was never true
585 if "L" in border:
586 sl.append(f"{left:.2f} {top:.2f} m {left:.2f} {bottom:.2f} l S")
587 if "T" in border:
588 sl.append(f"{left:.2f} {top:.2f} m {right:.2f} {top:.2f} l S")
589 if "R" in border:
590 sl.append(
591 f"{right:.2f} {top:.2f} m {right:.2f} {bottom:.2f} l S"
592 )
593 if "B" in border:
594 sl.append(
595 f"{left:.2f} {bottom:.2f} m {right:.2f} {bottom:.2f} l S"
596 )
598 if self._record_text_quad_points: 598 ↛ 599line 598 didn't jump to line 599 because the condition on line 598 was never true
599 self._add_quad_points(self.x, self.y, w, h)
601 s_start = self.x
602 s_width: float = 0
603 # We try to avoid modifying global settings for temporary changes.
604 current_ws = frag_ws = 0.0
605 current_lift = 0.0
606 current_char_vpos = CharVPos.LINE
607 current_font = self.current_font
608 current_font_size_pt = self.font_size_pt
609 current_font_style = self.font_style
610 current_text_mode = self.text_mode
611 current_font_stretching = self.font_stretching
612 current_char_spacing = self.char_spacing
613 fill_color_changed = False
614 last_used_color = self.fill_color
615 if fragments:
616 if text_line.align == Align.R: 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true
617 dx = w - l_c_margin - styled_txt_width
618 elif text_line.align in [Align.C, Align.X]: 618 ↛ 619line 618 didn't jump to line 619 because the condition on line 618 was never true
619 dx = (w - styled_txt_width) / 2
620 else:
621 dx = l_c_margin
622 s_start += dx
623 word_spacing: float = 0
624 if text_line.align == Align.J and text_line.number_of_spaces:
625 word_spacing = (
626 w - l_c_margin - r_c_margin - styled_txt_width
627 ) / text_line.number_of_spaces
628 sl.append(
629 f"BT {(self.x + dx) * k:.2f} "
630 f"{(self.h - self.y - 0.5 * h - 0.3 * max_font_size) * k:.2f} Td"
631 )
632 if ( 632 ↛ 641line 632 didn't jump to line 641 because the condition on line 632 was never true
633 not prevent_font_change
634 and not self.current_font_is_set_on_page
635 and self.current_font is not None
636 and fragments[0].font.fontkey in self._fallback_font_ids
637 and self.current_font.fontkey not in self._fallback_font_ids
638 ):
639 # The first fragment uses a fallback font. Establish the current font for
640 # the page in this text object to avoid promoting the fallback font.
641 sl.append(
642 self._set_font_for_page(
643 self.current_font,
644 self.font_size_pt,
645 wrap_in_text_object=False,
646 )
647 )
648 underlines: list[
649 tuple[
650 float,
651 float,
652 CoreFont | TTFFont,
653 float,
654 DeviceRGB | DeviceGray | DeviceCMYK | None,
655 ]
656 ] = []
657 strikethroughs: list[
658 tuple[
659 float,
660 float,
661 CoreFont | TTFFont,
662 float,
663 DeviceRGB | DeviceGray | DeviceCMYK | None,
664 ]
665 ] = []
666 for i, frag in enumerate(fragments):
667 if isinstance(frag, TotalPagesSubstitutionFragment): 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true
668 self.pages[self.page].add_text_substitution(frag)
669 if frag.text_color != last_used_color:
670 # allow to change color within the line of text.
671 last_used_color = frag.text_color
672 assert last_used_color is not None
673 sl.append(last_used_color.serialize().lower())
674 fill_color_changed = True
675 if word_spacing and frag.font_stretching != 100: 675 ↛ 677line 675 didn't jump to line 677 because the condition on line 675 was never true
676 # Space character is already stretched, extra spacing is absolute.
677 frag_ws = word_spacing * 100 / frag.font_stretching
678 else:
679 frag_ws = word_spacing
680 if current_font_stretching != frag.font_stretching: 680 ↛ 681line 680 didn't jump to line 681 because the condition on line 680 was never true
681 current_font_stretching = frag.font_stretching
682 sl.append(f"{frag.font_stretching:.2f} Tz")
683 if current_char_spacing != frag.char_spacing: 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true
684 current_char_spacing = frag.char_spacing
685 sl.append(f"{frag.char_spacing:.2f} Tc")
686 if not self.current_font_is_set_on_page:
687 if prevent_font_change:
688 # This is "local" to the current BT / ET context:
689 current_font = frag.font
690 current_font_size_pt = frag.font_size_pt
691 current_font_style = frag.font_style
692 sl.append(
693 f"/F{current_font.i} {current_font_size_pt:.2f} Tf"
694 )
695 self._resource_catalog.add(
696 PDFResourceType.FONT, current_font.i, self.page
697 )
698 current_char_vpos = frag.char_vpos
699 else:
700 # This is "global" to the page,
701 # as it is rendered in the content stream
702 # BEFORE the text_lines /fragments,
703 # wrapped into BT / ET operators:
704 current_font = self.current_font = frag.font
705 current_font_size_pt = self.font_size_pt = (
706 frag.font_size_pt
707 )
708 current_font_style = self.font_style = frag.font_style
709 self._out(
710 self._set_font_for_page(
711 current_font,
712 current_font_size_pt,
713 )
714 )
715 current_char_vpos = frag.char_vpos
716 elif ( 716 ↛ 723line 716 didn't jump to line 723 because the condition on line 716 was never true
717 current_font != frag.font
718 or current_font_size_pt != frag.font_size_pt
719 or current_font_style != frag.font_style
720 or current_char_vpos != frag.char_vpos
721 ):
722 # This is "local" to the current BT / ET context:
723 current_font = frag.font
724 current_font_size_pt = frag.font_size_pt
725 current_font_style = frag.font_style
726 sl.append(
727 self._set_font_for_page(
728 current_font,
729 current_font_size_pt,
730 wrap_in_text_object=False,
731 )
732 )
733 current_char_vpos = frag.char_vpos
734 lift = frag.lift
735 if lift != current_lift: 735 ↛ 737line 735 didn't jump to line 737 because the condition on line 735 was never true
736 # Use text rise operator:
737 sl.append(f"{lift:.2f} Ts")
738 current_lift = lift
739 if ( 739 ↛ 743line 739 didn't jump to line 743 because the condition on line 739 was never true
740 frag.text_mode != TextMode.FILL
741 or frag.text_mode != current_text_mode
742 ):
743 current_text_mode = frag.text_mode
744 sl.append(f"{frag.text_mode} Tr {frag.line_width:.2f} w")
746 r_text = frag.render_pdf_text(
747 frag_ws,
748 current_ws,
749 word_spacing,
750 self.x + dx + s_width,
751 self.y + (0.5 * h + 0.3 * max_font_size),
752 self.h,
753 )
754 if r_text: 754 ↛ 757line 754 didn't jump to line 757 because the condition on line 754 was always true
755 sl.append(r_text)
757 frag_width = frag.get_width(
758 initial_cs=i != 0
759 ) + word_spacing * frag.characters.count(" ")
760 if frag.underline:
761 underlines.append(
762 (
763 self.x + dx + s_width,
764 frag_width,
765 frag.font,
766 frag.font_size,
767 frag.text_color,
768 )
769 )
770 if frag.strikethrough:
771 strikethroughs.append(
772 (
773 self.x + dx + s_width,
774 frag_width,
775 frag.font,
776 frag.font_size,
777 frag.text_color,
778 )
779 )
780 if frag.link:
781 self.link(
782 x=self.x + dx + s_width,
783 y=self.y + (0.5 * h) - (0.5 * frag.font_size),
784 w=frag_width,
785 h=frag.font_size,
786 link=frag.link,
787 )
788 if not frag.is_ttf_font: 788 ↛ 790line 788 didn't jump to line 790 because the condition on line 788 was always true
789 current_ws = frag_ws
790 s_width += frag_width
792 sl.append("ET")
794 # Underlines & strikethrough must be rendred OUTSIDE BT/ET contexts,
795 # cf. https://github.com/py-pdf/fpdf2/issues/1456
796 if underlines:
797 for start_x, width, font, font_size, text_color in underlines:
798 # Change color of the underlines
799 if text_color != last_used_color:
800 last_used_color = text_color
801 assert last_used_color is not None
802 sl.append(last_used_color.serialize().lower())
803 fill_color_changed = True
804 sl.append(
805 self._do_underline(
806 start_x,
807 self.y + (0.5 * h) + (0.3 * font_size),
808 width,
809 font,
810 )
811 )
812 if strikethroughs:
813 for (
814 start_x,
815 width,
816 font,
817 font_size,
818 text_color,
819 ) in strikethroughs:
820 # Change color of the strikethroughs
821 if text_color != last_used_color:
822 last_used_color = text_color
823 assert last_used_color is not None
824 sl.append(last_used_color.serialize().lower())
825 fill_color_changed = True
826 sl.append(
827 self._do_strikethrough(
828 start_x,
829 self.y + (0.5 * h) + (0.3 * font_size),
830 width,
831 font,
832 )
833 )
834 if link: 834 ↛ 835line 834 didn't jump to line 835 because the condition on line 834 was never true
835 self.link(
836 self.x + dx,
837 self.y
838 + (0.5 * h)
839 - (
840 0.5 * frag.font_size # pyright: ignore[reportPossiblyUnboundVariable]
841 ),
842 styled_txt_width,
843 frag.font_size, # pyright: ignore[reportPossiblyUnboundVariable]
844 link,
845 )
847 if sl:
848 # If any PDF settings have been left modified, wrap the line
849 # in a local context.
850 # pylint: disable=too-many-boolean-expressions
851 if (
852 current_ws != 0.0
853 or current_lift != 0.0
854 or current_char_vpos != CharVPos.LINE
855 or current_font != self.current_font
856 or current_font_size_pt != self.font_size_pt
857 or current_font_style != self.font_style
858 or current_text_mode != self.text_mode
859 or fill_color_changed
860 or current_font_stretching != self.font_stretching
861 or current_char_spacing != self.char_spacing
862 ):
863 s = f"q {' '.join(sl)} Q"
864 else:
865 s = " ".join(sl)
866 # pylint: enable=too-many-boolean-expressions
867 self._out(s)
868 # If the text is empty, h = max_font_size ends up as 0.
869 # We still need a valid default height for self.ln() (issue #601).
870 self._lasth = h or self.font_size
872 # XPos.LEFT -> self.x stays the same
873 if new_x == XPos.RIGHT:
874 self.x += w
875 elif new_x == XPos.START: 875 ↛ 876line 875 didn't jump to line 876 because the condition on line 875 was never true
876 self.x = s_start
877 elif new_x == XPos.END: 877 ↛ 878line 877 didn't jump to line 878 because the condition on line 877 was never true
878 self.x = s_start + s_width
879 elif new_x == XPos.WCONT: 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true
880 if s_width:
881 self.x = s_start + s_width - r_c_margin
882 else:
883 self.x = s_start
884 elif new_x == XPos.CENTER: 884 ↛ 885line 884 didn't jump to line 885 because the condition on line 884 was never true
885 self.x = s_start + s_width / 2.0
886 elif new_x == XPos.LMARGIN:
887 self.x = self.l_margin
888 elif new_x == XPos.RMARGIN: 888 ↛ 889line 888 didn't jump to line 889 because the condition on line 888 was never true
889 self.x = self.w - self.r_margin
891 # YPos.TOP: -> self.y stays the same
892 # YPos.LAST: -> self.y stays the same (single line)
893 if new_y == YPos.NEXT: 893 ↛ 895line 893 didn't jump to line 895 because the condition on line 893 was always true
894 self.y += h
895 if new_y == YPos.TMARGIN: 895 ↛ 896line 895 didn't jump to line 896 because the condition on line 895 was never true
896 self.y = self.t_margin
897 if new_y == YPos.BMARGIN: 897 ↛ 898line 897 didn't jump to line 898 because the condition on line 897 was never true
898 self.y = self.h - self.b_margin
900 return page_break_triggered
902 @check_page
903 def insert_toc_placeholder(
904 self,
905 render_toc_function: Callable[[fpdf.FPDF, list[OutlineSection]], None],
906 pages: int = 1,
907 allow_extra_pages: bool = False,
908 reset_page_indices: bool = True,
909 ) -> None:
910 if pages < 1:
911 raise ValueError(
912 f"'pages' parameter must be equal or greater than 1: {pages}"
913 )
914 if not callable(render_toc_function):
915 raise TypeError(
916 f"The first argument must be a callable, got: {type(render_toc_function)}"
917 )
918 if self.toc_placeholder:
919 raise FPDFException(
920 "A placeholder for the table of contents has already been defined"
921 f" on page {self.toc_placeholder.start_page}"
922 )
923 self.toc_placeholder = ToCPlaceholder(
924 render_toc_function,
925 self.page,
926 self.y,
927 self.cur_orientation,
928 pages,
929 reset_page_indices,
930 )
931 self._toc_allow_page_insertion = allow_extra_pages
932 for _ in range(pages):
933 self._perform_page_break()
935 @check_page
936 @support_deprecated_txt_arg
937 def multi_cell(
938 self,
939 w: float,
940 h: Optional[float] = None,
941 text: str = "",
942 border: Literal[0, 1] | str = 0,
943 align: str | Align = Align.J,
944 fill: bool = False,
945 split_only: bool = False, # DEPRECATED
946 link: Optional[int | str] = None,
947 ln: Literal["DEPRECATED"] = "DEPRECATED",
948 max_line_height: Optional[float] = None,
949 markdown: bool = False,
950 print_sh: bool = False,
951 new_x: str | XPos = XPos.RIGHT,
952 new_y: str | YPos = YPos.NEXT,
953 wrapmode: WrapMode = WrapMode.WORD,
954 dry_run: bool = False,
955 output: str | MethodReturnValue = MethodReturnValue.PAGE_BREAK,
956 center: bool = False,
957 padding: int | Sequence[int] | Padding = 0,
958 ) -> fpdf.fpdf.FPDF.MultiCellResult:
959 padding = Padding.new(padding)
960 wrapmode = WrapMode.coerce(wrapmode)
962 if split_only:
963 warnings.warn(
964 (
965 'The parameter "split_only" is deprecated since v2.7.4.'
966 ' Use instead dry_run=True and output="LINES".'
967 ),
968 DeprecationWarning,
969 stacklevel=get_stack_level(),
970 )
971 if dry_run or split_only:
972 with self._disable_writing():
973 return self.multi_cell(
974 w=w,
975 h=h,
976 text=text,
977 border=border,
978 align=align,
979 fill=fill,
980 link=link,
981 ln=ln,
982 max_line_height=max_line_height,
983 markdown=markdown,
984 print_sh=print_sh,
985 new_x=new_x,
986 new_y=new_y,
987 wrapmode=wrapmode,
988 dry_run=False,
989 split_only=False,
990 output=MethodReturnValue.LINES if split_only else output,
991 center=center,
992 padding=padding,
993 )
994 if not self.font_family:
995 raise FPDFException(
996 "No font set, you need to call set_font() beforehand"
997 )
998 if isinstance(w, str) or isinstance(h, str):
999 raise ValueError(
1000 "Parameter 'w' and 'h' must be numbers, not strings."
1001 " You can omit them by passing string content with text="
1002 )
1003 new_x = XPos.coerce(new_x)
1004 new_y = YPos.coerce(new_y)
1005 if ln != "DEPRECATED":
1006 # For backwards compatibility, if "ln" is used we overwrite "new_[xy]".
1007 if ln == 0:
1008 new_x = XPos.RIGHT
1009 new_y = YPos.NEXT
1010 elif ln == 1:
1011 new_x = XPos.LMARGIN
1012 new_y = YPos.NEXT
1013 elif ln == 2:
1014 new_x = XPos.LEFT
1015 new_y = YPos.NEXT
1016 elif ln == 3:
1017 new_x = XPos.RIGHT
1018 new_y = YPos.TOP
1019 else:
1020 raise ValueError(
1021 f'Invalid value for parameter "ln" ({ln}),'
1022 " must be an int between 0 and 3."
1023 )
1024 warnings.warn(
1025 (
1026 'The parameter "ln" is deprecated since v2.5.2.'
1027 f" Instead of ln={ln} use new_x=XPos.{new_x.name}, new_y=YPos.{new_y.name}."
1028 ),
1029 DeprecationWarning,
1030 stacklevel=get_stack_level(),
1031 )
1032 align = Align.coerce(align)
1034 page_break_triggered = False
1036 if h is None:
1037 h = self.font_size
1039 # If width is 0, set width to available width between margins
1040 if w == 0:
1041 w = self.w - self.r_margin - self.x
1043 # Store the starting position before applying padding
1044 prev_x, prev_y = self.x, self.y
1046 # Apply padding to contents
1047 # decrease maximum allowed width by padding
1048 # shift the starting point by padding
1049 maximum_allowed_width = w = w - padding.right - padding.left
1050 clearance_margins: list[float] = []
1051 # If we don't have padding on either side, we need a clearance margin.
1052 if not padding.left:
1053 clearance_margins.append(self.c_margin)
1054 if not padding.right:
1055 clearance_margins.append(self.c_margin)
1056 if align != Align.X:
1057 self.x += padding.left
1058 self.y += padding.top
1060 # Center overrides padding
1061 if center:
1062 self.x = (
1063 self.w / 2
1064 if align == Align.X
1065 else self.l_margin + (self.epw - w) / 2
1066 )
1067 prev_x = self.x
1069 # Calculate text length
1070 text = self.normalize_text(text)
1071 normalized_string = text.replace("\r", "")
1072 styled_text_fragments = (
1073 self._preload_bidirectional_text(normalized_string, markdown)
1074 if self.text_shaping
1075 else self._preload_font_styles(normalized_string, markdown)
1076 )
1078 prev_current_font = self.current_font
1079 prev_font_style = self.font_style
1080 prev_underline = self.underline
1081 total_height: float = 0
1083 text_lines: list[TextLine] = []
1084 multi_line_break = MultiLineBreak(
1085 styled_text_fragments,
1086 maximum_allowed_width,
1087 clearance_margins,
1088 align=align,
1089 print_sh=print_sh,
1090 wrapmode=wrapmode,
1091 )
1092 text_line = multi_line_break.get_line()
1093 while (text_line) is not None:
1094 text_lines.append(text_line)
1095 text_line = multi_line_break.get_line()
1097 if (
1098 not text_lines
1099 ): # ensure we display at least one cell - cf. issue #349
1100 text_lines = [
1101 TextLine(
1102 [],
1103 text_width=0,
1104 number_of_spaces=0,
1105 align=align,
1106 height=h,
1107 max_width=w,
1108 trailing_nl=False,
1109 )
1110 ]
1112 if max_line_height is None or len(text_lines) == 1:
1113 line_height = h
1114 else:
1115 line_height = min(h, max_line_height)
1117 box_required = fill or border
1118 page_break_triggered = False
1120 for text_line_index, text_line in enumerate(text_lines):
1121 start_of_new_page = self._perform_page_break_if_need_be(
1122 h + padding.bottom
1123 )
1124 if start_of_new_page:
1125 page_break_triggered = True
1126 self.y += padding.top
1128 if box_required and (text_line_index == 0 or start_of_new_page):
1129 # estimate how many cells can fit on this page
1130 top_gap = self.y # Top padding has already been added
1131 bottom_gap = padding.bottom + self.b_margin
1132 lines_before_break = int(
1133 (self.h - top_gap - bottom_gap) // line_height
1134 )
1135 # check how many cells should be rendered
1136 num_lines = min(
1137 lines_before_break, len(text_lines) - text_line_index
1138 )
1139 box_height = max(
1140 h - text_line_index * line_height, num_lines * line_height
1141 )
1142 # render the box
1143 x = self.x - (w / 2 if align == Align.X else 0)
1144 draw_box_borders(
1145 self,
1146 x - padding.left,
1147 self.y - padding.top,
1148 x + w + padding.right,
1149 self.y + box_height + padding.bottom,
1150 border,
1151 self.fill_color if fill else None,
1152 )
1153 is_last_line = text_line_index == len(text_lines) - 1
1154 self._render_styled_text_line(
1155 text_line,
1156 h=line_height,
1157 new_x=new_x if is_last_line else XPos.LEFT,
1158 new_y=new_y if is_last_line else YPos.NEXT,
1159 border=0, # already rendered
1160 fill=False, # already rendered
1161 link=link,
1162 padding=Padding(0, padding.right, 0, padding.left),
1163 prevent_font_change=markdown,
1164 )
1165 total_height += line_height
1166 if not is_last_line and align == Align.X:
1167 # prevent cumulative shift to the left
1168 self.x = prev_x
1170 if total_height < h:
1171 # Move to the bottom of the multi_cell
1172 if new_y == YPos.NEXT:
1173 self.y += h - total_height
1174 total_height = h
1176 if page_break_triggered and new_y == YPos.TOP:
1177 # When a page jump is performed and the requested y is TOP,
1178 # pretend we started at the top of the text block on the new page.
1179 # cf. test_multi_cell_table_with_automatic_page_break
1180 prev_y = self.y
1182 last_line = text_lines[-1]
1183 if (
1184 last_line
1185 and last_line.trailing_nl
1186 and new_y in (YPos.LAST, YPos.NEXT)
1187 ):
1188 # The line renderer can't handle trailing newlines in the text.
1189 self.ln()
1191 if new_y == YPos.TOP: # We may have jumped a few lines -> reset
1192 self.y = prev_y
1193 elif new_y == YPos.NEXT: # move down by bottom padding
1194 self.y += padding.bottom
1196 if markdown:
1197 self.font_style = prev_font_style
1198 self.current_font = prev_current_font
1199 self.underline = prev_underline
1201 if (
1202 new_x == XPos.RIGHT
1203 ): # move right by right padding to align outer RHS edge
1204 self.x += padding.right
1205 elif (
1206 new_x == XPos.LEFT
1207 ): # move left by left padding to align outer LHS edge
1208 self.x -= padding.left
1210 output = MethodReturnValue.coerce(output)
1211 return_value = ()
1212 if output & MethodReturnValue.PAGE_BREAK:
1213 return_value += (page_break_triggered,) # type: ignore[assignment]
1214 if output & MethodReturnValue.LINES:
1215 # Bugfix: https://github.com/py-pdf/fpdf2/issues/1840, multi-cell-md-result
1216 output_lines = self._join_text_lines(text_lines, markdown=markdown)
1217 return_value += (output_lines,) # type: ignore[assignment]
1218 if output & MethodReturnValue.HEIGHT:
1219 return_value += (total_height + padding.top + padding.bottom,) # type: ignore[assignment]
1220 if len(return_value) == 1:
1221 return return_value[0]
1222 return return_value # type: ignore[return-value]
1225# Monkey-patch
1226fpdf.fpdf.FPDF = FPDF # type: ignore[misc]
1227fpdf.FPDF = FPDF # type: ignore[misc]