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
« 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
6import bs4
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)
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")
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
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
41 anchor = soup.new_tag("a")
42 __ = tag.insert(0, anchor)
43 return anchor
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.
55 Example of tag:
57 .. code-block:: html
59 <li>
60 <a href="chapter1.xhtml">Chapter 1</a>
61 <ol>[...children]</ol>
62 </li>
63 """
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)] = ""
76 tag_name: ClassVar[str] = "li"
78 @override
79 def create_tag(self):
80 super().create_tag()
81 self.href = self.href
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"]
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)
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)
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)
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
142 tag_name: ClassVar[str] = "nav"
143 new_attrs: ClassVar[dict[str, str]] = {}
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
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)
159 self.create_tag()
161 __ = temporary.replace_with(self.tag)
163 if old_tag.get("id"):
164 self.tag["id"] = old_tag["id"]
166 new_title_tag = self._get_attributes()["title"].get(self.tag) # type: ignore[reportOptionalCall]
168 if old_title_tag and new_title_tag and old_title_tag.get("id"):
169 new_title_tag["id"] = old_title_tag["id"]
171 def insert_self_in_soup(self):
172 if self.soup.main:
173 __ = self.soup.main.insert(0, self.tag)
175 elif self.soup.body:
176 __ = self.soup.body.insert(0, self.tag)
178 else:
179 __ = self.soup.insert(0, self.tag)
181 @property
182 def text(self) -> str | None:
183 return self.title
185 @text.setter
186 def text(self, value: str | None) -> None:
187 self.title = value
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)
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)
209class TocRoot(NavRoot):
210 new_attrs: ClassVar[dict[str, str]] = {
211 epub_type: "toc",
212 "role": "doc-toc",
213 "id": "toc",
214 }
216 def reset(self, entries: Sequence[TOCEntryData]):
217 self.reset_tag()
219 self._items: list[NavItem] = []
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)
231 add_items(self, entries)
234class PageListRoot(NavRoot):
235 new_attrs: ClassVar[dict[str, str]] = {
236 epub_type: "page-list",
237 "id": "page-list",
238 "hidden": "",
239 }
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 )
247 toc = self.soup.select_one('nav[epub|type="toc"]')
248 if toc:
249 __ = toc.insert_after(self.tag)
250 return
252 landmarks = self.soup.select_one('nav[epub|type="landmarks"]')
253 if landmarks:
254 __ = landmarks.insert_before(self.tag)
255 return
257 super().insert_self_in_soup()
259 def reset(self, entries: Sequence[PageBreakData]):
260 self.reset_tag()
262 self._items: list[NavItem] = []
264 for pagebreak in entries:
265 __ = self.add(text=pagebreak.label, filename=pagebreak.filename)
268class LandmarksRoot(NavRoot):
269 new_attrs: ClassVar[dict[str, str]] = {
270 epub_type: "landmarks",
271 "id": "landmarks",
272 "hidden": "",
273 }
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 )
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
286 toc = self.soup.select_one('nav[epub|type="toc"]')
287 if toc:
288 __ = toc.insert_after(self.tag)
289 return
291 super().insert_self_in_soup()
293 def reset(self, entries: Sequence[LandmarkEntryData]):
294 self.reset_tag()
296 self._items: list[NavItem] = []
298 for entry in entries:
299 filename = entry.filename
301 item = self.add(text=entry.label, filename=filename)
303 if entry.epub_type:
304 anchor = item.tag.select_one("& > a")
305 if anchor:
306 anchor[epub_type] = entry.epub_type