Coverage for fpdf2_textindex / renderer.py: 88.92%

304 statements  

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

1"""Text Index Renderer.""" 

2 

3from collections import deque 

4from collections.abc import Iterable, Iterator 

5import contextlib 

6import dataclasses 

7import logging 

8from typing import Literal, Protocol, TYPE_CHECKING 

9 

10import fpdf 

11 

12from fpdf2_textindex import constants as const 

13from fpdf2_textindex.constants import LOGGER 

14from fpdf2_textindex.interface import CrossReferenceType 

15from fpdf2_textindex.interface import LinkLocation 

16from fpdf2_textindex.interface import TextIndexEntry 

17from fpdf2_textindex.md_emphasis import MDEmphasis 

18from fpdf2_textindex.pdf import FPDF 

19from fpdf2_textindex.utils import md_link 

20 

21 

22class TextIndexEntryP(Protocol): 

23 """Text Index Protocol.""" 

24 

25 @property 

26 def depth(self) -> int: 

27 """The depth of the entry.""" 

28 ... 

29 

30 @property 

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

32 """The label of the entry.""" 

33 ... 

34 

35 @property 

36 def sort_label(self) -> str: 

37 """The sort label of the entry.""" 

38 ... 

39 

40 

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

42class _AlsoPseudoEntry: 

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

44 

45 depth: int 

46 

47 @property 

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

49 return None 

50 

51 @property 

52 def sort_label(self) -> str: 

53 return "" 

54 

55 

56class TextIndexRenderer: 

57 """Text Index (Writer). 

58 

59 A reference implementation of a Text Index to use with [fpdf2](https://py-pdf.github.io/fpdf2/index.html). 

60 

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

62 subclassed for additional functionality. 

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

64 configure it as needed, and pass its 

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

66 `render_index_function`-argument to 

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

68 """ 

69 

70 if TYPE_CHECKING: 

71 _cur_header: str | None 

72 _link_locations: dict[str, LinkLocation] 

73 border: bool 

74 ignore_same_page_refs: bool 

75 level_indent: float 

76 line_spacing: float 

77 max_outline_level: int 

78 outline_level: int 

79 run_in_style: bool 

80 show_header: bool 

81 sort_emph_first: bool 

82 text_styles: list[fpdf.TextStyle] 

83 

84 def __init__( 

85 self, 

86 *, 

87 border: bool = False, 

88 ignore_same_page_refs: bool = True, 

89 level_indent: float | None = 7.5, 

90 line_spacing: float | None = None, 

91 max_outline_level: int | None = None, 

92 outline_level: int | None = None, 

93 run_in_style: bool = True, 

94 show_header: bool = False, 

95 sort_emph_first: bool = False, 

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

97 ) -> None: 

98 """Initializes the renderer. 

99 

100 Args: 

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

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

103 ignore_same_page_refs: Whether to ignore references (locators) to 

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

105 multiple times. 

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

107 entry. Defaults to `7.5` times the 

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

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

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

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

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

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

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

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

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

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

118 entries (or headers) in the PDF outline. 

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

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

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

122 sort_emph_first: Whether to show emphasized references (locators) 

123 first. Defaults to `False`. 

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

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

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

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

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

129 text style of the last PDF page. 

130 """ # noqa: DOC501 

131 self.border = border 

132 self.ignore_same_page_refs = bool(ignore_same_page_refs) 

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

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

135 self.max_outline_level = ( 

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

137 ) 

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

139 self.run_in_style = bool(run_in_style) 

140 self.show_header = bool(show_header) 

141 self.sort_emph_first = bool(sort_emph_first) 

142 

143 if text_styles is None: 

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

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

146 self.text_styles = list(text_styles) 

147 elif isinstance(text_styles, fpdf.TextStyle): 

148 self.text_styles = [text_styles] 

149 else: 

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

151 raise TypeError(msg) 

152 

153 self._cur_header = None 

154 self._h_header_min = None 

155 self._link_locations = {} 

156 

157 def render_text_index( 

158 self, 

159 pdf: FPDF, 

160 entries: list[TextIndexEntry], 

161 ) -> None: 

162 """Renders the text index. 

163 

164 Note: 

165 Use this method as `render_index_function`-argument in 

166 `fpdf2_textindex.pdf.FPDF.insert_index_placeholder`. 

167 

168 Args: 

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

170 entries: The list of entries to render. 

171 

172 Raises: 

173 ValueError: If a textstyle has a [fpdf.Align](https://py-pdf.github.io/fpdf2/fpdf/enums.html#fpdf.enums.Align) 

174 -value as left margin. 

175 """ # noqa: DOC502 

176 assert pdf.index_placeholder is not None 

177 

178 LOGGER.info("Rendering text index") 

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

180 LOGGER.warning("No entries defined") 

181 return 

182 

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

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

185 if self.run_in_style: 

186 LOGGER.warning( 

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

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

189 max_depth, 

190 max_depth - 1, 

191 ) 

192 else: 

193 LOGGER.warning( 

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

195 "enable run-in (see docs)" 

196 ) 

197 

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

199 # section title 

200 prev_section_title_styles = pdf.section_title_styles 

201 pdf.section_title_styles = {} 

202 

203 for entry in entries: 

204 if entry.depth > 1: 

205 continue 

206 

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

208 self._prepare_entry(pdf, entry, max_depth) 

209 ) 

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

211 for e, text in prepared_entries: 

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

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

214 self._render_entry(pdf, e, text) 

215 ) 

216 if isinstance(e, TextIndexEntry): 

217 self._set_links( 

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

219 ) 

220 if self._run_in_children(e, max_depth): 

221 for c in e.children: 

222 self._set_links( 

223 pdf, 

224 c, 

225 page_entry, 

226 x_entry, 

227 y_entry, 

228 w_entry, 

229 h_entry, 

230 ) 

231 

232 pdf.section_title_styles = prev_section_title_styles 

233 

234 LOGGER.info("Rendered text index") 

235 

236 def _render_entry( 

237 self, 

238 pdf: FPDF, 

239 entry: TextIndexEntryP, 

240 entry_text: str, 

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

242 # Do not fit half an entry 

243 text_style = self._get_text_style(entry.depth) 

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

245 pdf._perform_page_break_if_need_be(h_entry) 

246 

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

248 # Consider level indent 

249 if TYPE_CHECKING: 

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

251 l_margin = ( 

252 text_style.l_margin or pdf.l_margin 

253 ) + self.level_indent * entry.depth 

254 with ( 

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

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

257 ): 

258 page_entry = pdf.page 

259 pdf.multi_cell( 

260 w=0, 

261 h=pdf.font_size * self.line_spacing, 

262 text=entry_text, 

263 align=fpdf.Align.L, 

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

265 first_line_indent=-self.level_indent, 

266 markdown=True, 

267 new_x=fpdf.XPos.LMARGIN, 

268 new_y=fpdf.YPos.NEXT, 

269 ) 

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

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

272 pdf.y - y_entry, 

273 h_entry, 

274 ) 

275 return page_entry, x_entry, y_entry, w_entry, h_entry 

276 

277 def _render_header( 

278 self, 

279 pdf: FPDF, 

280 entry: TextIndexEntryP, 

281 first_entry_text: str, 

282 ) -> None: 

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

284 return 

285 

286 if entry.sort_label == "\uffff": # Empty label and sort key 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true

287 return 

288 

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

290 if next_header == self._cur_header: 

291 return 

292 

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

294 h_header_min = self._calc_min_header_height(pdf, first_entry_text) 

295 pdf._perform_page_break_if_need_be(h_header_min) 

296 

297 with ( 

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

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

300 ): 

301 h = pdf.font_size * self.line_spacing 

302 pdf.cell( 

303 h=h, 

304 text=next_header, 

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

306 new_x=fpdf.XPos.LMARGIN, 

307 new_y=fpdf.YPos.NEXT, 

308 ) 

309 

310 self._cur_header = next_header 

311 

312 @contextlib.contextmanager 

313 def _add_to_outline( 

314 self, 

315 pdf: FPDF, 

316 entry_depth: int, 

317 entry_label: str | None, 

318 *, 

319 header: bool = False, 

320 ) -> Iterator[None]: 

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

322 yield 

323 return 

324 

325 level = ( 

326 self.outline_level 

327 + int(self.show_header and not header) 

328 + entry_depth 

329 - 1 

330 ) 

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

332 yield 

333 return 

334 

335 name = MDEmphasis.remove(entry_label) 

336 pdf.start_section(name, level=level) 

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

338 outline_struct_elem = struct_elem 

339 yield 

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

341 

342 def _calc_entry_size( 

343 self, 

344 pdf: FPDF, 

345 entry_depth: int, 

346 entry_text: str, 

347 ) -> tuple[float, float]: 

348 text_style = self._get_text_style(entry_depth) 

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

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

351 msg = ( 

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

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

354 ) 

355 raise ValueError(msg) 

356 

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

358 # Consider level indent 

359 l_margin = ( 

360 text_style.l_margin or pdf.l_margin 

361 ) + self.level_indent * entry_depth 

362 

363 with pdf.use_text_style( 

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

365 ): 

366 if TYPE_CHECKING: 

367 lines: list[str] 

368 h: float 

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

370 w=0, 

371 h=pdf.font_size * self.line_spacing, 

372 text=entry_text, 

373 align=fpdf.Align.L, 

374 dry_run=True, 

375 first_line_indent=-self.level_indent, 

376 markdown=True, 

377 output=fpdf.enums.MethodReturnValue.LINES 

378 | fpdf.enums.MethodReturnValue.HEIGHT, 

379 padding=fpdf.util.Padding( 

380 top=text_style.t_margin or 0, 

381 bottom=text_style.b_margin or 0, 

382 ), 

383 ) 

384 w = max( 

385 pdf.get_string_width( 

386 line, 

387 normalized=True, 

388 markdown=True, 

389 ) 

390 for line in lines 

391 ) 

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

393 

394 assert pdf.x == prev_x and pdf.y == prev_y, ( 

395 "position changed during calculation of entry height" 

396 ) 

397 return w, h 

398 

399 def _calc_min_header_height( 

400 self, 

401 pdf: FPDF, 

402 entry_text: str, 

403 ) -> float: 

404 # Header 

405 text_style = self.text_styles[0] 

406 h_min = text_style.t_margin 

407 h_min += ( 

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

409 ) 

410 h_min += text_style.b_margin 

411 

412 # First entry 

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

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

415 return h_min 

416 

417 @staticmethod 

418 def _entry_at_label_path( 

419 entry: TextIndexEntry, 

420 label_path: Iterable[str], 

421 ) -> TextIndexEntry | None: 

422 # Go to root 

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

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

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 

428 # Iterate down according to label path 

429 for label in label_path: 

430 node = node.get_child(label) 

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

432 return None 

433 return node 

434 

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

436 d = min( 

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

438 len(self.text_styles) - 1, 

439 ) 

440 return self.text_styles[d] 

441 

442 def _prepare_entry( 

443 self, 

444 pdf: FPDF, 

445 entry: TextIndexEntry, 

446 max_depth: int, 

447 *, 

448 _run_in: bool = False, 

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

450 running_in = entry.parent and self._run_in_children( 

451 entry.parent, max_depth 

452 ) 

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

454 return 

455 

456 has_refs = len(entry.references) > 0 

457 has_see_refs = any( 

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

459 ) 

460 assert not (has_see_refs and has_refs), ( 

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

462 f"and a SEE-ross reference" 

463 ) 

464 has_also_refs = any( 

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

466 ) 

467 

468 # Label 

469 text_pts = [entry.label] 

470 

471 # SEE-cross references 

472 if has_see_refs: 

473 text_pts.extend( 

474 self._prepare_cross_references( 

475 pdf, 

476 entry, 

477 CrossReferenceType.SEE, 

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

479 ) 

480 ) 

481 

482 # References (locators) 

483 if has_refs: 

484 text_pts.extend( 

485 self._prepare_references( 

486 pdf, 

487 entry, 

488 const.CATEGORY_SEPARATOR 

489 if has_see_refs 

490 else const.FIELD_SEPARATOR, 

491 ) 

492 ) 

493 

494 # Run-in style 

495 run_in_children = self._run_in_children(entry, max_depth) 

496 if run_in_children and entry.children: 

497 if has_refs: 

498 separator: str = const.LIST_SEPARATOR 

499 elif has_see_refs: # and not has_refs 

500 separator = const.CATEGORY_SEPARATOR 

501 else: # not has_see_refs 

502 separator = const.PATH_SEPARATOR 

503 text_pts.append(separator) 

504 

505 for i, child in enumerate(entry.children): 

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

507 text_pts.append(const.LIST_SEPARATOR) 

508 text_pts.extend( 

509 t 

510 for _, t in self._prepare_entry( 

511 pdf, child, max_depth, _run_in=True 

512 ) 

513 ) 

514 

515 # Own SEE ALSO-ross references 

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

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

518 # cross references. 

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

520 text_pts.extend( 

521 self._prepare_cross_references( 

522 pdf, 

523 entry, 

524 CrossReferenceType.ALSO, 

525 "running_in" if running_in else "entry", 

526 ) 

527 ) 

528 

529 text = "".join(text_pts) 

530 LOGGER.debug( 

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

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

533 entry.label, 

534 entry.depth, 

535 text, 

536 ) 

537 yield entry, text 

538 

539 if not run_in_children: 

540 for child in entry.children: 

541 yield from self._prepare_entry( 

542 pdf, child, max_depth, _run_in=False 

543 ) 

544 

545 if ( 

546 not running_in 

547 and entry.parent 

548 and any( 

549 cr.type == CrossReferenceType.ALSO 

550 for cr in entry.parent.cross_references 

551 ) 

552 ): 

553 text = "".join( 

554 self._prepare_cross_references( 

555 pdf, 

556 entry.parent, 

557 CrossReferenceType.ALSO, 

558 "sub_entry", 

559 ) 

560 ) 

561 LOGGER.debug( 

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

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

564 entry.label, 

565 entry.depth, 

566 text, 

567 ) 

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

569 

570 def _prepare_cross_references( 

571 self, 

572 pdf: FPDF, 

573 entry: TextIndexEntry, 

574 cross_ref_type: CrossReferenceType, 

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

576 ) -> Iterator[str]: 

577 # Sort by type and label path 

578 entry.cross_references.sort(key=lambda cr: (cr.type, *cr.label_path)) 

579 

580 # See (also) under 

581 under_mode = ( 

582 len(entry.cross_references) == 1 

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

584 == 1 

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

586 ) 

587 

588 match mode: 

589 case "entry": 

590 yield const.CATEGORY_SEPARATOR 

591 case "running_in": 

592 yield " (" 

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

594 pass 

595 case _: 

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

597 raise ValueError(msg) 

598 

599 cross_ref_type_str = str(cross_ref_type) 

600 cross_ref_type_str = ( 

601 cross_ref_type_str.lower() 

602 if mode == "running_in" 

603 else cross_ref_type_str.capitalize() 

604 ) 

605 if under_mode: 

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

607 cross_ref_type_str = MDEmphasis.ITALICS.format(cross_ref_type_str) 

608 yield f"{cross_ref_type_str:s} " 

609 

610 i = 0 

611 for cross_ref in entry.cross_references: 

612 if cross_ref.type != cross_ref_type: 

613 continue 

614 

615 # Try to find cross referenced entry 

616 cross_ref_entry = self._entry_at_label_path( 

617 entry, cross_ref.label_path 

618 ) 

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

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

621 log_level = ( 

622 logging.WARNING 

623 if len(cross_ref.label_path) == 1 

624 else logging.ERROR 

625 ) 

626 LOGGER.log( 

627 log_level, 

628 msg, 

629 entry.joined_label_path, 

630 cross_ref.joined_label_path, 

631 ) 

632 if log_level == logging.ERROR: 

633 raise RuntimeError( 

634 msg 

635 % (entry.joined_label_path, cross_ref.joined_label_path) 

636 ) 

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

638 msg = ( 

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

640 "reference(s) (blind cross reference)" 

641 ) 

642 LOGGER.warning( 

643 msg, entry.joined_label_path, cross_ref.joined_label_path 

644 ) 

645 elif len(cross_ref_entry.cross_references) > 0: 

646 msg = ( 

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

648 "cross reference(s) (blind cross reference)" 

649 ) 

650 LOGGER.warning( 

651 msg, entry.joined_label_path, cross_ref.joined_label_path 

652 ) 

653 

654 # Write delimiter 

655 if i > 0: 

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

657 i += 1 

658 

659 # Write cross reference 

660 cross_link = None 

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

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

663 if cross_link not in self._link_locations: 

664 # Reserve link if not existing before 

665 pdf.set_link(name=cross_link) 

666 label_path = cross_ref.label_path 

667 if under_mode: 

668 label_path = label_path[:-1] 

669 content = const.PATH_SEPARATOR.join(label_path) 

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

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

672 yield content 

673 

674 if mode == "running_in": 

675 yield ")" 

676 

677 def _prepare_references( 

678 self, 

679 pdf: FPDF, 

680 entry: TextIndexEntry, 

681 first_separator: str, 

682 ) -> Iterator[str]: 

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

684 return 

685 

686 # Respect emphasis-first option 

687 refs = sorted( 

688 entry.references, 

689 key=( 

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

691 if self.sort_emph_first 

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

693 ), 

694 ) 

695 

696 # Warn about too many references 

697 if len(refs) >= const.REFERENCES_LIMIT: 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true

698 LOGGER.warning( 

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

700 "selective", 

701 entry.joined_label_path, 

702 len(refs), 

703 ) 

704 

705 self._last_page = -1 

706 for i, ref in enumerate(refs): 

707 # Render page of start id 

708 if TYPE_CHECKING: 

709 assert isinstance(ref.start_location, LinkLocation) 

710 yield from self._prepare_referenced_page( 

711 pdf, 

712 ref.start_link, 

713 ref.start_location, 

714 ref.locator_emphasis, 

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

716 ) 

717 

718 # Render page of end id 

719 if isinstance(ref.end_link, str): 

720 if TYPE_CHECKING: 

721 assert isinstance(ref.end_location, LinkLocation) 

722 yield from self._prepare_referenced_page( 

723 pdf, 

724 ref.end_link, 

725 ref.end_location, 

726 ref.locator_emphasis, 

727 const.RANGE_SEPARATOR, 

728 ) 

729 

730 # Render suffix of start id 

731 separator = "" 

732 if isinstance(ref.start_suffix, str): 

733 yield separator 

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

735 separator = " " 

736 

737 # Render suffix of end id 

738 if isinstance(ref.end_suffix, str): 

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

740 msg = ( 

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

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

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

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

745 ) 

746 raise RuntimeError(msg) 

747 yield separator 

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

749 

750 def _prepare_referenced_page( 

751 self, 

752 pdf: FPDF, 

753 text_to_index_link: str, 

754 link_loc: LinkLocation, 

755 locator_emphasis: bool, 

756 separator: str, 

757 ) -> Iterator[str]: 

758 # Ignore consecutive references to same page 

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

760 return 

761 

762 # Catch that font does not support unicode characters 

763 if separator == const.RANGE_SEPARATOR: 

764 try: 

765 pdf.normalize_text(separator) 

766 except fpdf.errors.FPDFUnicodeEncodingException: 

767 separator = "-" 

768 

769 # Write separator 

770 yield separator 

771 

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

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

774 pdf.add_link( 

775 name=index_to_text_link, 

776 page=link_loc.page, 

777 x=link_loc.x, 

778 y=link_loc.y, 

779 ) 

780 

781 # Write page number 

782 self._last_page = link_loc.page 

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

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

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

786 

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

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

789 

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

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

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

793 the deepest level is run-in. 

794 

795 Note: 

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

797 though, for your readers' sake! 

798 """ 

799 if self.run_in_style: 

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

801 return False 

802 

803 def _set_links( 

804 self, 

805 pdf: FPDF, 

806 entry: TextIndexEntry, 

807 page_entry: int, 

808 x_entry: float, 

809 y_entry: float, 

810 w_entry: float, 

811 h_entry: float, 

812 ) -> None: 

813 # Add link to entry label into link locations 

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

815 assert entry_link not in self._link_locations, ( 

816 repr(entry), 

817 self._link_locations[entry_link], 

818 ) 

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

820 link_loc = LinkLocation( 

821 page=page_entry, 

822 x=x_entry, 

823 y=y_entry, 

824 w=w_entry, 

825 h=h_entry, 

826 ) 

827 self._link_locations[entry_link] = link_loc 

828 LOGGER.debug( 

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

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

831 entry.label, 

832 entry.depth, 

833 link_loc, 

834 ) 

835 

836 # Point links on text page to index entry 

837 # References 

838 for ref in entry.references: 

839 # dest = pdf.named_destinations[text_to_index_link] 

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

841 fpdf_link_idx = pdf._index_links[ref.start_link] 

842 pdf.set_link( 

843 link=fpdf_link_idx, 

844 name=ref.start_link, 

845 page=link_loc.page, 

846 x=link_loc.x, 

847 y=link_loc.y, 

848 ) 

849 

850 if isinstance(ref.end_link, str): 

851 fpdf_link_idx = pdf._index_links[ref.end_link] 

852 pdf.set_link( 

853 link=fpdf_link_idx, 

854 name=ref.end_link, 

855 page=link_loc.page, 

856 x=link_loc.x, 

857 y=link_loc.y, 

858 ) 

859 

860 # Cross references 

861 for cross_ref in entry.cross_references: 

862 fpdf_link_idx = pdf._index_links[cross_ref.link] 

863 pdf.set_link( 

864 link=fpdf_link_idx, 

865 name=cross_ref.link, 

866 page=link_loc.page, 

867 x=link_loc.x, 

868 y=link_loc.y, 

869 )