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

1from dataclasses import dataclass 

2from typing import ClassVar, SupportsIndex, override 

3 

4import bs4 

5 

6from epublib.identifier import EPUBId 

7from epublib.xml_element import XMLElement, XMLParent 

8 

9 

10@dataclass(kw_only=True) 

11class SpineItemRef(XMLElement): 

12 """An item reference in the EPUB spine.""" 

13 

14 id: str | None = None 

15 linear: bool | None = None 

16 properties: str | None = None 

17 

18 obj_to_tag: ClassVar[dict[str, str]] = {"name": "idref"} 

19 

20 @property 

21 @override 

22 def tag_name(self): 

23 return "itemref" 

24 

25 @property 

26 def idref(self) -> EPUBId: 

27 return EPUBId(self.name) 

28 

29 @idref.setter 

30 def idref(self, value: EPUBId): 

31 self.name: str = EPUBId(value) 

32 

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"] 

38 

39 return tag 

40 

41 @override 

42 def __post_init__(self): 

43 super().__post_init__() 

44 self.name = EPUBId(self.name) 

45 

46 

47class BookSpine(XMLParent[SpineItemRef]): 

48 """The EPUB spine, which defines the linear reading order of the book.""" 

49 

50 tag_name: str | None = "spine" 

51 default_item_type: type[SpineItemRef] = SpineItemRef 

52 

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

59 

60 return items 

61 

62 def add(self, id_ref: str | EPUBId): 

63 __ = self.add_item(SpineItemRef(name=id_ref)) 

64 

65 def insert(self, position: int, id_ref: str): 

66 __ = self.insert_item(position, SpineItemRef(name=id_ref)) 

67 

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 ) 

72 

73 def remove(self, idref: str | EPUBId): 

74 self.remove_item(self[idref]) 

75 

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) 

80 

81 __ = item.tag.extract() 

82 __ = self.tag.insert(actual_new_position, item.tag) 

83 

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

90 

91 self._items.remove(item) 

92 self._items.insert(new_position, item) 

93 self._move_tag(item, new_position) 

94 

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} 

98 

99 if len(new_items_set) != len(items): 

100 raise ValueError("Duplicate items in new order") 

101 

102 if new_items_set != curr_items_set: 

103 raise ValueError("Items do not match current spine items") 

104 

105 self._items: list[SpineItemRef] = items 

106 

107 self.tag.clear() 

108 for item in items: 

109 __ = self.tag.append(item.tag) 

110 

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)