Coverage for src/epublib/nav/__init__.py: 100%

152 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-07 10:16 -0300

1from abc import ABC 

2from collections.abc import Sequence 

3from dataclasses import dataclass 

4from typing import Annotated, ClassVar, cast, override 

5 

6import bs4 

7 

8from epublib.nav.util import LandmarkEntryData, PageBreakData, TOCEntryData, epub_type 

9from epublib.xml_element import ( 

10 AttributeValue, 

11 HrefRecursiveElement, 

12 HrefRoot, 

13 SyncType, 

14 XMLAttribute, 

15 XMLChildProtocol, 

16 XMLElement, 

17 XMLParent, 

18) 

19 

20 

21@dataclass(kw_only=True) 

22class NavElement[I: XMLChildProtocol](XMLParent[I], XMLElement, ABC): 

23 @property 

24 @override 

25 def parent_tag(self) -> bs4.Tag | None: 

26 return self.tag.select_one("& > ol") 

27 

28 @override 

29 def create_parent_tag(self) -> bs4.Tag: 

30 ol = self.soup.new_tag("ol") 

31 __ = self.tag.append(ol) 

32 return ol 

33 

34 

35def create_href(soup: bs4.BeautifulSoup, tag: bs4.Tag) -> bs4.Tag: 

36 span = tag.select_one("& > span") 

37 if span: 

38 span.name = "a" 

39 return span 

40 

41 anchor = soup.new_tag("a") 

42 __ = tag.insert(0, anchor) 

43 return anchor 

44 

45 

46@dataclass(kw_only=True) 

47class NavItem( 

48 NavElement["NavItem"], 

49 HrefRecursiveElement["NavItem"], 

50): 

51 """ 

52 A navigation item in the navigation document. The text tag and the 

53 href tag are the same. 

54 

55 Example of tag: 

56 

57 .. code-block:: html 

58 

59 <li> 

60 <a href="chapter1.xhtml">Chapter 1</a> 

61 <ol>[...children]</ol> 

62 </li> 

63 """ 

64 

65 filename: str = "" 

66 text: Annotated[ 

67 str, 

68 XMLAttribute( 

69 sync=SyncType.STRING, 

70 get=lambda tag: tag.select_one("& > span, & > a"), 

71 create=create_href, 

72 ), 

73 ] 

74 href: Annotated[str, XMLAttribute(get="a", create=create_href)] = "" 

75 

76 tag_name: ClassVar[str] = "li" 

77 

78 @override 

79 def create_tag(self): 

80 super().create_tag() 

81 self.href = self.href 

82 

83 @override 

84 def __setattr__(self, name: str, value: AttributeValue | None) -> None: 

85 super().__setattr__(name, value) 

86 if name == "href" or name == "filename": 

87 text_tag = self.tag.select_one("& > span, & > a") 

88 if text_tag and text_tag.name == "a" and not text_tag.get("href"): 

89 text_tag.name = "span" 

90 if "href" in text_tag.attrs: 

91 del text_tag["href"] 

92 

93 @override 

94 def add( # type: ignore[reportIncompatibleMethodOverride] 

95 self, 

96 text: str, 

97 filename: str = "", 

98 href: str = "", 

99 ) -> "NavItem": 

100 return super().add(text=text, filename=filename, href=href) 

101 

102 @override 

103 def insert( # type: ignore[reportIncompatibleMethodOverride] 

104 self, 

105 position: int | None, 

106 text: str, 

107 filename: str = "", 

108 href: str = "", 

109 ) -> "NavItem": 

110 return super().insert(position, text=text, filename=filename, href=href) 

111 

112 @override 

113 def add_after_self( # type: ignore[reportIncompatibleMethodOverride] 

114 self, 

115 text: str, 

116 filename: str = "", 

117 href: str = "", 

118 ) -> "NavItem": 

119 return super().add_after_self(text=text, filename=filename, href=href) 

120 

121 

122@dataclass(kw_only=True) 

123class NavRoot( 

124 NavElement[NavItem], 

125 HrefRoot[NavItem], 

126 ABC, 

127): 

128 title: Annotated[ 

129 str | None, 

130 XMLAttribute( 

131 sync=SyncType.STRING, 

132 get=lambda tag: tag.select_one( 

133 "& > h1, & > h2, & > h3, & > h4, & > h5, & > h6" 

134 ), 

135 create=lambda soup, tag: cast( 

136 bs4.Tag, 

137 tag.insert(0, soup.new_tag("h2" if soup.find("h1") else "h1"))[0], 

138 ), 

139 ), 

140 ] = None 

141 

142 tag_name: ClassVar[str] = "nav" 

143 new_attrs: ClassVar[dict[str, str]] = {} 

144 

145 @override 

146 def create_tag(self): 

147 super().create_tag() 

148 for key, val in self.new_attrs.items(): 

149 self.tag[key] = val 

150 

151 def reset_tag(self): 

152 old_title_tag = cast(bs4.Tag, self._get_attributes()["title"].get(self.tag)) # type: ignore[reportOptionalCall] 

153 old_tag = self.tag 

154 # put temporary in place to detect accurately which heading to 

155 # use for the new title 

156 temporary = self.soup.new_tag("span") 

157 __ = old_tag.replace_with(temporary) 

158 

159 self.create_tag() 

160 

161 __ = temporary.replace_with(self.tag) 

162 

163 if old_tag.get("id"): 

164 self.tag["id"] = old_tag["id"] 

165 

166 new_title_tag = self._get_attributes()["title"].get(self.tag) # type: ignore[reportOptionalCall] 

167 

168 if old_title_tag and new_title_tag and old_title_tag.get("id"): 

169 new_title_tag["id"] = old_title_tag["id"] 

170 

171 def insert_self_in_soup(self): 

172 if self.soup.main: 

173 __ = self.soup.main.insert(0, self.tag) 

174 

175 elif self.soup.body: 

176 __ = self.soup.body.insert(0, self.tag) 

177 

178 else: 

179 __ = self.soup.insert(0, self.tag) 

180 

181 @property 

182 def text(self) -> str | None: 

183 return self.title 

184 

185 @text.setter 

186 def text(self, value: str | None) -> None: 

187 self.title = value 

188 

189 @override 

190 def add( # type: ignore[reportIncompatibleMethodOverride] 

191 self, 

192 text: str, 

193 filename: str = "", 

194 href: str = "", 

195 ) -> NavItem: 

196 return super().add(text=text, filename=filename, href=href) 

197 

198 @override 

199 def insert( # type: ignore[reportIncompatibleMethodOverride] 

200 self, 

201 position: int | None, 

202 text: str, 

203 filename: str = "", 

204 href: str = "", 

205 ) -> NavItem: 

206 return super().insert(position, text=text, filename=filename, href=href) 

207 

208 

209class TocRoot(NavRoot): 

210 new_attrs: ClassVar[dict[str, str]] = { 

211 epub_type: "toc", 

212 "role": "doc-toc", 

213 "id": "toc", 

214 } 

215 

216 def reset(self, entries: Sequence[TOCEntryData]): 

217 self.reset_tag() 

218 

219 self._items: list[NavItem] = [] 

220 

221 def add_items(item: NavItem | NavRoot, children: Sequence[TOCEntryData]): 

222 for entry in children: 

223 if not entry.label.strip(): 

224 continue 

225 filename = entry.filename 

226 if entry.id is not None: 

227 filename += f"#{entry.id}" 

228 added_item = item.add(text=entry.label, filename=filename) 

229 add_items(added_item, entry.children) 

230 

231 add_items(self, entries) 

232 

233 

234class PageListRoot(NavRoot): 

235 new_attrs: ClassVar[dict[str, str]] = { 

236 epub_type: "page-list", 

237 "id": "page-list", 

238 "hidden": "", 

239 } 

240 

241 @override 

242 def insert_self_in_soup(self): 

243 assert not self.soup.select_one('nav[epub|type="page-list"]'), ( 

244 "page list already existent!" 

245 ) 

246 

247 toc = self.soup.select_one('nav[epub|type="toc"]') 

248 if toc: 

249 __ = toc.insert_after(self.tag) 

250 return 

251 

252 landmarks = self.soup.select_one('nav[epub|type="landmarks"]') 

253 if landmarks: 

254 __ = landmarks.insert_before(self.tag) 

255 return 

256 

257 super().insert_self_in_soup() 

258 

259 def reset(self, entries: Sequence[PageBreakData]): 

260 self.reset_tag() 

261 

262 self._items: list[NavItem] = [] 

263 

264 for pagebreak in entries: 

265 __ = self.add(text=pagebreak.label, filename=pagebreak.filename) 

266 

267 

268class LandmarksRoot(NavRoot): 

269 new_attrs: ClassVar[dict[str, str]] = { 

270 epub_type: "landmarks", 

271 "id": "landmarks", 

272 "hidden": "", 

273 } 

274 

275 @override 

276 def insert_self_in_soup(self): 

277 assert not self.soup.select_one('nav[epub|type="landmarks"]'), ( 

278 "landmarks already existent!" 

279 ) 

280 

281 page_list = self.soup.select_one('nav[epub|type="page-list"]') 

282 if page_list: 

283 __ = page_list.insert_after(self.tag) 

284 return 

285 

286 toc = self.soup.select_one('nav[epub|type="toc"]') 

287 if toc: 

288 __ = toc.insert_after(self.tag) 

289 return 

290 

291 super().insert_self_in_soup() 

292 

293 def reset(self, entries: Sequence[LandmarkEntryData]): 

294 self.reset_tag() 

295 

296 self._items: list[NavItem] = [] 

297 

298 for entry in entries: 

299 filename = entry.filename 

300 

301 item = self.add(text=entry.label, filename=filename) 

302 

303 if entry.epub_type: 

304 anchor = item.tag.select_one("& > a") 

305 if anchor: 

306 anchor[epub_type] = entry.epub_type