Coverage for fpdf2_textindex / pdf.py: 74.30%

334 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-24 15:45 +0000

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

2 

3from collections import defaultdict 

4from collections.abc import Callable, Iterable, Sequence 

5import os 

6import pathlib 

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

8import warnings 

9 

10import fpdf 

11from fpdf.enums import Align 

12from fpdf.enums import DocumentCompliance 

13from fpdf.enums import MethodReturnValue 

14from fpdf.enums import OutputIntentSubType 

15from fpdf.enums import PDFResourceType 

16from fpdf.enums import PageOrientation 

17from fpdf.enums import WrapMode 

18from fpdf.enums import XPos 

19from fpdf.enums import YPos 

20from fpdf.fonts import TTFFont 

21from fpdf.line_break import Fragment 

22from fpdf.line_break import MultiLineBreak 

23from fpdf.line_break import TextLine 

24from fpdf.linearization import LinearizedOutputProducer 

25from fpdf.output import OutputProducer 

26from fpdf.output import PDFICCProfile 

27from fpdf.output import ResourceTypes 

28from fpdf.table import draw_box_borders 

29from fpdf.unicode_script import get_unicode_script 

30from fpdf.util import Padding 

31from fpdf.util import builtin_srgb2014_bytes 

32 

33from fpdf2_textindex import constants as const 

34from fpdf2_textindex.concordance import ConcordanceList 

35from fpdf2_textindex.interface import LinkLocation 

36from fpdf2_textindex.interface import TextIndexEntry 

37from fpdf2_textindex.parser import TextIndexParser 

38 

39 

40class IndexPlaceholder(NamedTuple): 

41 """Index Placeholder.""" 

42 

43 render_function: Callable[["FPDF", list["TextIndexEntry"]], None] 

44 start_page: int 

45 y: float 

46 page_orientation: str | PageOrientation 

47 pages: int = 1 

48 reset_page_indices: bool = True 

49 

50 

51class FPDF(fpdf.FPDF): 

52 """PDF Generation Class.""" 

53 

54 if TYPE_CHECKING: 

55 _index_allow_page_insertion: bool 

56 _index_links: dict[str, int] 

57 _index_parser: TextIndexParser 

58 index_placeholder: IndexPlaceholder | None 

59 

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

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

62 

63 STRICT_INDEX_MODE: bool = True 

64 """If `True` and an entry has a normal reference (locator) and a SEE-cross 

65 reference, a `ValueError` will be raised. Else, it will just be a warning. 

66 Defaults to `True`. 

67 """ 

68 

69 def __init__( 

70 self, 

71 orientation: PageOrientation | str = PageOrientation.PORTRAIT, 

72 unit: str | float = "mm", 

73 format: str | tuple[float, float] = "A4", 

74 font_cache_dir: Literal["DEPRECATED"] = "DEPRECATED", 

75 *, 

76 enforce_compliance: DocumentCompliance | str | None = None, 

77 ) -> None: 

78 """Initializes the :py:class:`FPDF`. 

79 

80 Args: 

81 orientation: Page orientation. Possible values are `"portrait"` (can 

82 be abbreviated `"P"`) or `"landscape"` (can be abbreviated 

83 `"L"`). Defaults to `"portrait"`. 

84 unit: Possible values are `"pt"`, `"mm"`, `"cm"`, `"in"`, or a 

85 number. A point equals 1/72 of an inch, that is to say about 

86 0.35 mm (an inch being 2.54 cm). This is a very common unit in 

87 typography; font sizes are expressed in this unit. 

88 If given a number, then it will be treated as the number of 

89 points per unit (eg. 72 = 1 in). Default to `"mm"`. 

90 format: Page format. Possible values are `"a3"`, `"a4"`, `"a5"`, 

91 `"letter"`, `"legal"` or a tuple `(width, height)` expressed in 

92 the given unit. Default to `"a4"`. 

93 font_cache_dir: [**DEPRECATED since v2.5.1**] unused. 

94 enforce_compliance: When enforce compliance is set, :py:class:`FPDF` 

95 actively prevents non-compliant operations and will raise errors 

96 if you try something forbidden for the selected profile. 

97 Defaults to `None`. 

98 """ 

99 super().__init__( 

100 orientation=orientation, 

101 unit=unit, 

102 format=format, 

103 font_cache_dir=font_cache_dir, 

104 enforce_compliance=enforce_compliance, 

105 ) 

106 self._concordance_list = None 

107 if self.CONCORDANCE_FILE is not None: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 self._concordance_list = ConcordanceList.from_file( 

109 self.CONCORDANCE_FILE 

110 ) 

111 self._index_allow_page_insertion = False 

112 self._index_links = {} 

113 self._index_parser = TextIndexParser(strict=self.STRICT_INDEX_MODE) 

114 self.index_placeholder: IndexPlaceholder | None = None 

115 """Index placeholder. Defaults to ``None``.""" 

116 

117 def _set_index_link_locations(self) -> None: 

118 link_locations = {} 

119 

120 # Collect index locations 

121 for page_num, pdf_page in self.pages.items(): 

122 if pdf_page.annots is None: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 continue 

124 

125 h_page = pdf_page.dimensions()[1] / self.k 

126 for a in pdf_page.annots: 

127 link_name = str(a.dest) 

128 if not ( 128 ↛ 132line 128 didn't jump to line 132 because the condition on line 128 was never true

129 link_name.startswith(const.INDEX_ID_PREFIX) 

130 or link_name.startswith(const.ENTRY_ID_PREFIX) 

131 ): 

132 continue 

133 assert a.rect.startswith("[") and a.rect.endswith("]"), a.rect 

134 x, y_h, x_w, y = map( 

135 lambda x: float(x) / self.k, 

136 a.rect[1:-1].split(" ", maxsplit=3), 

137 ) 

138 w = x_w - x 

139 h = y - y_h 

140 y = h_page - y 

141 link_locations[link_name] = LinkLocation( 

142 page=page_num, x=x, y=y, w=w, h=h 

143 ) 

144 

145 # Add link locations to entries 

146 for entry in self._index_parser.entries: 

147 for ref in entry.references: 

148 ref.start_location = link_locations[ref.start_link] 

149 if ref.end_link: 

150 ref.end_location = link_locations[ref.end_link] 

151 for cross_ref in entry.cross_references: 

152 cross_ref.location = link_locations[cross_ref.link] 

153 

154 def _insert_index(self) -> None: 

155 # NOTE: Text Index reuses functionality of ToC 

156 

157 # Collect links locations and add them to entries 

158 self._set_index_link_locations() 

159 

160 # Doc has been closed but we want to write to self.pages[self.page] 

161 # instead of self.buffer: 

162 indexp = self.index_placeholder 

163 assert indexp is not None 

164 prev_page, prev_y = self.page, self.y 

165 prev_toc_placeholder = self.toc_placeholder 

166 prev_toc_allow_page_insertion = self._toc_allow_page_insertion 

167 

168 self.page, self.y = indexp.start_page, indexp.y 

169 self.toc_placeholder = fpdf.fpdf.ToCPlaceholder( 

170 lambda pdf, outlines: None, 

171 indexp.start_page, 

172 indexp.y, 

173 indexp.page_orientation, 

174 pages=indexp.pages, 

175 reset_page_indices=indexp.reset_page_indices, 

176 ) 

177 self._toc_allow_page_insertion = self._index_allow_page_insertion 

178 # flag rendering ToC for page breaking function 

179 self.in_toc_rendering = True 

180 # Reset toc inserted counter to 0 

181 self._toc_inserted_pages = 0 

182 self._set_orientation(indexp.page_orientation, self.dw_pt, self.dh_pt) 

183 indexp.render_function(self, self._index_parser.entries) 

184 self.in_toc_rendering = False # set ToC rendering flag off 

185 expected_final_page = indexp.start_page + indexp.pages - 1 

186 if ( 186 ↛ 190line 186 didn't jump to line 190 because the condition on line 186 was never true

187 self.page != expected_final_page 

188 and not self._index_allow_page_insertion 

189 ): 

190 too = "many" if self.page > expected_final_page else "few" 

191 error_msg = ( 

192 f"The rendering function passed to " 

193 f"'FPDF.insert_index_placeholder' triggered too {too:s} page " 

194 f"breaks: ToC ended on page {self.page:d} while it was " 

195 f"expected to span exactly {indexp.pages:d} pages" 

196 ) 

197 raise fpdf.errors.FPDFException(error_msg) 

198 if self._toc_inserted_pages: 

199 # Generating final page footer after more pages were inserted: 

200 self._render_footer() 

201 # We need to reorder the pages, because some new pages have been 

202 # inserted in the Index, but they have been inserted at the end of 

203 # self.pages: 

204 new_pages = [ 

205 self.pages.pop(len(self.pages)) 

206 for _ in range(self._toc_inserted_pages) 

207 ] 

208 new_pages = list(reversed(new_pages)) 

209 indices_remap: dict[int, int] = {} 

210 for page_index in range( 

211 indexp.start_page + 1, self.pages_count + len(new_pages) + 1 

212 ): 

213 if page_index in self.pages: 

214 new_pages.append(self.pages.pop(page_index)) 

215 page = self.pages[page_index] = new_pages.pop(0) 

216 # Fix page indices: 

217 indices_remap[page.index()] = page_index 

218 page.set_index(page_index) 

219 # Fix page labels: 

220 if indexp.reset_page_indices is False: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 page.get_page_label().st = page_index # type: ignore[union-attr] 

222 assert len(new_pages) == 0, f"#new_pages: {len(new_pages)}" 

223 # Fix links: 

224 for dest in self.links.values(): 

225 assert dest.page_number is not None 

226 new_index = indices_remap.get(dest.page_number) 

227 if new_index is not None: 

228 dest.page_number = new_index 

229 # Fix outline: 

230 for section in self._outline: 230 ↛ 231line 230 didn't jump to line 231 because the loop on line 230 never started

231 new_index = indices_remap.get(section.page_number) 

232 if new_index is not None: 

233 section.dest = section.dest.replace(page=new_index) 

234 section.page_number = new_index 

235 if section.struct_elem: 

236 # pylint: disable=protected-access 

237 section.struct_elem._page_number = ( # pyright: ignore[reportPrivateUsage] 

238 new_index 

239 ) 

240 # Fix resource catalog: 

241 resources_per_page = self._resource_catalog.resources_per_page 

242 new_resources_per_page: dict[ 

243 tuple[int, PDFResourceType], set[ResourceTypes] 

244 ] = defaultdict(set) 

245 for ( 

246 page_number, 

247 resource_type, 

248 ), resource in resources_per_page.items(): 

249 key = ( 

250 indices_remap.get(page_number, page_number), 

251 resource_type, 

252 ) 

253 new_resources_per_page[key] = resource 

254 self._resource_catalog.resources_per_page = new_resources_per_page 

255 

256 self._toc_allow_page_insertion = prev_toc_allow_page_insertion 

257 self._toc_inserted_pages = 0 

258 self.toc_placeholder = prev_toc_placeholder 

259 self.page, self.y = prev_page, prev_y 

260 

261 def _preload_font_styles( 

262 self, 

263 text: str | None, 

264 markdown: bool, 

265 ) -> Sequence[Fragment]: 

266 """Preloads the font styles by markdown parsing. 

267 

268 When Markdown styling is enabled, we require secondary fonts to 

269 render text in bold & italics. This function ensure that those fonts are 

270 available. It needs to perform Markdown parsing, so we return the 

271 resulting `styled_txt_frags` tuple to avoid repeating this processing 

272 later on. 

273 

274 Args: 

275 text: The text to parse the markdown of. 

276 markdown: Whether markdown is enabled. 

277 

278 Returns: 

279 The preloaded text fragments. 

280 """ 

281 if not self.in_toc_rendering and text and markdown: 

282 # Load concordance list if not done in init 

283 if ( 283 ↛ 287line 283 didn't jump to line 287 because the condition on line 283 was never true

284 self.CONCORDANCE_FILE is not None 

285 and self._concordance_list is None 

286 ): 

287 self._concordance_list = ConcordanceList.from_file( 

288 self.CONCORDANCE_FILE 

289 ) 

290 # Replace concordance entries by entry annotations 

291 if self._concordance_list: 291 ↛ 292line 291 didn't jump to line 292 because the condition on line 291 was never true

292 text = self._concordance_list.parse_text(text) 

293 # Replace entry annotations by markdown link 

294 first_id = self._index_parser.last_directive_id + 1 

295 text = self._index_parser.parse_text(text) 

296 last_id = self._index_parser.last_directive_id + 1 

297 # Reserve the links (named destinations) 

298 for text_to_index_id in range(first_id, last_id): 

299 link_name = f"{const.INDEX_ID_PREFIX:s}{text_to_index_id:d}" 

300 link_idx = self.add_link(name=link_name) 

301 self._index_links[link_name] = link_idx 

302 return super()._preload_font_styles(text, markdown) 

303 

304 @property 

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

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

307 return self._index_parser.entries 

308 

309 def add_index_entry( 

310 self, 

311 label_path: Iterable[str], 

312 sort_key: str | None = None, 

313 ) -> TextIndexEntry: 

314 """Adds manually a text index entry. 

315 

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

317 cross references. 

318 

319 Args: 

320 label_path: The label path of the entry. 

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

322 

323 Returns: 

324 The text index entry. 

325 """ 

326 entry = self._index_parser.entry_at_label_path(label_path, create=True) 

327 assert isinstance(entry, TextIndexEntry) 

328 entry.sort_key = sort_key 

329 return entry 

330 

331 @fpdf.fpdf.check_page 

332 def insert_index_placeholder( 

333 self, 

334 render_index_function: Callable[["FPDF", list[TextIndexEntry]], None], 

335 *, 

336 pages: int = 1, 

337 allow_extra_pages: bool = False, 

338 reset_page_indices: bool = True, 

339 ) -> None: 

340 """Configures Text Index rendering at the end of the document 

341 generation, and reserves some vertical space right now in order to 

342 insert it. At least one page break is triggered by this method. 

343 

344 Args: 

345 render_index_function: A function that will be invoked to render 

346 the Index. This function will receive 2 parameters: 

347 `pdf`: an instance of :py:class:`fpdf2_textindex.pdf.FPDF`; 

348 `entries`: a list of 

349 :py:class:`fpdf2_textindex.interface.TextIndexEntry`s. 

350 pages: The number of pages that the Index will span, including the 

351 current one. As many page breaks as the value of this argument 

352 will occur immediately after calling this method. Defaults to 

353 `1`. 

354 allow_extra_pages: If set to `True`, allows for an unlimited 

355 number of extra pages in the Text Index, which may cause 

356 discrepancies with pre-rendered page numbers. 

357 For consistent numbering, using page labels to create a separate 

358 numbering style for the Index is recommended. Defaults to 

359 `False`. 

360 reset_page_indices : Whether to reset the pages indices after the 

361 Text Index. Defaults to `True`. 

362 

363 Raises: 

364 FPDFException: If an index placeholder has been inserted before. 

365 TypeError: If `render_index_function` is not callable. 

366 ValueError: If ``pages`` is less than `1`. 

367 """ 

368 if not callable(render_index_function): 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true

369 msg = ( 

370 f"The first argument must be a callable, got: " 

371 f"{type(render_index_function)!s:s}" 

372 ) 

373 raise TypeError(msg) 

374 if pages < 1: 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true

375 msg = ( 

376 f"'pages' parameter must be equal or greater than 1: {pages:d}" 

377 ) 

378 raise ValueError(msg) 

379 if self.index_placeholder: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 msg = ( 

381 "A placeholder for the index has already been defined on page " 

382 f"{self.index_placeholder.start_page}" 

383 ) 

384 raise fpdf.errors.FPDFException(msg) 

385 self.index_placeholder = IndexPlaceholder( 

386 render_index_function, 

387 self.page, 

388 self.y, 

389 self.cur_orientation, 

390 pages, 

391 reset_page_indices, 

392 ) 

393 self._index_allow_page_insertion = allow_extra_pages 

394 for _ in range(pages): 

395 self._perform_page_break() 

396 

397 @fpdf.fpdf.check_page 

398 @fpdf.deprecation.support_deprecated_txt_arg 

399 def multi_cell( 

400 self, 

401 w: float, 

402 h: float | None = None, 

403 text: str = "", 

404 border: Literal[0, 1] | str = 0, 

405 align: Align | str = Align.J, 

406 fill: bool = False, 

407 split_only: bool = False, # DEPRECATED 

408 link: int | str | None = None, 

409 ln: Literal["DEPRECATED"] = "DEPRECATED", 

410 max_line_height: float | None = None, 

411 markdown: bool = False, 

412 print_sh: bool = False, 

413 new_x: XPos | str = XPos.RIGHT, 

414 new_y: YPos | str = YPos.NEXT, 

415 wrapmode: WrapMode = WrapMode.WORD, 

416 dry_run: bool = False, 

417 output: MethodReturnValue | str = MethodReturnValue.PAGE_BREAK, 

418 center: bool = False, 

419 padding: Padding | Sequence[int] | int = 0, 

420 first_line_indent: float = 0, 

421 ) -> fpdf.FPDF.MultiCellResult: 

422 r"""This method allows printing text with line breaks. 

423 

424 They can be automatic (breaking at the most recent space or soft-hyphen 

425 character) as soon as the text reaches the right border of the cell, or 

426 explicit (via the `"\\n"` character). As many cells as necessary are 

427 stacked, one below the other. Text can be aligned, centered or 

428 justified. The cell block can be framed and the background painted. A 

429 cell has an horizontal padding, on the left & right sides, defined by 

430 the 

431 [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html)-property. 

432 

433 Note: 

434 Using 

435 `new_x=XPos.RIGHT, new_y=XPos.TOP, maximum height=pdf.font_size` 

436 is useful to build tables with multiline text in cells. 

437 

438 Args: 

439 w: Cell width. If `0`, they extend up to the right margin of the 

440 page. 

441 h: Height of a single line of text. Defaults to `None`, meaning to 

442 use the current font size. 

443 text: Text to print. 

444 border: Indicates if borders must be drawn around the cell. 

445 The value can be either a number (`0`: no border; `1`: 

446 frame) or a string containing some or all of the following 

447 characters (in any order): 

448 `"L"`: left, 

449 `"T"`: top, 

450 `"R"`: right, 

451 `"B"`: bottom. 

452 Defaults to `0`. 

453 align: Sets the text alignment inside the cell. 

454 Possible values are: 

455 `"J"`: justify (default value), 

456 `"L"` / `""`: left align, 

457 `"C"`: center, 

458 `"X"`: center around current x-position, or 

459 `"R"`: right align. 

460 fill: Indicates if the cell background must be painted (`True`) 

461 or transparent (`False`). Defaults to `False`. 

462 split_only: **DEPRECATED since 2.7.4**: Use `dry_run=True` and 

463 `output=("LINES",)` instead. 

464 link: Optional link to add on the cell, internal (identifier 

465 returned by [:py:meth:`fpdf.FPDF.add_link`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.add_link) 

466 or external URL. 

467 new_x: New current position in x after the call. Defaults to 

468 [:py:attr:`fpdf.XPos.RIGHT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.XPos). 

469 new_y: New current position in y after the call. Defaults to 

470 [:py:attr:`fpdf.YPos.NEXT`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.YPos). 

471 ln: **DEPRECATED since 2.5.1**: Use `new_x` and `new_y` instead. 

472 max_line_height: Optional maximum height of each sub-cell generated. 

473 Defaults to `None`. 

474 markdown: Enables minimal markdown-like markup to render part 

475 of text as bold / italics / strikethrough / underlined. 

476 Supports `"\\"` as escape character. Defaults to `False`. 

477 print_sh: Treat a soft-hyphen (`"\\u00ad"`) as a normal printable 

478 character, instead of a line breaking opportunity. Defaults to 

479 `False`. 

480 wrapmode: [:py:attr:`fpdf.enums.WrapMode.WORD`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode) 

481 for word based line wrapping (default) or 

482 [:py:attr:`fpdf.enums.WrapMode.CHAR`](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.WrapMode) 

483 for character based line wrapping. 

484 dry_run: If `True`, does not output anything in the document. 

485 Can be useful when combined with `output`. Defaults to 

486 `False`. 

487 output: Defines what this method returns. If several enum values are 

488 joined, the result will be a tuple. 

489 txt: [**DEPRECATED since v2.7.6**] string to print. 

490 center: Center the cell horizontally on the page. Defaults to 

491 `False`. 

492 padding: Padding to apply around the text. Defaults to `0`. 

493 When one value is specified, it applies the same padding to all 

494 four sides. 

495 When two values are specified, the first padding applies to the 

496 top and bottom, the second to the left and right. 

497 When three values are specified, the first padding applies to 

498 the top, the second to the right and left, the third to the 

499 bottom. 

500 When four values are specified, the paddings apply to the top, 

501 right, bottom, and left in that order (clockwise) 

502 If padding for left or right ends up being non-zero then the 

503 respective [:py:attr:`fpdf.FPDF.c_margin`](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html) 

504 is ignored. Center overrides values for horizontal padding. 

505 first_line_indent: The indent of the first line. Defaults to `0`. 

506 

507 Returns: 

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

509 value. 

510 

511 Raises: 

512 FPDFException: If no font has been set before. 

513 ValueError: If `w` or `h` is a string. 

514 """ # noqa: DOC102 

515 padding = Padding.new(padding) 

516 wrapmode = WrapMode.coerce(wrapmode) 

517 

518 if split_only: 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true

519 warnings.warn( 

520 ( 

521 'The parameter "split_only" is deprecated since v2.7.4.' 

522 ' Use instead dry_run=True and output="LINES".' 

523 ), 

524 DeprecationWarning, 

525 stacklevel=fpdf.deprecation.get_stack_level(), 

526 ) 

527 if dry_run or split_only: 

528 with self._disable_writing(): 

529 return self.multi_cell( 

530 w=w, 

531 h=h, 

532 text=text, 

533 border=border, 

534 align=align, 

535 fill=fill, 

536 link=link, 

537 ln=ln, 

538 max_line_height=max_line_height, 

539 markdown=markdown, 

540 print_sh=print_sh, 

541 new_x=new_x, 

542 new_y=new_y, 

543 wrapmode=wrapmode, 

544 dry_run=False, 

545 split_only=False, 

546 output=MethodReturnValue.LINES if split_only else output, 

547 center=center, 

548 padding=padding, 

549 # CHANGE 

550 first_line_indent=first_line_indent, 

551 ) 

552 if not self.font_family: 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true

553 raise fpdf.errors.FPDFException( 

554 "No font set, you need to call set_font() beforehand" 

555 ) 

556 if isinstance(w, str) or isinstance(h, str): 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true

557 raise ValueError( 

558 "Parameter 'w' and 'h' must be numbers, not strings." 

559 " You can omit them by passing string content with text=" 

560 ) 

561 new_x = XPos.coerce(new_x) 

562 new_y = YPos.coerce(new_y) 

563 if ln != "DEPRECATED": 563 ↛ 566line 563 didn't jump to line 566 because the condition on line 563 was never true

564 # For backwards compatibility, if "ln" is used we overwrite 

565 # "new_[xy]". 

566 if ln == 0: 

567 new_x = XPos.RIGHT 

568 new_y = YPos.NEXT 

569 elif ln == 1: 

570 new_x = XPos.LMARGIN 

571 new_y = YPos.NEXT 

572 elif ln == 2: 

573 new_x = XPos.LEFT 

574 new_y = YPos.NEXT 

575 elif ln == 3: 

576 new_x = XPos.RIGHT 

577 new_y = YPos.TOP 

578 else: 

579 raise ValueError( 

580 f'Invalid value for parameter "ln" ({ln}),' 

581 " must be an int between 0 and 3." 

582 ) 

583 warnings.warn( 

584 ( 

585 f'The parameter "ln" is deprecated since v2.5.2.' 

586 f" Instead of ln={ln} use new_x=XPos.{new_x.name}, " 

587 f"new_y=YPos.{new_y.name}." 

588 ), 

589 DeprecationWarning, 

590 stacklevel=fpdf.errors.get_stack_level(), 

591 ) 

592 align = Align.coerce(align) 

593 

594 page_break_triggered = False 

595 

596 if h is None: 

597 h = self.font_size 

598 

599 # If width is 0, set width to available width between margins 

600 if w == 0: 600 ↛ 604line 600 didn't jump to line 604 because the condition on line 600 was always true

601 w = self.w - self.r_margin - self.x 

602 

603 # Store the starting position before applying padding 

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

605 

606 # Apply padding to contents 

607 # decrease maximum allowed width by padding 

608 # shift the starting point by padding 

609 maximum_allowed_width = w = w - padding.right - padding.left 

610 clearance_margins: list[float] = [] 

611 # If we don't have padding on either side, we need a clearance margin. 

612 if not padding.left: 612 ↛ 614line 612 didn't jump to line 614 because the condition on line 612 was always true

613 clearance_margins.append(self.c_margin) 

614 if not padding.right: 614 ↛ 616line 614 didn't jump to line 616 because the condition on line 614 was always true

615 clearance_margins.append(self.c_margin) 

616 if align != Align.X: 616 ↛ 618line 616 didn't jump to line 618 because the condition on line 616 was always true

617 self.x += padding.left 

618 self.y += padding.top 

619 

620 # Center overrides padding 

621 if center: 621 ↛ 622line 621 didn't jump to line 622 because the condition on line 621 was never true

622 self.x = ( 

623 self.w / 2 

624 if align == Align.X 

625 else self.l_margin + (self.epw - w) / 2 

626 ) 

627 prev_x = self.x 

628 

629 # Calculate text length 

630 text = self.normalize_text(text) 

631 normalized_string = text.replace("\r", "") 

632 styled_text_fragments = ( 

633 self._preload_bidirectional_text(normalized_string, markdown) 

634 if self.text_shaping 

635 else self._preload_font_styles(normalized_string, markdown) 

636 ) 

637 

638 prev_current_font = self.current_font 

639 prev_font_style = self.font_style 

640 prev_underline = self.underline 

641 total_height: float = 0 

642 

643 text_lines: list[TextLine] = [] 

644 multi_line_break = MultiLineBreak( 

645 styled_text_fragments, 

646 maximum_allowed_width, 

647 clearance_margins, 

648 align=align, 

649 print_sh=print_sh, 

650 wrapmode=wrapmode, 

651 # CHANGE 

652 first_line_indent=first_line_indent, 

653 ) 

654 text_line = multi_line_break.get_line() 

655 while (text_line) is not None: 

656 text_lines.append(text_line) 

657 text_line = multi_line_break.get_line() 

658 

659 if ( 

660 not text_lines 

661 ): # ensure we display at least one cell - cf. issue #349 

662 text_lines = [ 

663 TextLine( 

664 [], 

665 text_width=0, 

666 number_of_spaces=0, 

667 align=align, 

668 height=h, 

669 max_width=w, 

670 trailing_nl=False, 

671 ) 

672 ] 

673 

674 if max_line_height is None or len(text_lines) == 1: 674 ↛ 677line 674 didn't jump to line 677 because the condition on line 674 was always true

675 line_height = h 

676 else: 

677 line_height = min(h, max_line_height) 

678 

679 box_required = fill or border 

680 page_break_triggered = False 

681 

682 for text_line_index, text_line in enumerate(text_lines): 

683 start_of_new_page = self._perform_page_break_if_need_be( 

684 h + padding.bottom 

685 ) 

686 if start_of_new_page: 

687 page_break_triggered = True 

688 self.y += padding.top 

689 # CHANGE 

690 if text_line_index == 0: 

691 self.x += first_line_indent 

692 # END CHANGE 

693 

694 if box_required and (text_line_index == 0 or start_of_new_page): 694 ↛ 696line 694 didn't jump to line 696 because the condition on line 694 was never true

695 # estimate how many cells can fit on this page 

696 top_gap = self.y # Top padding has already been added 

697 bottom_gap = padding.bottom + self.b_margin 

698 lines_before_break = int( 

699 (self.h - top_gap - bottom_gap) // line_height 

700 ) 

701 # check how many cells should be rendered 

702 num_lines = min( 

703 lines_before_break, len(text_lines) - text_line_index 

704 ) 

705 box_height = max( 

706 h - text_line_index * line_height, num_lines * line_height 

707 ) 

708 # render the box 

709 x = self.x - (w / 2 if align == Align.X else 0) 

710 draw_box_borders( 

711 self, 

712 x - padding.left, 

713 self.y - padding.top, 

714 # CHANGE 

715 x + w + padding.right + max(0, -first_line_indent), 

716 # END CHANGE 

717 self.y + box_height + padding.bottom, 

718 border, 

719 self.fill_color if fill else None, 

720 ) 

721 is_last_line = text_line_index == len(text_lines) - 1 

722 self._render_styled_text_line( 

723 text_line, 

724 h=line_height, 

725 new_x=new_x if is_last_line else XPos.LEFT, 

726 new_y=new_y if is_last_line else YPos.NEXT, 

727 border=0, # already rendered 

728 fill=False, # already rendered 

729 link=link, 

730 padding=Padding(0, padding.right, 0, padding.left), 

731 prevent_font_change=markdown, 

732 ) 

733 total_height += line_height 

734 if not is_last_line and align == Align.X: 734 ↛ 736line 734 didn't jump to line 736 because the condition on line 734 was never true

735 # prevent cumulative shift to the left 

736 self.x = prev_x 

737 # CHANGE 

738 if text_line_index == 0: 

739 self.x -= first_line_indent 

740 # END CHANGE 

741 

742 if total_height < h: 742 ↛ 744line 742 didn't jump to line 744 because the condition on line 742 was never true

743 # Move to the bottom of the multi_cell 

744 if new_y == YPos.NEXT: 

745 self.y += h - total_height 

746 total_height = h 

747 

748 if page_break_triggered and new_y == YPos.TOP: 748 ↛ 752line 748 didn't jump to line 752 because the condition on line 748 was never true

749 # When a page jump is performed and the requested y is TOP, 

750 # pretend we started at the top of the text block on the new page. 

751 # cf. test_multi_cell_table_with_automatic_page_break 

752 prev_y = self.y 

753 

754 last_line = text_lines[-1] 

755 if ( 755 ↛ 761line 755 didn't jump to line 761 because the condition on line 755 was never true

756 last_line 

757 and last_line.trailing_nl 

758 and new_y in (YPos.LAST, YPos.NEXT) 

759 ): 

760 # The line renderer can't handle trailing newlines in the text. 

761 self.ln() 

762 

763 if new_y == YPos.TOP: # We may have jumped a few lines -> reset 763 ↛ 764line 763 didn't jump to line 764 because the condition on line 763 was never true

764 self.y = prev_y 

765 elif new_y == YPos.NEXT: # move down by bottom padding 765 ↛ 768line 765 didn't jump to line 768 because the condition on line 765 was always true

766 self.y += padding.bottom 

767 

768 if markdown: 

769 self.font_style = prev_font_style 

770 self.current_font = prev_current_font 

771 self.underline = prev_underline 

772 

773 if ( 

774 new_x == XPos.RIGHT 

775 ): # move right by right padding to align outer RHS edge 

776 self.x += padding.right 

777 elif ( 

778 new_x == XPos.LEFT 

779 ): # move left by left padding to align outer LHS edge 

780 self.x -= padding.left 

781 

782 output = MethodReturnValue.coerce(output) 

783 return_value = () 

784 if output & MethodReturnValue.PAGE_BREAK: 

785 return_value += (page_break_triggered,) # type: ignore[assignment] 

786 if output & MethodReturnValue.LINES: 

787 output_lines: list[str] = [] 

788 for text_line in text_lines: 

789 characters: list[str] = [] 

790 for frag in text_line.fragments: 

791 characters.extend(frag.characters) 

792 output_lines.append("".join(characters)) 

793 return_value += (output_lines,) # type: ignore[assignment] 

794 if output & MethodReturnValue.HEIGHT: 

795 return_value += (total_height + padding.top + padding.bottom,) # type: ignore[assignment] 

796 if len(return_value) == 1: 

797 return return_value[0] 

798 return return_value # type: ignore[return-value] 

799 

800 @overload 

801 def output( # type: ignore[overload-overlap] 

802 self, 

803 name: Literal[""] | None = "", 

804 *, 

805 linearize: bool = False, 

806 output_producer_class: type[OutputProducer] = OutputProducer, 

807 ) -> bytearray: ... 

808 

809 @overload 

810 def output( 

811 self, 

812 name: os.PathLike[str] | str | BinaryIO, 

813 *, 

814 linearize: bool = False, 

815 output_producer_class: type[OutputProducer] = OutputProducer, 

816 ) -> None: ... 

817 

818 def output( 

819 self, 

820 name: os.PathLike[str] | BinaryIO | str | Literal[""] | None = "", 

821 *, 

822 linearize: bool = False, 

823 output_producer_class: type[OutputProducer] = OutputProducer, 

824 ) -> bytearray | None: 

825 """Output PDF to some destination. 

826 

827 By default the bytearray buffer is returned. 

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

829 

830 Args: 

831 name: Optional file object or file path where to save the PDF under. 

832 Defaults to `""`. 

833 linearize: Whether to use the 

834 :py:class:`fpdf.output.LinearizedOutputProducer`. Defaults to 

835 `False`. 

836 output_producer_class: Use a custom class for PDF file generation. 

837 Defaults to :py:class:`fpdf.output.OutputProducer`. 

838 

839 Returns: 

840 If a `name` is given, the PDF will be written to a new file and 

841 `None` will be returned. Else, a bytearray buffer is returned, 

842 comprising the PDF. 

843 

844 Raises: 

845 PDFAComplianceError: If the compliance requires at least one 

846 embedded file. 

847 """ 

848 # Clear cache of cached functions to free up memory after output 

849 get_unicode_script.cache_clear() 

850 # Finish document if necessary: 

851 if not self.buffer: 851 ↛ 905line 851 didn't jump to line 905 because the condition on line 851 was always true

852 if self.page == 0: 852 ↛ 853line 852 didn't jump to line 853 because the condition on line 852 was never true

853 self.add_page() 

854 # Generating final page footer: 

855 self._render_footer() 

856 # Generating .buffer based on .pages: 

857 if self.toc_placeholder: 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true

858 self._insert_table_of_contents() 

859 # CHANGE 

860 if self.index_placeholder: 860 ↛ 863line 860 didn't jump to line 863 because the condition on line 860 was always true

861 self._insert_index() 

862 # CHANGE 

863 if self.str_alias_nb_pages: 863 ↛ 874line 863 didn't jump to line 874 because the condition on line 863 was always true

864 for page in self.pages.values(): 

865 for substitution_item in page.get_text_substitutions(): 865 ↛ 866line 865 didn't jump to line 866 because the loop on line 865 never started

866 page.contents = page.contents.replace( # type: ignore[union-attr] 

867 substitution_item.get_placeholder_string().encode( 

868 "latin-1" 

869 ), 

870 substitution_item.render_text_substitution( 

871 str(self.pages_count) 

872 ).encode("latin-1"), 

873 ) 

874 for _, font in self.fonts.items(): 

875 if isinstance(font, TTFFont) and font.color_font: 875 ↛ 876line 875 didn't jump to line 876 because the condition on line 875 was never true

876 font.color_font.load_glyphs() 

877 if self._compliance and self._compliance.profile == "PDFA": 877 ↛ 878line 877 didn't jump to line 878 because the condition on line 877 was never true

878 if len(self._output_intents) == 0: 

879 self.add_output_intent( 

880 OutputIntentSubType.PDFA, 

881 output_condition_identifier="sRGB", 

882 output_condition="IEC 61966-2-1:1999", 

883 registry_name="http://www.color.org", 

884 dest_output_profile=PDFICCProfile( 

885 contents=builtin_srgb2014_bytes(), 

886 n=3, 

887 alternate="DeviceRGB", 

888 ), 

889 info="sRGB2014 (v2)", 

890 ) 

891 if ( 

892 self._compliance.part == 4 

893 and self._compliance.conformance == "F" 

894 and len(self.embedded_files) == 0 

895 ): 

896 msg = ( 

897 f"{self._compliance.label} requires at least one " 

898 "embedded file" 

899 ) 

900 raise fpdf.errors.PDFAComplianceError(msg) 

901 if linearize: 901 ↛ 902line 901 didn't jump to line 902 because the condition on line 901 was never true

902 output_producer_class = LinearizedOutputProducer 

903 output_producer = output_producer_class(self) 

904 self.buffer = output_producer.bufferize() 

905 if name: 905 ↛ 911line 905 didn't jump to line 911 because the condition on line 905 was always true

906 if isinstance(name, (str, os.PathLike)): 906 ↛ 907line 906 didn't jump to line 907 because the condition on line 906 was never true

907 pathlib.Path(name).write_bytes(self.buffer) 

908 else: 

909 name.write(self.buffer) 

910 return None 

911 return self.buffer