Coverage for fpdf2_textindex/renderer.py: 89.39%

302 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-01 14:22 +0000

1"""Text Index Renderer.""" 

2 

3from __future__ import annotations 

4 

5from collections import deque 

6from collections.abc import Iterable, Iterator 

7import contextlib 

8import dataclasses 

9import logging 

10from typing import Literal, TYPE_CHECKING 

11 

12import fpdf 

13 

14from fpdf2_textindex import constants as const 

15from fpdf2_textindex.constants import LOGGER 

16from fpdf2_textindex.errors import FPDF2TextindexError 

17from fpdf2_textindex.interface import CrossReferenceType 

18from fpdf2_textindex.interface import LinkLocation 

19from fpdf2_textindex.interface import TextIndexEntry 

20from fpdf2_textindex.md_emphasis import MDEmphasis 

21from fpdf2_textindex.utils import md_link 

22 

23if TYPE_CHECKING: 

24 from fpdf2_textindex.interface import CrossReference 

25 from fpdf2_textindex.interface import TextIndexEntryP 

26 from fpdf2_textindex.pdf import FPDF 

27 

28 

29@dataclasses.dataclass(frozen=True, kw_only=True, slots=True) 

30class _AlsoPseudoEntry: 

31 """A pseudo entry for printing an ALSO reference as separate subentry.""" 

32 

33 depth: int 

34 

35 @property 

36 def label(self) -> str | None: 

37 return None 

38 

39 @property 

40 def sort_label(self) -> str: 

41 return "" 

42 

43 

44class TextIndexRenderer: 

45 """Text Index (Writer). 

46 

47 A reference implementation of a Text Index to use with 

48 [fpdf2](https://py-pdf.github.io/fpdf2/index.html). 

49 

50 This class provides a customizable Text Index that can be used directly or 

51 subclassed for additional functionality. 

52 To use this class, create an instance of :py:class:`TextIndexRenderer`, 

53 configure it as needed, and pass its 

54 :py:meth:`TextIndexRenderer.render_text_index`-method as 

55 `render_index_function`-argument to 

56 :py:meth:`fpdf2_textindex.pdf.FPDF.insert_index_placeholder`. 

57 """ 

58 

59 if TYPE_CHECKING: 

60 _cur_header: str | None 

61 _link_locations: dict[str, LinkLocation] 

62 border: bool 

63 ignore_same_page_refs: bool 

64 level_indent: float 

65 line_spacing: float 

66 max_outline_level: int 

67 outline_level: int 

68 run_in_style: bool 

69 show_header: bool 

70 sort_emph_first: bool 

71 text_styles: list[fpdf.TextStyle] 

72 

73 def __init__( 

74 self, 

75 *, 

76 border: bool = False, 

77 ignore_same_page_refs: bool = True, 

78 level_indent: float | None = 7.5, 

79 line_spacing: float | None = None, 

80 max_outline_level: int | None = None, 

81 outline_level: int | None = None, 

82 run_in_style: bool = True, 

83 show_header: bool = False, 

84 sort_emph_first: bool = False, 

85 text_styles: Iterable[fpdf.TextStyle] | fpdf.TextStyle | None = None, 

86 ) -> None: 

87 """Initializes the renderer. 

88 

89 Args: 

90 border: Whether to show borders around the entries and headers. 

91 Mainly for debugging purposes. Defaults to `False`. 

92 ignore_same_page_refs: Whether to ignore references (locators) to 

93 the same PDF page (default), else same pages will be printed 

94 multiple times. 

95 level_indent: The indent to add per entry depth to the left of the 

96 entry. Defaults to `7.5` times the 

97 [fpdf.FPDF.unit](https://py-pdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF). 

98 line_spacing: The spacing between lines as multiple of the font 

99 size. Defaults to `None`, meaning `1.0`. 

100 max_outline_level: If `outline_level` >= 0, `max_outline_level` 

101 will decide how many deeper entries will be added to the PDF 

102 outline. Defaults to `None`, meaning that no liimit is set. 

103 outline_level: If `outline_level` >= 0, the first entry depth will 

104 be added at this outline level to the PDF. If 

105 `show_header=True`, the headers will be added at this outline 

106 level to the PDF. Defaults to `None`, meaning to not show the 

107 entries (or headers) in the PDF outline. 

108 run_in_style: Whether to print the deepest entry levels at "run-in"- 

109 style (>2). Defaults to `True`. 

110 show_header: Whether to show the headers. Defaults to `False`. 

111 sort_emph_first: Whether to show emphasized references (locators) 

112 first. Defaults to `False`. 

113 text_styles: The text styles to use to print the entries at the 

114 different depths. If `show_header=True`, the first text style 

115 refers to the style of the headers. If an entry is "deeper" than 

116 there are text styles, the renderer will fall back to deepest 

117 given text style. Defaults to `None`, meaning to take the 

118 text style of the last PDF page. 

119 """ # noqa: DOC501 

120 self.border = border 

121 self.ignore_same_page_refs = bool(ignore_same_page_refs) 

122 self.level_indent = 0.0 if level_indent is None else float(level_indent) 

123 self.line_spacing = 1.0 if line_spacing is None else float(line_spacing) 

124 self.max_outline_level = ( 

125 -1 if max_outline_level is None else int(max_outline_level) 

126 ) 

127 self.outline_level = -1 if outline_level is None else int(outline_level) 

128 self.run_in_style = bool(run_in_style) 

129 self.show_header = bool(show_header) 

130 self.sort_emph_first = bool(sort_emph_first) 

131 

132 if text_styles is None: 

133 self.text_styles = [fpdf.TextStyle()] 

134 elif isinstance(text_styles, Iterable): 134 ↛ 136line 134 didn't jump to line 136 because the condition on line 134 was always true

135 self.text_styles = list(text_styles) 

136 elif isinstance(text_styles, fpdf.TextStyle): 

137 self.text_styles = [text_styles] 

138 else: 

139 msg = f"invalid type of text_styles: {type(text_styles):__name__:s}" 

140 raise TypeError(msg) 

141 

142 self._cur_header = None 

143 self._h_header_min = None 

144 self._link_locations = {} 

145 

146 def render_text_index( 

147 self, 

148 pdf: FPDF, 

149 entries: list[TextIndexEntry], 

150 ) -> None: 

151 """Renders the text index. 

152 

153 Note: 

154 Use this method as `render_index_function`-argument in 

155 `fpdf2_textindex.pdf.FPDF.insert_index_placeholder`. 

156 

157 Args: 

158 pdf: The `fpdf2_textindex.pdf.FPDF`-instance to render in. 

159 entries: The list of entries to render. 

160 

161 Raises: 

162 ValueError: If a textstyle has a 

163 [fpdf.Align](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.Align) 

164 -value as left margin. 

165 """ # noqa: DOC502 

166 assert pdf.index_placeholder is not None 

167 

168 LOGGER.info("Rendering text index") 

169 if not entries: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 LOGGER.warning("No entries defined") 

171 return 

172 

173 max_depth = max(e.depth for e in entries) 

174 if max_depth > 2: 174 ↛ 190line 174 didn't jump to line 190 because the condition on line 174 was always true

175 if self.run_in_style: 

176 LOGGER.warning( 

177 "Deep index (>2 levels): Level %d entries will be run-in " 

178 "to level %d (see docs to disable)", 

179 max_depth, 

180 max_depth - 1, 

181 ) 

182 else: 

183 LOGGER.warning( 

184 "Deep index (>2 levels): Consider reducing depth, or " 

185 "enable run-in (see docs)" 

186 ) 

187 

188 # Reset section title styles to guarantee adding to outline without add 

189 # section title 

190 prev_section_title_styles = pdf.section_title_styles 

191 pdf.section_title_styles = {} 

192 

193 for entry in entries: 

194 if entry.depth > 1: 

195 continue 

196 

197 prepared_entries: list[tuple[TextIndexEntryP, str]] = list( 

198 self._prepare_entry(pdf, entry, max_depth) 

199 ) 

200 self._render_header(pdf, entry, prepared_entries[0][1]) 

201 for e, text in prepared_entries: 

202 # LOGGER.info("%d %r", pdf.page, e.label) 

203 page_entry, x_entry, y_entry, w_entry, h_entry = ( 

204 self._render_entry(pdf, e, text) 

205 ) 

206 if isinstance(e, TextIndexEntry): 

207 self._set_links( 

208 pdf, e, page_entry, x_entry, y_entry, w_entry, h_entry 

209 ) 

210 if self._run_in_children(e, max_depth): 

211 for c in e.children: 

212 self._set_links( 

213 pdf, 

214 c, 

215 page_entry, 

216 x_entry, 

217 y_entry, 

218 w_entry, 

219 h_entry, 

220 ) 

221 

222 pdf.section_title_styles = prev_section_title_styles 

223 

224 LOGGER.info("Rendered text index") 

225 

226 def _render_entry( 

227 self, 

228 pdf: FPDF, 

229 entry: TextIndexEntryP, 

230 entry_text: str, 

231 ) -> tuple[int, float, float, float, float]: 

232 # Do not fit half an entry 

233 text_style = self._get_text_style(entry.depth) 

234 w_entry, h_entry = self._calc_entry_size(pdf, entry.depth, entry_text) 

235 pdf._perform_page_break_if_need_be(h_entry) 

236 

237 x_entry, y_entry = pdf.x, pdf.y 

238 # Consider level indent 

239 if TYPE_CHECKING: 

240 assert not isinstance(text_style.l_margin, fpdf.Align) 

241 l_margin = ( 

242 text_style.l_margin or pdf.l_margin 

243 ) + self.level_indent * entry.depth 

244 with ( 

245 self._add_to_outline(pdf, entry.depth, entry.label), 

246 pdf.use_text_style(text_style.replace(l_margin=l_margin)), 

247 ): 

248 page_entry = pdf.page 

249 pdf.multi_cell( 

250 w=0, 

251 h=pdf.font_size * self.line_spacing, 

252 text=entry_text, 

253 align=fpdf.Align.L, 

254 border=int(self.border), # type: ignore[arg-type] 

255 first_line_indent=-self.level_indent, 

256 markdown=True, 

257 new_x=fpdf.XPos.LMARGIN, 

258 new_y=fpdf.YPos.NEXT, 

259 ) 

260 x_entry += self.level_indent * (entry.depth - 1) 

261 assert fpdf.util.FloatTolerance.equal(pdf.y - y_entry, h_entry), ( 

262 pdf.y - y_entry, 

263 h_entry, 

264 ) 

265 return page_entry, x_entry, y_entry, w_entry, h_entry 

266 

267 def _render_header( 

268 self, 

269 pdf: FPDF, 

270 entry: TextIndexEntryP, 

271 first_entry_text: str, 

272 ) -> None: 

273 if not self.show_header or entry.depth > 1: 

274 return 

275 

276 # Empty label and sort key 

277 if entry.sort_label == const._LAST_SORT_LABEL: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true

278 return 

279 

280 next_header = entry.sort_label[0].upper() 

281 if next_header == self._cur_header: 

282 return 

283 

284 # Do not fit a single header without an entry at page bottom 

285 h_header_min = self._calc_min_header_height(pdf, first_entry_text) 

286 pdf._perform_page_break_if_need_be(h_header_min) 

287 

288 with ( 

289 self._add_to_outline(pdf, entry.depth, next_header, header=True), 

290 pdf.use_text_style(self._get_text_style(0)), 

291 ): 

292 h = pdf.font_size * self.line_spacing 

293 pdf.cell( 

294 h=h, 

295 text=next_header, 

296 border=int(self.border), # type: ignore[arg-type] 

297 new_x=fpdf.XPos.LMARGIN, 

298 new_y=fpdf.YPos.NEXT, 

299 ) 

300 

301 self._cur_header = next_header 

302 

303 @contextlib.contextmanager 

304 def _add_to_outline( 

305 self, 

306 pdf: FPDF, 

307 entry_depth: int, 

308 entry_label: str | None, 

309 *, 

310 header: bool = False, 

311 ) -> Iterator[None]: 

312 if entry_label is None or self.outline_level < 0: 

313 yield 

314 return 

315 

316 level = ( 

317 self.outline_level 

318 + int(self.show_header and not header) 

319 + entry_depth 

320 - 1 

321 ) 

322 if self.max_outline_level > -1 and level > self.max_outline_level: 

323 yield 

324 return 

325 

326 name = MDEmphasis.remove(entry_label) 

327 pdf.start_section(name, level=level) 

328 with pdf._marked_sequence(title=name) as struct_elem: 

329 outline_struct_elem = struct_elem 

330 yield 

331 pdf._outline[-1].struct_elem = outline_struct_elem 

332 

333 def _calc_entry_size( 

334 self, 

335 pdf: FPDF, 

336 entry_depth: int, 

337 entry_text: str, 

338 ) -> tuple[float, float]: 

339 text_style = self._get_text_style(entry_depth) 

340 if isinstance(text_style.l_margin, (fpdf.Align | str)): 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true

341 align = fpdf.Align.coerce(text_style.l_margin) 

342 msg = ( 

343 f"TextStyle with l_margin as align value {align!r} cannot be " 

344 f"used in {type(self).__name__:s}" 

345 ) 

346 raise ValueError(msg) 

347 

348 prev_x, prev_y = pdf.x, pdf.y 

349 # Consider level indent 

350 l_margin = ( 

351 text_style.l_margin or pdf.l_margin 

352 ) + self.level_indent * entry_depth 

353 

354 with pdf.use_text_style( 

355 text_style.replace(t_margin=0, l_margin=l_margin, b_margin=0) 

356 ): 

357 if TYPE_CHECKING: 

358 lines: list[str] 

359 h: float 

360 lines, h = pdf.multi_cell( # type: ignore[assignment, misc] 

361 w=0, 

362 h=pdf.font_size * self.line_spacing, 

363 text=entry_text, 

364 align=fpdf.Align.L, 

365 dry_run=True, 

366 first_line_indent=-self.level_indent, 

367 markdown=True, 

368 output=fpdf.enums.MethodReturnValue.LINES 

369 | fpdf.enums.MethodReturnValue.HEIGHT, 

370 padding=fpdf.util.Padding( 

371 top=text_style.t_margin or 0, 

372 bottom=text_style.b_margin or 0, 

373 ), 

374 ) 

375 w = max( 

376 pdf.get_string_width( 

377 line, 

378 normalized=True, 

379 markdown=True, 

380 ) 

381 for line in lines 

382 ) 

383 w += 2 * pdf.c_margin + self.level_indent 

384 

385 assert pdf.x == prev_x, ( 

386 "x-position changed during calculation of entry height" 

387 ) 

388 assert pdf.y == prev_y, ( 

389 "y-position changed during calculation of entry height" 

390 ) 

391 return w, h 

392 

393 def _calc_min_header_height( 

394 self, 

395 pdf: FPDF, 

396 entry_text: str, 

397 ) -> float: 

398 # Header 

399 text_style = self.text_styles[0] 

400 h_min = text_style.t_margin 

401 h_min += ( 

402 (text_style.size_pt or pdf.font_size_pt) * self.line_spacing / pdf.k 

403 ) 

404 h_min += text_style.b_margin 

405 

406 # First entry 

407 text_style = self.text_styles[min(1, len(self.text_styles) - 1)] 

408 h_min += self._calc_entry_size(pdf, 1, entry_text)[1] 

409 return h_min 

410 

411 @staticmethod 

412 def _entry_at_label_path( 

413 entry: TextIndexEntry, 

414 label_path: Iterable[str], 

415 ) -> TextIndexEntry | None: 

416 # Go to root 

417 d = deque(entry.iter_parents(), maxlen=1) 

418 node: TextIndexEntry | None = (d[0] if d else entry).parent # root 

419 if node is None: 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true

420 return None 

421 

422 # Iterate down according to label path 

423 for label in label_path: 

424 node = node.get_child(label) 

425 if node is None: 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true

426 return None 

427 return node 

428 

429 def _get_text_style(self, entry_depth: int) -> fpdf.TextStyle: 

430 d = min( 

431 int(self.show_header) + entry_depth - 1, 

432 len(self.text_styles) - 1, 

433 ) 

434 return self.text_styles[d] 

435 

436 def _prepare_entry( 

437 self, 

438 pdf: FPDF, 

439 entry: TextIndexEntry, 

440 max_depth: int, 

441 *, 

442 _run_in: bool = False, 

443 ) -> Iterator[tuple[TextIndexEntryP, str]]: 

444 running_in = entry.parent and self._run_in_children( 

445 entry.parent, max_depth 

446 ) 

447 if running_in and not _run_in: 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true

448 return 

449 

450 has_refs = bool(entry.references) 

451 has_see_refs = any( 

452 cr.type == CrossReferenceType.SEE for cr in entry.cross_references 

453 ) 

454 assert not (has_see_refs and has_refs), ( 

455 f"Entry {entry.joined_label_path!r} has a reference (locator) " 

456 f"and a SEE-ross reference" 

457 ) 

458 has_also_refs = any( 

459 cr.type == CrossReferenceType.ALSO for cr in entry.cross_references 

460 ) 

461 

462 # Label 

463 text_pts = [entry.label] 

464 

465 # SEE-cross references 

466 if has_see_refs: 

467 text_pts.extend( 

468 self._prepare_cross_references( 

469 pdf, 

470 entry, 

471 CrossReferenceType.SEE, 

472 "running_in" if running_in or entry.depth > 1 else "entry", 

473 ) 

474 ) 

475 

476 # References (locators) 

477 if has_refs: 

478 text_pts.extend( 

479 self._prepare_references( 

480 pdf, 

481 entry, 

482 const.CATEGORY_SEPARATOR 

483 if has_see_refs 

484 else const.FIELD_SEPARATOR, 

485 ) 

486 ) 

487 

488 # Run-in style 

489 run_in_children = self._run_in_children(entry, max_depth) 

490 if run_in_children and entry.children: 

491 if has_refs: 

492 separator: str = const.LIST_SEPARATOR 

493 elif has_see_refs: # and not has_refs 

494 separator = const.CATEGORY_SEPARATOR 

495 else: # not has_see_refs 

496 separator = const.PATH_SEPARATOR 

497 text_pts.append(separator) 

498 

499 for i, child in enumerate(entry.sorted_children): 

500 if i > 0: 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true

501 text_pts.append(const.LIST_SEPARATOR) 

502 text_pts.extend( 

503 t 

504 for _, t in self._prepare_entry( 

505 pdf, child, max_depth, _run_in=True 

506 ) 

507 ) 

508 

509 # Own SEE ALSO-ross references 

510 # Check whether we lack children and thus potentially need to inline our 

511 # own SEE ALSO-cross references. This provides run-in style for such 

512 # cross references. 

513 if has_also_refs and (not entry.children or run_in_children): 

514 text_pts.extend( 

515 self._prepare_cross_references( 

516 pdf, 

517 entry, 

518 CrossReferenceType.ALSO, 

519 "running_in" if running_in else "entry", 

520 ) 

521 ) 

522 

523 text = "".join(text_pts) 

524 LOGGER.debug( 

525 "%sEntry %r (Level%d): %r", 

526 " " * (entry.depth - 1), 

527 entry.label, 

528 entry.depth, 

529 text, 

530 ) 

531 yield entry, text 

532 

533 if not run_in_children: 

534 for child in entry.sorted_children: 

535 yield from self._prepare_entry( 

536 pdf, child, max_depth, _run_in=False 

537 ) 

538 

539 if ( 

540 not running_in 

541 and entry.parent 

542 and entry is entry.parent.children[-1] 

543 and any( 

544 cr.type == CrossReferenceType.ALSO 

545 for cr in entry.parent.cross_references 

546 ) 

547 ): 

548 text = "".join( 

549 self._prepare_cross_references( 

550 pdf, 

551 entry.parent, 

552 CrossReferenceType.ALSO, 

553 "sub_entry", 

554 ) 

555 ) 

556 LOGGER.debug( 

557 "%sEntry %r (Level%d): %r", 

558 " " * (entry.depth - 1), 

559 entry.label, 

560 entry.depth, 

561 text, 

562 ) 

563 yield _AlsoPseudoEntry(depth=entry.depth), text 

564 

565 def _prepare_cross_references( 

566 self, 

567 pdf: FPDF, 

568 entry: TextIndexEntry, 

569 cross_ref_type: CrossReferenceType, 

570 mode: Literal["entry", "running_in", "sub_entry"], 

571 ) -> Iterator[str]: 

572 # See (also) under 

573 under_mode = ( 

574 len(entry.cross_references) == 1 

575 and sum(cr.type == cross_ref_type for cr in entry.cross_references) 

576 == 1 

577 and entry.label == entry.cross_references[-1].label_path[-1] 

578 ) 

579 

580 match mode: 

581 case "entry": 

582 yield const.CATEGORY_SEPARATOR 

583 case "running_in": 

584 yield " (" 

585 case "sub_entry": 585 ↛ 587line 585 didn't jump to line 587 because the pattern on line 585 always matched

586 pass 

587 case _: 

588 msg = f"invalid mode: {mode!r}" 

589 raise ValueError(msg) 

590 

591 cross_ref_type_str = str(cross_ref_type) 

592 cross_ref_type_str = ( 

593 cross_ref_type_str.lower() 

594 if mode == "running_in" 

595 else cross_ref_type_str.capitalize() 

596 ) 

597 if under_mode: 

598 cross_ref_type_str = f"{cross_ref_type_str:s} under" 

599 cross_ref_type_str = MDEmphasis.ITALICS.format(cross_ref_type_str) 

600 yield f"{cross_ref_type_str:s} " 

601 

602 i = 0 

603 last_cross_ref: CrossReference | None = None 

604 for cross_ref in entry.cross_references: 

605 if cross_ref.type != cross_ref_type: 

606 continue 

607 if ( 

608 last_cross_ref 

609 and last_cross_ref.label_path == cross_ref.label_path 

610 ): 

611 continue 

612 last_cross_ref = cross_ref 

613 

614 # Try to find cross referenced entry 

615 cross_ref_entry = self._entry_at_label_path( 

616 entry, cross_ref.label_path 

617 ) 

618 if cross_ref_entry is None: 618 ↛ 619line 618 didn't jump to line 619 because the condition on line 618 was never true

619 msg = "In entry %s, cross referenced entry %s does not exist" 

620 log_level = ( 

621 logging.WARNING 

622 if len(cross_ref.label_path) == 1 

623 else logging.ERROR 

624 ) 

625 LOGGER.log( 

626 log_level, 

627 msg, 

628 entry.joined_label_path, 

629 cross_ref.joined_label_path, 

630 ) 

631 if log_level == logging.ERROR: 

632 raise FPDF2TextindexError( 

633 msg 

634 % (entry.joined_label_path, cross_ref.joined_label_path) 

635 ) 

636 elif sum(len(e.references) for e in iter(cross_ref_entry)) == 0: 

637 msg = ( 

638 "In entry %s, cross referenced entry %s has no own " 

639 "reference(s) (blind cross reference)" 

640 ) 

641 LOGGER.warning( 

642 msg, entry.joined_label_path, cross_ref.joined_label_path 

643 ) 

644 elif len(cross_ref_entry.cross_references) > 0: 

645 msg = ( 

646 "In entry %s, cross referenced entry %s leads to other " 

647 "cross reference(s) (blind cross reference)" 

648 ) 

649 LOGGER.warning( 

650 msg, entry.joined_label_path, cross_ref.joined_label_path 

651 ) 

652 

653 # Write delimiter 

654 if i > 0: 

655 yield f"{const.REFS_DELIMITER:s} " 

656 i += 1 

657 

658 # Write cross reference 

659 cross_link = None 

660 if cross_ref_entry is not None: 660 ↛ 665line 660 didn't jump to line 665 because the condition on line 660 was always true

661 cross_link = f"{const.ENTRY_ID_PREFIX:s}{cross_ref_entry.id:d}" 

662 if cross_link not in self._link_locations: 

663 # Reserve link if not existing before 

664 pdf.set_link(name=cross_link) 

665 label_path = cross_ref.label_path 

666 if under_mode: 

667 label_path = label_path[:-1] 

668 content = const.PATH_SEPARATOR.join(label_path) 

669 if cross_link: 669 ↛ 671line 669 didn't jump to line 671 because the condition on line 669 was always true

670 content = md_link(content, f"#{cross_link}") 

671 yield content 

672 

673 if mode == "running_in": 

674 yield ")" 

675 

676 def _prepare_references( 

677 self, 

678 pdf: FPDF, 

679 entry: TextIndexEntry, 

680 first_separator: str, 

681 ) -> Iterator[str]: 

682 if len(entry.references) == 0: 682 ↛ 683line 682 didn't jump to line 683 because the condition on line 682 was never true

683 return 

684 

685 # Respect emphasis-first option 

686 refs = sorted( 

687 entry.references, 

688 key=( 

689 (lambda r: (not r.locator_emphasis, r.start_id, r.end_id)) 

690 if self.sort_emph_first 

691 else (lambda r: (r.start_id, r.end_id)) 

692 ), 

693 ) 

694 

695 # Warn about too many references 

696 if len(refs) >= const.REFERENCES_LIMIT: 

697 LOGGER.warning( 

698 "Entry %r has %d locators, consider reorganising or being more " 

699 "selective", 

700 entry.joined_label_path, 

701 len(refs), 

702 ) 

703 

704 self._last_page = -1 

705 for i, ref in enumerate(refs): 

706 # Render page of start id 

707 if TYPE_CHECKING: 

708 assert isinstance(ref.start_location, LinkLocation) 

709 yield from self._prepare_referenced_page( 

710 pdf, 

711 ref.start_link, 

712 ref.start_location, 

713 ref.locator_emphasis, 

714 first_separator if i == 0 else const.FIELD_SEPARATOR, 

715 ) 

716 

717 # Render page of end id 

718 if isinstance(ref.end_link, str): 

719 if TYPE_CHECKING: 

720 assert isinstance(ref.end_location, LinkLocation) 

721 yield from self._prepare_referenced_page( 

722 pdf, 

723 ref.end_link, 

724 ref.end_location, 

725 ref.locator_emphasis, 

726 const.RANGE_SEPARATOR, 

727 ) 

728 

729 # Render suffix of start id 

730 separator = "" 

731 if isinstance(ref.start_suffix, str): 

732 yield separator 

733 yield md_link(ref.start_suffix, f"#{ref.start_link:s}") 

734 separator = " " 

735 

736 # Render suffix of end id 

737 if isinstance(ref.end_suffix, str): 

738 if ref.end_link is None: 738 ↛ 739line 738 didn't jump to line 739 because the condition on line 738 was never true

739 msg = ( 

740 f"entry's {entry.joined_label_path!r:s} " 

741 f"(id={entry.id:d}) reference with start id " 

742 f"{ref.start_id:d} has end suffix " 

743 f"{ref.end_suffix!r:s}, but no end id" 

744 ) 

745 raise FPDF2TextindexError(msg) 

746 yield separator 

747 yield md_link(ref.end_suffix, f"#{ref.end_link:s}") 

748 

749 def _prepare_referenced_page( 

750 self, 

751 pdf: FPDF, 

752 text_to_index_link: str, 

753 link_loc: LinkLocation, 

754 locator_emphasis: bool, 

755 separator: str, 

756 ) -> Iterator[str]: 

757 # Ignore consecutive references to same page 

758 if self.ignore_same_page_refs and link_loc.page == self._last_page: 

759 return 

760 

761 # Catch that font does not support unicode characters 

762 if separator == const.RANGE_SEPARATOR: 

763 try: 

764 pdf.normalize_text(separator) 

765 except fpdf.errors.FPDFUnicodeEncodingException: 

766 separator = "-" 

767 

768 # Write separator 

769 yield separator 

770 

771 # Point link of page number in index to text page 

772 index_to_text_link = f"{text_to_index_link:s}{const.TEXT_ID_SUFFIX:s}" 

773 pdf.add_link( 

774 name=index_to_text_link, 

775 page=link_loc.page, 

776 x=link_loc.x, 

777 y=link_loc.y, 

778 ) 

779 

780 # Write page number 

781 self._last_page = link_loc.page 

782 content = pdf.pages[link_loc.page].get_label() 

783 text = md_link(content, f"#{index_to_text_link:s}") 

784 yield MDEmphasis.BOLD.format(text) if locator_emphasis else text 

785 

786 def _run_in_children(self, entry: TextIndexEntry, max_depth: int) -> bool: 

787 """Returns whether the entry should render its children in run-in style. 

788 

789 Top-level entries are at level 1, and are considered children of the 

790 index (root) itself. Depths 1 and 2 (top-level entries and their sub- 

791 -entries) are always indented. Thereafter, for practical reasons, only 

792 the deepest level is run-in. 

793 

794 Note: 

795 Please don't make indexes deeper than 3 levels (sub-sub-entries) 

796 though, for your readers' sake! 

797 """ 

798 if self.run_in_style: 

799 return entry.depth >= 2 and entry.depth == max_depth - 1 

800 return False 

801 

802 def _set_links( 

803 self, 

804 pdf: FPDF, 

805 entry: TextIndexEntry, 

806 page_entry: int, 

807 x_entry: float, 

808 y_entry: float, 

809 w_entry: float, 

810 h_entry: float, 

811 ) -> None: 

812 # Add link to entry label into link locations 

813 entry_link = f"{const.ENTRY_ID_PREFIX:s}{entry.id:d}" 

814 assert entry_link not in self._link_locations, ( 

815 repr(entry), 

816 self._link_locations[entry_link], 

817 ) 

818 pdf.add_link(name=entry_link, x=x_entry, y=y_entry) 

819 link_loc = LinkLocation( 

820 page=page_entry, 

821 x=x_entry, 

822 y=y_entry, 

823 w=w_entry, 

824 h=h_entry, 

825 ) 

826 self._link_locations[entry_link] = link_loc 

827 LOGGER.debug( 

828 "%sEntry %r (Level%d): %r", 

829 " " * (entry.depth - 1), 

830 entry.label, 

831 entry.depth, 

832 link_loc, 

833 ) 

834 

835 # Point links on text page to index entry 

836 # References 

837 for ref in entry.references: 

838 # dest = pdf.named_destinations[text_to_index_link] 

839 # fpdf_link_idx = reverse_dict_items(pdf.links.items())[dest] 

840 fpdf_link_idx = pdf._index_links[ref.start_link] 

841 pdf.set_link( 

842 link=fpdf_link_idx, 

843 name=ref.start_link, 

844 page=link_loc.page, 

845 x=link_loc.x, 

846 y=link_loc.y, 

847 ) 

848 

849 if isinstance(ref.end_link, str): 

850 fpdf_link_idx = pdf._index_links[ref.end_link] 

851 pdf.set_link( 

852 link=fpdf_link_idx, 

853 name=ref.end_link, 

854 page=link_loc.page, 

855 x=link_loc.x, 

856 y=link_loc.y, 

857 ) 

858 

859 # Cross references 

860 for cross_ref in entry.cross_references: 

861 fpdf_link_idx = pdf._index_links[cross_ref.link] 

862 pdf.set_link( 

863 link=fpdf_link_idx, 

864 name=cross_ref.link, 

865 page=link_loc.page, 

866 x=link_loc.x, 

867 y=link_loc.y, 

868 )