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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-01 14:22 +0000
1"""Text Index Renderer."""
3from __future__ import annotations
5from collections import deque
6from collections.abc import Iterable, Iterator
7import contextlib
8import dataclasses
9import logging
10from typing import Literal, TYPE_CHECKING
12import fpdf
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
23if TYPE_CHECKING:
24 from fpdf2_textindex.interface import CrossReference
25 from fpdf2_textindex.interface import TextIndexEntryP
26 from fpdf2_textindex.pdf import FPDF
29@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
30class _AlsoPseudoEntry:
31 """A pseudo entry for printing an ALSO reference as separate subentry."""
33 depth: int
35 @property
36 def label(self) -> str | None:
37 return None
39 @property
40 def sort_label(self) -> str:
41 return ""
44class TextIndexRenderer:
45 """Text Index (Writer).
47 A reference implementation of a Text Index to use with
48 [fpdf2](https://py-pdf.github.io/fpdf2/index.html).
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 """
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]
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.
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)
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)
142 self._cur_header = None
143 self._h_header_min = None
144 self._link_locations = {}
146 def render_text_index(
147 self,
148 pdf: FPDF,
149 entries: list[TextIndexEntry],
150 ) -> None:
151 """Renders the text index.
153 Note:
154 Use this method as `render_index_function`-argument in
155 `fpdf2_textindex.pdf.FPDF.insert_index_placeholder`.
157 Args:
158 pdf: The `fpdf2_textindex.pdf.FPDF`-instance to render in.
159 entries: The list of entries to render.
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
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
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 )
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 = {}
193 for entry in entries:
194 if entry.depth > 1:
195 continue
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 )
222 pdf.section_title_styles = prev_section_title_styles
224 LOGGER.info("Rendered text index")
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)
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
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
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
280 next_header = entry.sort_label[0].upper()
281 if next_header == self._cur_header:
282 return
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)
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 )
301 self._cur_header = next_header
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
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
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
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)
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
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
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
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
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
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
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
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]
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
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 )
462 # Label
463 text_pts = [entry.label]
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 )
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 )
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)
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 )
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 )
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
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 )
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
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 )
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)
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} "
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
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 )
653 # Write delimiter
654 if i > 0:
655 yield f"{const.REFS_DELIMITER:s} "
656 i += 1
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
673 if mode == "running_in":
674 yield ")"
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
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 )
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 )
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 )
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 )
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 = " "
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}")
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
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 = "-"
768 # Write separator
769 yield separator
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 )
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
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.
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.
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
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 )
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 )
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 )
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 )