Coverage for src/epublib/package/spine.py: 96%
79 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 16:07 -0300
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 16:07 -0300
1from dataclasses import dataclass
2from typing import ClassVar, SupportsIndex, override
4import bs4
6from epublib.identifier import EPUBId
7from epublib.xml_element import XMLElement, XMLParent
10@dataclass(kw_only=True)
11class SpineItemRef(XMLElement):
12 """An item reference in the EPUB spine."""
14 id: str | None = None
15 linear: bool | None = None
16 properties: str | None = None
18 obj_to_tag: ClassVar[dict[str, str]] = {"name": "idref"}
20 @property
21 @override
22 def tag_name(self):
23 return "itemref"
25 @property
26 def idref(self) -> EPUBId:
27 return EPUBId(self.name)
29 @idref.setter
30 def idref(self, value: EPUBId):
31 self.name: str = EPUBId(value)
33 @override
34 def create_tag(self, soup: bs4.BeautifulSoup, **kwargs: str) -> bs4.Tag:
35 tag = super().create_tag(soup, **kwargs)
36 if self.linear:
37 del tag["linear"]
39 return tag
41 @override
42 def __post_init__(self):
43 super().__post_init__()
44 self.name = EPUBId(self.name)
47class BookSpine(XMLParent[SpineItemRef]):
48 """The EPUB spine, which defines the linear reading order of the book."""
50 tag_name: str | None = "spine"
51 default_item_type: type[SpineItemRef] = SpineItemRef
53 @override
54 def create_items(self):
55 items: list[SpineItemRef] = []
56 for tag in self.tag.children:
57 if isinstance(tag, bs4.Tag):
58 items.append(SpineItemRef.from_tag(tag))
60 return items
62 def add(self, id_ref: str | EPUBId):
63 __ = self.add_item(SpineItemRef(name=id_ref))
65 def insert(self, position: int, id_ref: str):
66 __ = self.insert_item(position, SpineItemRef(name=id_ref))
68 def get_position(self, idref: str | EPUBId) -> int | None:
69 return next(
70 (i for i, item in enumerate(self.items) if item.idref == idref), None
71 )
73 def remove(self, idref: str | EPUBId):
74 self.remove_item(self[idref])
76 def _move_tag(self, item: SpineItemRef, new_position: int):
77 tags = list(self.tag.select("itemref"))
78 successor = tags[new_position]
79 actual_new_position = self.tag.index(successor)
81 __ = item.tag.extract()
82 __ = self.tag.insert(actual_new_position, item.tag)
84 def move_item(self, item: int | str | SpineItemRef, new_position: int):
85 if isinstance(item, (str, int)):
86 item = self[item]
87 else:
88 if item not in self.items:
89 raise ValueError(f"Item {item} not in spine")
91 self._items.remove(item)
92 self._items.insert(new_position, item)
93 self._move_tag(item, new_position)
95 def reorder(self, items: list[SpineItemRef]):
96 new_items_set = {item.idref for item in items}
97 curr_items_set = {item.idref for item in self.items}
99 if len(new_items_set) != len(items):
100 raise ValueError("Duplicate items in new order")
102 if new_items_set != curr_items_set:
103 raise ValueError("Items do not match current spine items")
105 self._items: list[SpineItemRef] = items
107 self.tag.clear()
108 for item in items:
109 __ = self.tag.append(item.tag)
111 @override
112 def __getitem__(self, name: str | SupportsIndex) -> SpineItemRef:
113 if isinstance(name, SupportsIndex):
114 return self.items[name]
115 return super().__getitem__(name)