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

1"""FPDF-Support for Text Index.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable, Sequence 

6import os 

7import pathlib 

8from typing import BinaryIO, Literal, NamedTuple, TYPE_CHECKING, overload 

9import warnings 

10 

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 

35 

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 

42 

43if TYPE_CHECKING: 

44 from fpdf.enums import DocumentCompliance 

45 from fpdf.graphics_state import StateStackType 

46 from fpdf.line_break import Fragment 

47 

48 from fpdf2_textindex.interface import LabelPathT 

49 

50 

51class IndexPlaceholder(NamedTuple): 

52 """Index Placeholder.""" 

53 

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 

60 

61 

62class FPDF(fpdf.FPDF): 

63 """PDF Generation Class.""" 

64 

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 

73 

74 CONCORDANCE_FILE: os.PathLike[str] | str | None = None 

75 """The path to a concordance file. Defaults to `None`.""" 

76 

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 """ 

83 

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`. 

94 

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``.""" 

129 

130 def _set_index_link_locations(self) -> None: 

131 link_locations = {} 

132 

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 

137 

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 ) 

158 

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] 

167 

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 

202 

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. 

209 

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. 

215 

216 Args: 

217 text: The text to parse the markdown of. 

218 markdown: Whether markdown is enabled. 

219 

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) 

249 

250 @property 

251 def index_entries(self) -> list[TextIndexEntry]: 

252 """The (so far parsed) index entries.""" 

253 return self._index_parser.entries.copy() 

254 

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. 

261 

262 Note: References (locators) to pages cannot be added manually, only 

263 cross references. 

264 

265 Args: 

266 label_path: The label path of the entry. 

267 sort_key: The sort key of the entry. Defaults to `None`. 

268 

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 

277 

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. 

283 

284 Args: 

285 label_path: The label path. 

286 

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] 

292 

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. 

305 

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`. 

324 

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() 

360 

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. 

387 

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. 

396 

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. 

401 

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`. 

470 

471 Returns: 

472 A single value or a tuple, depending on the `output` parameter 

473 value. 

474 

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) 

481 

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) 

557 

558 page_break_triggered = False 

559 

560 if h is None: 

561 h = self.font_size 

562 

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 

566 

567 # Store the starting position before applying padding 

568 prev_x, prev_y = self.x, self.y 

569 

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 

583 

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 

592 

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 ) 

601 

602 prev_current_font = self.current_font 

603 prev_font_style = self.font_style 

604 prev_underline = self.underline 

605 total_height: float = 0 

606 

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() 

622 

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 ] 

637 

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) 

642 

643 box_required = fill or border 

644 page_break_triggered = False 

645 

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 

657 

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 

705 

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 

711 

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 

717 

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() 

726 

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 

731 

732 if markdown: 

733 self.font_style = prev_font_style 

734 self.current_font = prev_current_font 

735 self.underline = prev_underline 

736 

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 

745 

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] 

758 

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: ... 

767 

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: ... 

776 

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. 

785 

786 By default the bytearray buffer is returned. 

787 If a `name` is given, the PDF is written to a new file. 

788 

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`. 

797 

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. 

802 

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