Coverage for src/epublib/resources/manager.py: 100%
429 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-07 12:07 -0300
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-07 12:07 -0300
1from abc import ABC, abstractmethod
2from collections.abc import Callable, Generator, Iterable, MutableSequence, Sequence
3from pathlib import Path
4from typing import (
5 Literal,
6 Protocol,
7 TypedDict,
8 Unpack,
9 cast,
10 overload,
11 override,
12 runtime_checkable,
13)
15import bs4
17from epublib.exceptions import EPUBError
18from epublib.identifier import EPUBId
19from epublib.media_type import Category, MediaType
20from epublib.nav.resource import NavigationDocument
21from epublib.ncx.resource import NCXFile
22from epublib.package.guide import BookGuide
23from epublib.package.manifest import BookManifest, ManifestItem
24from epublib.package.metadata import BookMetadata, ValuedMetadataItem
25from epublib.package.resource import PackageDocument, resource_to_manifest_item
26from epublib.package.spine import BookSpine, SpineItemRef
27from epublib.resources import (
28 ContentDocument,
29 PublicationResource,
30 Resource,
31 XMLResource,
32)
33from epublib.resources.window import Window
34from epublib.soup import WithSoupProtocol
35from epublib.util import (
36 attr_to_str,
37 get_absolute_href,
38 get_relative_href,
39 split_fragment,
40 strip_fragment,
41)
44@runtime_checkable
45class SoupChanging(Protocol):
46 def on_soup_change(self) -> None: ...
49type ResourceIdentifier = str | Path | EPUBId | ManifestItem | SpineItemRef
50type ResourceQuery[R: Resource = Resource] = type[R] | MediaType | Category | str
53class AddResourceOptions(TypedDict, total=False):
54 is_cover: bool
55 after: Resource | ResourceIdentifier | None
56 before: Resource | ResourceIdentifier | None
57 add_to_manifest: bool | None
58 identifier: str | EPUBId | None
59 add_to_spine: bool | None
60 spine_position: int | None
61 linear: bool | None
62 add_to_toc: bool | None
63 toc_position: int | None
64 add_to_ncx: bool | None
65 ncx_position: int | None
68def ri_to_filename(
69 identifier: ResourceIdentifier,
70 manifest: BookManifest,
71) -> str:
72 """
73 Convert various resource identifier types to its corresponding filename
74 """
76 if isinstance(identifier, ManifestItem):
77 return identifier.filename
79 if isinstance(identifier, (EPUBId, SpineItemRef)):
80 return manifest[identifier].filename
82 return strip_fragment(str(identifier))
85def ri_to_id(
86 identifier: ResourceIdentifier,
87 manifest: BookManifest,
88) -> EPUBId | None:
89 """
90 Convert various resource identifier types to its corresponding EPUBId
91 """
92 if isinstance(identifier, ManifestItem):
93 return identifier.id
95 if isinstance(identifier, EPUBId):
96 return identifier
98 if isinstance(identifier, SpineItemRef):
99 return identifier.idref
101 manifest_item = manifest.get(identifier)
102 if manifest_item:
103 return manifest_item.id
104 return None
107class GenericResourceManager[T: Resource](MutableSequence[T], ABC):
108 default_reference_attrs: tuple[str, ...] = (
109 "href",
110 "src",
111 "full-path",
112 "xlink:href",
113 )
115 def __init__(
116 self,
117 resources: MutableSequence[T],
118 container_file: XMLResource,
119 package_document: PackageDocument,
120 nav_getter: Callable[[], NavigationDocument],
121 ncx_getter: Callable[[], NCXFile | None] = lambda: None,
122 ):
123 self._resources: MutableSequence[T] = resources
124 self.container_file: XMLResource = container_file
125 self.package_document: PackageDocument = package_document
126 self._get_nav: Callable[[], NavigationDocument] = nav_getter
127 self._get_ncx: Callable[[], NCXFile | None] = ncx_getter
129 def ri_to_filename(self, identifier: ResourceIdentifier) -> str:
130 return ri_to_filename(identifier, self.manifest)
132 def ri_to_id(self, identifier: ResourceIdentifier) -> EPUBId | None:
133 return ri_to_id(identifier, self.manifest)
135 @property
136 def manifest(self) -> BookManifest:
137 return self.package_document.manifest
139 @property
140 def metadata(self) -> BookMetadata:
141 return self.package_document.metadata
143 @property
144 def spine(self) -> BookSpine:
145 return self.package_document.spine
147 @property
148 def guide(self) -> BookGuide | None:
149 return self.package_document.guide
151 @property
152 def ncx(self) -> NCXFile | None:
153 return self._get_ncx()
155 @property
156 def nav(self) -> NavigationDocument:
157 return self._get_nav()
159 @overload
160 def filter(self, query: MediaType | Category | str) -> Generator[T]: ...
162 @overload
163 def filter[R: Resource](self, query: ResourceQuery[R]) -> Generator[R]: ...
165 @overload
166 def filter(self, query: type[T] | None = None) -> Generator[T]: ...
168 def filter(
169 self,
170 query: ResourceQuery[Resource] | None = None,
171 ) -> Generator[Resource]:
172 if query is None:
173 yield from self._resources
174 if isinstance(query, MediaType):
175 yield from (
176 resource
177 for resource in self._resources
178 if isinstance(resource, PublicationResource)
179 and resource.media_type is query
180 )
181 elif isinstance(query, Category):
182 yield from (
183 resource
184 for resource in self._resources
185 if isinstance(resource, PublicationResource)
186 and resource.media_type.category is query
187 )
188 elif isinstance(query, type):
189 yield from (
190 resource for resource in self._resources if isinstance(resource, query)
191 )
193 def get(
194 self,
195 identifier: ResourceIdentifier,
196 query: ResourceQuery[T] | None = None,
197 ) -> T | None:
198 identifier = self.ri_to_filename(identifier)
200 return next(
201 (
202 resource
203 for resource in self.filter(query)
204 if resource.filename == identifier
205 ),
206 None,
207 )
209 @overload
210 def __getitem__(self, identifier: slice) -> MutableSequence[T]: ...
211 @overload
212 def __getitem__(self, identifier: ResourceIdentifier | int) -> T: ...
213 @override
214 def __getitem__( # type: ignore[reportIncompatibleMethodOverride]
215 self, identifier: ResourceIdentifier | int | slice
216 ) -> T | MutableSequence[T]:
217 if isinstance(identifier, (int, slice)):
218 x = self._resources[identifier]
219 return x
221 resource = self.get(identifier)
222 if resource is None:
223 raise KeyError(identifier)
225 return resource
227 @override
228 def __iter__(self) -> Generator[T]:
229 yield from self._resources
231 @override
232 def __reversed__(self) -> Generator[T]:
233 yield from reversed(self._resources)
235 @override
236 def count(self, value: T) -> int:
237 return self._resources.count(value)
239 @override
240 def index(self, value: T, start: int = 0, stop: int | None = None) -> int:
241 kwargs = {"stop": stop} if stop is not None else {}
242 return self._resources.index(value, start, **kwargs)
244 @override
245 def __contains__(self, value: Resource) -> bool: # type: ignore[reportIncompatibleMethodOverride]
246 return value in self._resources
248 @override
249 def __len__(self) -> int:
250 return len(self._resources)
252 @overload
253 def __setitem__(self, index: int, item: T) -> None: ...
254 @overload
255 def __setitem__(self, index: slice, item: Iterable[T]) -> None: ...
256 @override
257 def __setitem__(self, index: int | slice, item: T | Iterable[T]) -> None:
258 self._resources.__setitem__(index, item) # type: ignore[reportArgumentType]
260 @override
261 def __delitem__(self, i: int | slice) -> None:
262 del self._resources[i]
264 def _resolve_position(
265 self,
266 default: int,
267 position: int | None = None,
268 after: Resource | None = None,
269 before: Resource | None = None,
270 ):
271 if after and position is None:
272 try:
273 return self._resources.index(after) + 1
274 except ValueError as error:
275 raise EPUBError(
276 f"resource provided as argument 'after' ('{after}') "
277 "must be part of this epub"
278 ) from error
279 if before and position is None:
280 try:
281 return self._resources.index(before) - 1
282 except ValueError as error:
283 raise EPUBError(
284 f"resource provided as argument 'before' ('{after}') "
285 "must be part of this epub"
286 ) from error
287 if position is not None:
288 return position
289 return default
291 @staticmethod
292 def _should_be_manifested(resource: Resource) -> bool:
293 return Path(resource.filename).parts[0] != "META-INF"
295 @staticmethod
296 def _should_be_in_spine(resource: Resource) -> bool:
297 return isinstance(resource, ContentDocument)
299 @staticmethod
300 def _should_be_spine_linear(_resource: Resource) -> bool:
301 return True
303 def add_to_manifest[R: Resource](
304 self,
305 resource: R,
306 media_type: MediaType | str | None = None,
307 identifier: EPUBId | str | None = None,
308 fallback: str | None = None,
309 media_overlay: str | None = None,
310 is_cover: bool = False,
311 is_nav: bool = False,
312 properties: list[str] | None = None,
313 detect_properties: bool = True,
314 exists_ok: bool = False,
315 ) -> tuple[R, ManifestItem]:
316 """
317 Add a resource to the manifest, if not already present. The
318 resource may be promoted to a PublicationResource if needed, so
319 the resource is returned as well.
320 """
321 manifest_item = self.manifest.get(resource.filename)
322 if manifest_item:
323 if exists_ok:
324 return resource, manifest_item
325 raise EPUBError(f"Resource '{resource.filename}' already in manifest")
327 # Promoting to PublicationResource
328 if not isinstance(resource, PublicationResource):
329 new_resource = PublicationResource.from_resource(resource, media_type)
330 try:
331 index = self._resources.index(resource)
332 self._resources[index] = new_resource # type: ignore[reportArgumentType]
333 except ValueError:
334 pass
336 resource = new_resource
338 manifest_item = resource_to_manifest_item(
339 resource,
340 self.package_document,
341 media_type=media_type,
342 identifier=identifier,
343 fallback=fallback,
344 media_overlay=media_overlay,
345 is_cover=is_cover,
346 is_nav=is_nav,
347 properties=properties,
348 detect_properties=detect_properties,
349 )
350 __ = self.manifest.add_item(manifest_item)
352 return resource, manifest_item
354 def add(
355 self,
356 resource: T,
357 is_cover: bool = False,
358 position: int | None = None,
359 after: Resource | ResourceIdentifier | None = None,
360 before: Resource | ResourceIdentifier | None = None,
361 add_to_manifest: bool | None = None,
362 identifier: str | EPUBId | None = None,
363 add_to_spine: bool | None = None,
364 spine_position: int | None = None,
365 linear: bool | None = None,
366 add_to_toc: bool | None = None,
367 toc_position: int | None = None,
368 add_to_ncx: bool | None = None,
369 ncx_position: int | None = None,
370 ) -> None:
371 is_nav = isinstance(resource, NavigationDocument)
373 if not isinstance(after, Resource) and after is not None:
374 after = self.get(after)
375 if not isinstance(before, Resource) and before is not None:
376 before = self.get(before)
378 position = self._resolve_position(len(self._resources), position, after, before)
379 if position == 0:
380 position += 1 # mimetype must be first resource
382 self._resources.insert(position, resource)
384 if add_to_manifest is False and add_to_spine:
385 raise EPUBError("Cannot add to spine without adding to manifest")
387 if add_to_manifest is False and add_to_toc:
388 raise EPUBError(
389 "Cannot update navigation document without adding to manifest"
390 )
392 if add_to_manifest is None:
393 add_to_manifest = add_to_spine or self._should_be_manifested(resource)
395 if add_to_spine is None:
396 add_to_spine = add_to_manifest and self._should_be_in_spine(resource)
398 if add_to_toc is None:
399 add_to_toc = bool(add_to_spine and self.nav and self.nav.toc)
401 if add_to_ncx and not self.ncx:
402 raise EPUBError.missing_ncx(self, "add_resource", "add_to_ncx")
404 if add_to_ncx is None:
405 add_to_ncx = self.ncx is not None and add_to_toc
407 if ncx_position is None:
408 ncx_position = toc_position
410 manifest_item: None | ManifestItem = None
412 if add_to_manifest:
413 resource, manifest_item = self.add_to_manifest(
414 resource,
415 identifier=identifier,
416 is_cover=is_cover,
417 is_nav=is_nav,
418 exists_ok=False,
419 )
421 if spine_position is None:
422 spine_position = len(self.spine.items)
424 if add_to_spine:
425 if linear is None:
426 linear = self._should_be_spine_linear(resource)
427 spine_item = SpineItemRef(
428 self.spine.soup,
429 idref=manifest_item.id,
430 linear=linear,
431 )
432 __ = self.spine.insert_item(spine_position, spine_item)
434 if add_to_toc and self.nav:
435 assert self.nav.toc
436 __ = self.nav.toc.insert(
437 toc_position,
438 resource.get_title(),
439 resource.filename,
440 )
442 if add_to_ncx and self.ncx:
443 __ = self.ncx.nav_map.insert(
444 ncx_position,
445 resource.get_title(),
446 resource.filename,
447 )
449 @override
450 def insert( # type: ignore[reportIncompatibleMethodOverride]
451 self,
452 position: int,
453 resource: T,
454 **kwargs: Unpack[AddResourceOptions],
455 ) -> None:
456 return self.add(resource, **kwargs, position=position)
458 def append( # type: ignore[reportIncompatibleMethodOverride]
459 self,
460 resource: T,
461 **kwargs: Unpack[AddResourceOptions],
462 ) -> None:
463 return self.add(resource, **kwargs)
465 @override
466 def remove( # type: ignore[reportIncompatibleMethodOverride]
467 self,
468 resource: ResourceIdentifier | T,
469 remove_css_js_links: Literal[False] | None = None,
470 ):
471 """
472 Remove a resource from this EPUB. If it is a CSS or JS file,
473 you can set the remove_css_js_links flag To remove any link
474 from content documents to it.
475 """
477 if not isinstance(resource, Resource):
478 res = self.get(resource)
479 if res is None:
480 raise EPUBError(
481 f"Can't remove resource '{resource}' not in this epub ('{self}')"
482 )
484 resource = res
486 elif resource not in self:
487 raise EPUBError(f"Resource '{resource}' not in EPUB")
489 if resource is self.package_document:
490 raise EPUBError("Can't remove package document")
492 if resource is self.container_file:
493 raise EPUBError("Can't remove container file")
495 if resource is self.nav:
496 raise EPUBError(
497 "Can't remove navigation document. Set the navigation "
498 "document to another resource or first."
499 )
501 self.nav.remove(resource.filename)
503 if self.ncx and resource is not self.ncx:
504 self.ncx.remove(resource.filename)
506 if self.guide and self.guide.get(resource.filename):
507 self.guide.remove(resource.filename)
509 remove_links: bool | None = remove_css_js_links
510 if remove_links is None:
511 remove_links = isinstance(resource, PublicationResource) and (
512 resource.media_type.is_css() or resource.media_type.is_js()
513 )
515 self.package_document.remove(resource.filename)
516 self._resources.remove(resource)
518 if remove_links:
519 if not isinstance(resource, PublicationResource) or not (
520 resource.media_type.is_css() or resource.media_type.is_js()
521 ):
522 raise EPUBError(
523 "Can't remove CSS and JavaScript links for file "
524 "that is neither CSS nor JavaScript"
525 )
527 for res in self.filter(ContentDocument):
528 relative_href = get_relative_href(res.filename, resource.filename)
529 for tag in res.soup.find_all(
530 "link",
531 rel="stylesheet",
532 href=relative_href,
533 ):
534 tag.decompose()
535 for tag in res.soup.find_all(
536 "script",
537 src=relative_href,
538 ):
539 tag.decompose()
541 def rename(
542 self,
543 resource: ResourceIdentifier | T,
544 new_filename: str | Path,
545 update_references: bool = True,
546 reference_attrs: list[str] | None = None,
547 ):
548 """
549 Rename the resource, optionally updating references to it
550 """
552 if not isinstance(resource, Resource):
553 res = self.get(resource)
554 if res is None:
555 raise EPUBError(
556 f"Can't rename resource '{resource}' not in this epub ('{self}')"
557 )
559 resource = res
561 elif resource not in self:
562 raise EPUBError(
563 f"Can't rename resource '{resource}' not in this epub ('{self}')"
564 )
566 if resource is self.container_file:
567 raise EPUBError("Can't rename container file")
569 if reference_attrs is None:
570 reference_attrs = list(self.default_reference_attrs)
572 selector = ", ".join(f"[{attr.replace(':', '|')}]" for attr in reference_attrs)
574 new_filename = str(new_filename)
576 other_resources = (
577 self.filter(XMLResource)
578 if update_references
579 else [self.package_document, *([self.ncx] if self.ncx else [])]
580 )
581 for other_resource in other_resources:
582 if other_resource is resource:
583 # If file moves to different folder, all refs must be updated
584 if Path(new_filename).parent != Path(resource.filename).parent:
585 soup = cast(bs4.BeautifulSoup, resource.soup)
586 for tag in soup.select(selector):
587 for attr in reference_attrs:
588 value = attr_to_str(tag.get(attr))
589 if value is None:
590 continue
591 ref, identifier = split_fragment(value)
592 if ref:
593 old_absolute_ref = get_absolute_href(
594 resource.filename,
595 ref,
596 )
597 if old_absolute_ref == resource.filename:
598 new_ref = get_relative_href(
599 new_filename,
600 new_filename,
601 )
602 else:
603 new_ref = get_relative_href(
604 new_filename,
605 old_absolute_ref,
606 )
608 tag[attr] = new_ref + (
609 f"#{identifier}" if identifier else ""
610 )
612 for tag in other_resource.soup.select(selector):
613 for attr in reference_attrs:
614 value = attr_to_str(tag.get(attr))
615 if value is None:
616 continue
617 if attr == "full-path":
618 if resource.filename == value:
619 tag[attr] = new_filename
620 else:
621 ref, identifier = split_fragment(value)
622 old_absolute_ref = get_absolute_href(
623 other_resource.filename,
624 ref,
625 )
626 if old_absolute_ref == resource.filename:
627 new_ref = get_relative_href(
628 other_resource.filename,
629 new_filename,
630 )
631 tag[attr] = new_ref + (
632 f"#{identifier}" if identifier else ""
633 )
635 if isinstance(other_resource, SoupChanging):
636 other_resource.on_soup_change()
638 resource.filename = new_filename
640 @overload
641 def resolve_href[R: Resource](
642 self,
643 href: str,
644 with_tag: Literal[False],
645 relative_to: Resource | ResourceIdentifier | None,
646 query: ResourceQuery[R],
647 ) -> R | None: ...
648 @overload
649 def resolve_href(
650 self,
651 href: str,
652 with_tag: Literal[False],
653 relative_to: T | ResourceIdentifier | None = None,
654 query: None = None,
655 ) -> T | None: ...
656 @overload
657 def resolve_href(
658 self,
659 href: str,
660 with_tag: Literal[True] = True,
661 relative_to: Resource | ResourceIdentifier | None = None,
662 query: None = None,
663 ) -> tuple[T, bs4.Tag | None] | tuple[None, None]: ...
664 @overload
665 def resolve_href[R: Resource](
666 self,
667 href: str,
668 with_tag: Literal[True] = True,
669 relative_to: Resource | ResourceIdentifier | None = None,
670 query: ResourceQuery[R] | None = None,
671 ) -> tuple[R, bs4.Tag | None] | tuple[None, None]: ...
672 @overload
673 def resolve_href[R: Resource](
674 self,
675 href: str,
676 with_tag: bool = True,
677 relative_to: Resource | ResourceIdentifier | None = None,
678 query: ResourceQuery[R] | None = None,
679 ) -> tuple[R, bs4.Tag | None] | tuple[None, None] | R | None: ...
681 def resolve_href( # type: ignore[reportInconsistentOverload]
682 self,
683 href: str,
684 with_tag: bool = True,
685 relative_to: Resource | ResourceIdentifier | None = None,
686 query: ResourceQuery[T] | None = None,
687 ) -> tuple[T, bs4.Tag | None] | tuple[None, None] | T | None:
688 """
689 Resolve an href (possibly with a fragment identifier) to a
690 resource. Optionally return the tag of the matched fragment
691 within that resource.
692 """
694 filename = href
695 if relative_to is not None:
696 if isinstance(relative_to, Resource):
697 relative_to = relative_to.filename
698 else:
699 relative_to = self.ri_to_filename(relative_to)
701 filename = get_absolute_href(relative_to, href)
703 filename, identifier = split_fragment(filename)
704 resource = self.get(filename, query)
706 if not with_tag:
707 return resource
709 if resource is None:
710 return None, None
712 if isinstance(resource, WithSoupProtocol):
713 return cast(T, resource), resource.soup.select_one(
714 f'[id="{identifier}"]'
715 ) if identifier is not None else None
717 return resource, None
719 def set_cover_image(self, resource: ResourceIdentifier | Resource) -> None:
720 """
721 Set the cover image of this EPUB. The resource must be an image
722 and will be added to the manifest if not already present.
723 """
725 if not isinstance(resource, Resource):
726 res = self.get(resource)
727 if res is None:
728 raise EPUBError(
729 f"Can't set cover image to resource '{resource}' not in this epub ('{self}')"
730 )
732 resource = res
734 elif resource not in self:
735 raise EPUBError(f"Resource '{resource}' not in EPUB")
737 if (
738 not isinstance(resource, PublicationResource)
739 or resource.media_type.category is not Category.IMAGE
740 ):
741 raise EPUBError("Cover image must be an image")
743 manifest_item = self.manifest[resource]
744 for other in self.manifest.items:
745 other.remove_property("cover-image")
746 manifest_item.add_property("cover-image")
748 metadata_item = self.metadata.get("cover", ValuedMetadataItem)
749 if manifest_item and metadata_item:
750 metadata_item.value = manifest_item.id
752 def tags_referencing(
753 self,
754 filename: str | Path,
755 reference_attrs: Sequence[str] | None = None,
756 ignore_fragment: bool = False,
757 ) -> Generator[tuple[ContentDocument, bs4.Tag]]:
758 """
759 Find all tags in content documents that reference the given filename.
760 """
762 filename = str(filename)
764 if reference_attrs is None:
765 reference_attrs = list(self.default_reference_attrs)
766 reference_attrs.remove("full-path")
768 for document in self.filter(ContentDocument):
769 relative_href = get_relative_href(document.filename, filename)
770 if ignore_fragment:
771 relative_href = strip_fragment(relative_href)
772 for attr in reference_attrs:
773 for tag in document.soup.find_all(attrs={attr: True}):
774 value = attr_to_str(tag[attr])
775 value = strip_fragment(value) if value else value
776 if value == relative_href:
777 yield document, tag
779 @override
780 def __repr__(self) -> str:
781 return f"{self.__class__.__name__}({len(self)} resources)"
784class ResourceManager(GenericResourceManager[Resource]):
785 @overload
786 def filter(
787 self, query: Literal[MediaType.XHTML, MediaType.IMAGE_SVG]
788 ) -> Generator[ContentDocument]: ...
789 @overload
790 def filter(self, query: Literal[MediaType.NCX]) -> Generator[NCXFile]: ...
791 @overload
792 def filter(self, query: MediaType | Category) -> Generator[PublicationResource]: ...
793 @overload
794 def filter[R: Resource](self, query: ResourceQuery[R]) -> Generator[R]: ...
795 @overload
796 def filter(
797 self, query: ResourceQuery[Resource] | None = None
798 ) -> Generator[Resource]: ...
800 @override
801 def filter( # type: ignore[reportIncompatibleMethodOverride]
802 self,
803 query: ResourceQuery[Resource] | None = None,
804 ) -> Generator[Resource]:
805 return super().filter(query)
807 @overload
808 def get[R: PublicationResource](
809 self, identifier: EPUBId | ManifestItem, query: type[R]
810 ) -> R | None: ...
811 @overload
812 def get(
813 self,
814 identifier: EPUBId | ManifestItem | SpineItemRef,
815 query: ResourceQuery[PublicationResource] = PublicationResource,
816 ) -> PublicationResource | None: ...
817 @overload
818 def get[R: Resource](self, identifier: str | Path, query: type[R]) -> R | None: ...
819 @overload
820 def get(
821 self,
822 identifier: str | Path,
823 query: ResourceQuery[Resource] = Resource,
824 ) -> Resource | None: ...
825 @override
826 def get(
827 self,
828 identifier: ResourceIdentifier,
829 query: ResourceQuery[Resource] | None = None,
830 ) -> Resource | None:
831 return super().get(identifier, query)
833 @overload
834 def resolve_href[R: Resource](
835 self,
836 href: str,
837 with_tag: Literal[False],
838 relative_to: Resource | ResourceIdentifier | None,
839 query: ResourceQuery[R],
840 ) -> R | None: ...
841 @overload
842 def resolve_href(
843 self,
844 href: str,
845 with_tag: Literal[False],
846 relative_to: Resource | ResourceIdentifier | None = None,
847 query: None = None,
848 ) -> Resource | None: ...
849 @overload
850 def resolve_href(
851 self,
852 href: str,
853 with_tag: Literal[True] = True,
854 relative_to: Resource | ResourceIdentifier | None = None,
855 query: None = None,
856 ) -> tuple[XMLResource, bs4.Tag] | tuple[Resource, None] | tuple[None, None]: ...
857 @overload
858 def resolve_href[R: Resource](
859 self,
860 href: str,
861 with_tag: Literal[True] = True,
862 relative_to: Resource | ResourceIdentifier | None = None,
863 query: ResourceQuery[R] | None = None,
864 ) -> tuple[R, bs4.Tag | None] | tuple[None, None]: ...
865 @override
866 def resolve_href( # type: ignore[reportIncompatibleMethodOverride]
867 self,
868 href: str,
869 with_tag: bool = True,
870 relative_to: Resource | ResourceIdentifier | None = None,
871 query: ResourceQuery[Resource] | None = None,
872 ):
873 return super().resolve_href(href, with_tag, relative_to, query)
875 @overload
876 def __getitem__(self, identifier: slice) -> MutableSequence[Resource]: ...
877 @overload
878 def __getitem__(self, identifier: str | Path | int) -> Resource: ...
879 @overload
880 def __getitem__(
881 self, identifier: EPUBId | ManifestItem | SpineItemRef
882 ) -> PublicationResource: ...
883 @override
884 def __getitem__( # type: ignore[reportIncompatibleMethodOverride]
885 self, identifier: ResourceIdentifier | int | slice
886 ) -> Resource | MutableSequence[Resource]:
887 return super().__getitem__(identifier)
889 @property
890 def cover_image(self) -> PublicationResource | None:
891 manifest_item = self.manifest.cover_image
892 return self.get(manifest_item) if manifest_item else None
895class WindowedResourceManager[T: PublicationResource](GenericResourceManager[T], ABC):
896 base_class: type[T]
898 def _is_in_window(self, resource: Resource) -> bool:
899 return isinstance(resource, self.base_class)
901 @abstractmethod
902 def _error_message(self, resource: Resource) -> str: ...
904 def __init__(
905 self,
906 resources: MutableSequence[Resource],
907 container_file: XMLResource,
908 package_document: PackageDocument,
909 nav_getter: Callable[[], NavigationDocument],
910 ncx_getter: Callable[[], NCXFile | None] = lambda: None,
911 ):
912 super().__init__(
913 Window[Resource, T](
914 resources,
915 self._is_in_window,
916 self._error_message,
917 ),
918 container_file,
919 package_document,
920 nav_getter,
921 ncx_getter,
922 )
924 @override
925 def __repr__(self) -> str:
926 return f"{self.__class__.__name__}[{self.base_class}]({len(self)} {self.base_class.__name__} resources)"
929class PublicationResourceManager(WindowedResourceManager[PublicationResource]):
930 base_class: type[PublicationResource] = PublicationResource
932 @override
933 def _error_message(self, resource: Resource) -> str:
934 return f"Resource '{resource}' is not a publication resource"
937class ImagesManager(PublicationResourceManager):
938 @override
939 def _is_in_window(self, resource: Resource) -> bool:
940 return (
941 isinstance(resource, PublicationResource)
942 and resource.media_type.category is Category.IMAGE
943 )
945 @override
946 def _error_message(self, resource: Resource) -> str:
947 return f"Resource '{resource}' is not an image"
949 @property
950 def cover(self) -> PublicationResource | None:
951 manifest_item = self.manifest.cover_image
952 return self.get(manifest_item) if manifest_item else None
955class ScriptsManager(PublicationResourceManager):
956 @override
957 def _is_in_window(self, resource: Resource) -> bool:
958 return isinstance(resource, PublicationResource) and resource.media_type.is_js()
960 @override
961 def _error_message(self, resource: Resource) -> str:
962 return f"Resource '{resource}' is not a script"
965class StylesManager(PublicationResourceManager):
966 @override
967 def _is_in_window(self, resource: Resource) -> bool:
968 return (
969 isinstance(resource, PublicationResource) and resource.media_type.is_css()
970 )
972 @override
973 def _error_message(self, resource: Resource) -> str:
974 return f"Resource '{resource}' is not a style"
977class FontsManager(PublicationResourceManager):
978 @override
979 def _is_in_window(self, resource: Resource) -> bool:
980 return (
981 isinstance(resource, PublicationResource)
982 and resource.media_type.category is Category.FONT
983 )
985 @override
986 def _error_message(self, resource: Resource) -> str:
987 return f"Resource '{resource}' is not a font"
990class AudioManager(PublicationResourceManager):
991 @override
992 def _is_in_window(self, resource: Resource) -> bool:
993 return (
994 isinstance(resource, PublicationResource)
995 and resource.media_type.category is Category.AUDIO
996 )
998 @override
999 def _error_message(self, resource: Resource) -> str:
1000 return f"Resource '{resource}' is not audio"
1003class VideoManager(PublicationResourceManager):
1004 @override
1005 def _is_in_window(self, resource: Resource) -> bool:
1006 return (
1007 isinstance(resource, PublicationResource) and resource.media_type.is_video()
1008 )
1010 @override
1011 def _error_message(self, resource: Resource) -> str:
1012 return f"Resource '{resource}' is not video"
1015class ContentDocumentManager(WindowedResourceManager[ContentDocument]):
1016 base_class: type[ContentDocument] = ContentDocument
1018 @override
1019 def _error_message(self, resource: Resource) -> str:
1020 return f"Resource '{resource}' is not a content document"