Coverage for fpdf2_textindex/interface/entry.py: 68.82%
75 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 Entry."""
3from __future__ import annotations
5import bisect
6import dataclasses
7from typing import Protocol, runtime_checkable
9from fpdf2_textindex import constants as const
10from fpdf2_textindex.constants import LOGGER
11from fpdf2_textindex.errors import FPDF2TextindexError
12from fpdf2_textindex.interface.cross_reference import CrossReference
13from fpdf2_textindex.interface.enums import CrossReferenceType
14from fpdf2_textindex.interface.label_path import LabelPathT
15from fpdf2_textindex.interface.node import Node
16from fpdf2_textindex.interface.reference import Reference
17from fpdf2_textindex.md_emphasis import MDEmphasis
20@runtime_checkable
21class TextIndexEntryP(Protocol):
22 """Text Index Protocol."""
24 @property
25 def depth(self) -> int:
26 """The depth of the entry."""
27 ...
29 @property
30 def label(self) -> str | None:
31 """The label of the entry."""
32 ...
34 @property
35 def sort_label(self) -> str:
36 """The sort label of the entry."""
37 ...
40@dataclasses.dataclass(kw_only=True, repr=False, slots=True)
41class TextIndexEntry(Node):
42 """Text Index Entry."""
44 _references: list[Reference] = dataclasses.field(
45 default_factory=list, init=False
46 )
47 """The references."""
49 _cross_references: list[CrossReference] = dataclasses.field(
50 default_factory=list, init=False
51 )
52 """The cross references."""
54 sort_key: str | None = dataclasses.field(default=None, init=False)
55 """The sort key."""
57 def __hash__(self) -> int:
58 return hash((self.id, self.label))
60 @property
61 def cross_references(self) -> list[CrossReference]:
62 """The cross references."""
63 return self._cross_references.copy()
65 @property
66 def references(self) -> list[Reference]:
67 """The references."""
68 return self._references.copy()
70 @property
71 def sort_label(self) -> str:
72 """The sort label of the entry."""
73 label = MDEmphasis.remove(self.label)
74 if not label: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true
75 label = const._LAST_SORT_LABEL
76 if self.sort_key:
77 label = self.sort_key + label
78 return label.lower()
80 @property
81 def sorted_children(self) -> list[TextIndexEntry]:
82 """The child entries sorted by its sort label."""
83 return sorted(self._children, key=lambda c: c.sort_label)
85 def add_cross_reference(
86 self,
87 id: int,
88 cross_ref_type: CrossReferenceType,
89 label_path: LabelPathT,
90 *,
91 strict: bool = True,
92 ) -> None:
93 """Adds a cross reference to the entry.
95 Args:
96 id: The id of the cross reference.
97 cross_ref_type: The type of the cross reference.
98 label_path: The label path of the cross reference.
99 strict: Whether to raise a `FPDF2TextindexError` if adding a
100 SEE-cross reference to an entry with former "normal" reference
101 (locator). Else, it will just be a warning and the SEE-cross
102 reference will be automatically converted to SEE ALSO. Defaults
103 to `True`.
105 Raises:
106 FPDF2TextindexError: If `strict=True` and adding a SEE-cross
107 reference to an entry with former "normal" reference (locator).
108 """
109 cref = CrossReference(id=id, type=cross_ref_type, label_path=label_path) # type: ignore[arg-type]
110 if self._references and cref.type == CrossReferenceType.SEE: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true
111 if strict:
112 msg = (
113 f"cannot add a SEE-cross reference to entry "
114 f"{self.joined_label_path!r} with former reference "
115 f"(locator)"
116 )
117 raise FPDF2TextindexError(msg)
118 LOGGER.warning(
119 "Adding a SEE-cross reference to entry %r with former "
120 "reference (locator); cross reference will be converted to SEE "
121 "ALSO",
122 self.joined_label_path,
123 )
124 cref.type = CrossReferenceType.ALSO
125 bisect.insort(
126 self._cross_references,
127 cref,
128 key=lambda cr: (cr.type, *cr.label_path),
129 )
131 def add_reference(
132 self,
133 start_id: int,
134 *,
135 locator_emphasis: bool = False,
136 start_suffix: str | None = None,
137 strict: bool = True,
138 ) -> None:
139 """Adds a reference (locator) to the entry.
141 Args:
142 start_id: The start id of the reference.
143 locator_emphasis: Whether to emphasize the locator of the reference.
144 Defaults to `False`.
145 start_suffix: The start suffix of the reference. Defaults to
146 `None`.
147 strict: Whether to raise a `FPDF2TextindexError` if adding a
148 SEE-cross reference to an entry with former "normal" reference
149 (locator). Else, it will just be a warning and the SEE-cross
150 reference will be automatically converted to SEE ALSO. Defaults
151 to `True`.
153 Raises:
154 FPDF2TextindexError: If `strict=True` and adding a reference locator
155 to an entry with former SEE-cross reference.
156 """
157 ref = Reference(
158 start_id=start_id,
159 start_suffix=start_suffix,
160 locator_emphasis=locator_emphasis,
161 )
162 if any( 162 ↛ 166line 162 didn't jump to line 166 because the condition on line 162 was never true
163 cref.type == CrossReferenceType.SEE
164 for cref in self._cross_references
165 ):
166 if strict:
167 msg = (
168 f"cannot add a reference (locator) to entry "
169 f"{self.joined_label_path!r} with former SEE-cross "
170 f"reference"
171 )
172 raise FPDF2TextindexError(msg)
173 LOGGER.warning(
174 "Adding a reference (locator) to entry %r with former SEE-"
175 "cross reference(s); cross reference(s) will be converted to "
176 "SEE ALSO",
177 self.joined_label_path,
178 )
179 for cref in self._cross_references:
180 if cref.type == CrossReferenceType.SEE:
181 cref.type = CrossReferenceType.ALSO
182 self._cross_references.sort(
183 key=lambda cr: (cr.type, *cr.label_path)
184 )
185 # Note: Not sorting here because order of insertion matters for
186 # `update_latest_reference_end`
187 self._references.append(ref)
189 def update_latest_reference_end(
190 self,
191 end_id: int,
192 end_suffix: str | None = None,
193 ) -> None:
194 """Updates the end of the latest reference.
196 Args:
197 end_id: The end id of the latest reference.
198 end_suffix: The end suffix of the latest reference. Defaults to
199 `None`.
201 Raises:
202 FPDF2TextindexError: If there has been no reference started before.
203 """
204 if len(self._references) == 0: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true
205 msg = "cannot update latest reference end without reference"
206 raise FPDF2TextindexError(msg)
207 self._references[-1].end_id = end_id
208 self._references[-1].end_suffix = end_suffix