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

1"""Text Index Entry.""" 

2 

3from __future__ import annotations 

4 

5import bisect 

6import dataclasses 

7from typing import Protocol, runtime_checkable 

8 

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 

18 

19 

20@runtime_checkable 

21class TextIndexEntryP(Protocol): 

22 """Text Index Protocol.""" 

23 

24 @property 

25 def depth(self) -> int: 

26 """The depth of the entry.""" 

27 ... 

28 

29 @property 

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

31 """The label of the entry.""" 

32 ... 

33 

34 @property 

35 def sort_label(self) -> str: 

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

37 ... 

38 

39 

40@dataclasses.dataclass(kw_only=True, repr=False, slots=True) 

41class TextIndexEntry(Node): 

42 """Text Index Entry.""" 

43 

44 _references: list[Reference] = dataclasses.field( 

45 default_factory=list, init=False 

46 ) 

47 """The references.""" 

48 

49 _cross_references: list[CrossReference] = dataclasses.field( 

50 default_factory=list, init=False 

51 ) 

52 """The cross references.""" 

53 

54 sort_key: str | None = dataclasses.field(default=None, init=False) 

55 """The sort key.""" 

56 

57 def __hash__(self) -> int: 

58 return hash((self.id, self.label)) 

59 

60 @property 

61 def cross_references(self) -> list[CrossReference]: 

62 """The cross references.""" 

63 return self._cross_references.copy() 

64 

65 @property 

66 def references(self) -> list[Reference]: 

67 """The references.""" 

68 return self._references.copy() 

69 

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() 

79 

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) 

84 

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. 

94 

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`. 

104 

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 ) 

130 

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. 

140 

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`. 

152 

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) 

188 

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. 

195 

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`. 

200 

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