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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 15:45 +0000
1"""Text Index Renderer."""
3from collections import deque
4from collections.abc import Iterable, Iterator
5import contextlib
6import dataclasses
7import logging
8from typing import Literal, Protocol, TYPE_CHECKING
10import fpdf
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
22class TextIndexEntryP(Protocol):
23 """Text Index Protocol."""
25 @property
26 def depth(self) -> int:
27 """The depth of the entry."""
28 ...
30 @property
31 def label(self) -> str | None:
32 """The label of the entry."""
33 ...
35 @property
36 def sort_label(self) -> str:
37 """The sort label of the entry."""
38 ...
41@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
42class _AlsoPseudoEntry:
43 """A pseudo entry for printing an ALSO reference as separate subentry."""
45 depth: int
47 @property
48 def label(self) -> str | None:
49 return None
51 @property
52 def sort_label(self) -> str:
53 return ""
56class TextIndexRenderer:
57 """Text Index (Writer).
59 A reference implementation of a Text Index to use with [fpdf2](https://py-pdf.github.io/fpdf2/index.html).
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 """
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]
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.
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)
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)
153 self._cur_header = None
154 self._h_header_min = None
155 self._link_locations = {}
157 def render_text_index(
158 self,
159 pdf: FPDF,
160 entries: list[TextIndexEntry],
161 ) -> None:
162 """Renders the text index.
164 Note:
165 Use this method as `render_index_function`-argument in
166 `fpdf2_textindex.pdf.FPDF.insert_index_placeholder`.
168 Args:
169 pdf: The `fpdf2_textindex.pdf.FPDF`-instance to render in.
170 entries: The list of entries to render.
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
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
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 )
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 = {}
203 for entry in entries:
204 if entry.depth > 1:
205 continue
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 )
232 pdf.section_title_styles = prev_section_title_styles
234 LOGGER.info("Rendered text index")
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)
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
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
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
289 next_header = entry.sort_label[0].upper()
290 if next_header == self._cur_header:
291 return
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)
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 )
310 self._cur_header = next_header
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
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
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
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)
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
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
394 assert pdf.x == prev_x and pdf.y == prev_y, (
395 "position changed during calculation of entry height"
396 )
397 return w, h
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
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
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
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
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]
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
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 )
468 # Label
469 text_pts = [entry.label]
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 )
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 )
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)
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 )
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 )
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
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 )
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
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))
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 )
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)
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} "
610 i = 0
611 for cross_ref in entry.cross_references:
612 if cross_ref.type != cross_ref_type:
613 continue
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 )
654 # Write delimiter
655 if i > 0:
656 yield f"{const.REFS_DELIMITER:s} "
657 i += 1
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
674 if mode == "running_in":
675 yield ")"
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
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 )
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 )
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 )
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 )
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 = " "
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}")
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
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 = "-"
769 # Write separator
770 yield separator
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 )
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
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.
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.
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
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 )
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 )
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 )
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 )