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

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) 

14 

15import bs4 

16 

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) 

42 

43 

44@runtime_checkable 

45class SoupChanging(Protocol): 

46 def on_soup_change(self) -> None: ... 

47 

48 

49type ResourceIdentifier = str | Path | EPUBId | ManifestItem | SpineItemRef 

50type ResourceQuery[R: Resource = Resource] = type[R] | MediaType | Category | str 

51 

52 

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 

66 

67 

68def ri_to_filename( 

69 identifier: ResourceIdentifier, 

70 manifest: BookManifest, 

71) -> str: 

72 """ 

73 Convert various resource identifier types to its corresponding filename 

74 """ 

75 

76 if isinstance(identifier, ManifestItem): 

77 return identifier.filename 

78 

79 if isinstance(identifier, (EPUBId, SpineItemRef)): 

80 return manifest[identifier].filename 

81 

82 return strip_fragment(str(identifier)) 

83 

84 

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 

94 

95 if isinstance(identifier, EPUBId): 

96 return identifier 

97 

98 if isinstance(identifier, SpineItemRef): 

99 return identifier.idref 

100 

101 manifest_item = manifest.get(identifier) 

102 if manifest_item: 

103 return manifest_item.id 

104 return None 

105 

106 

107class GenericResourceManager[T: Resource](MutableSequence[T], ABC): 

108 default_reference_attrs: tuple[str, ...] = ( 

109 "href", 

110 "src", 

111 "full-path", 

112 "xlink:href", 

113 ) 

114 

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 

128 

129 def ri_to_filename(self, identifier: ResourceIdentifier) -> str: 

130 return ri_to_filename(identifier, self.manifest) 

131 

132 def ri_to_id(self, identifier: ResourceIdentifier) -> EPUBId | None: 

133 return ri_to_id(identifier, self.manifest) 

134 

135 @property 

136 def manifest(self) -> BookManifest: 

137 return self.package_document.manifest 

138 

139 @property 

140 def metadata(self) -> BookMetadata: 

141 return self.package_document.metadata 

142 

143 @property 

144 def spine(self) -> BookSpine: 

145 return self.package_document.spine 

146 

147 @property 

148 def guide(self) -> BookGuide | None: 

149 return self.package_document.guide 

150 

151 @property 

152 def ncx(self) -> NCXFile | None: 

153 return self._get_ncx() 

154 

155 @property 

156 def nav(self) -> NavigationDocument: 

157 return self._get_nav() 

158 

159 @overload 

160 def filter(self, query: MediaType | Category | str) -> Generator[T]: ... 

161 

162 @overload 

163 def filter[R: Resource](self, query: ResourceQuery[R]) -> Generator[R]: ... 

164 

165 @overload 

166 def filter(self, query: type[T] | None = None) -> Generator[T]: ... 

167 

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 ) 

192 

193 def get( 

194 self, 

195 identifier: ResourceIdentifier, 

196 query: ResourceQuery[T] | None = None, 

197 ) -> T | None: 

198 identifier = self.ri_to_filename(identifier) 

199 

200 return next( 

201 ( 

202 resource 

203 for resource in self.filter(query) 

204 if resource.filename == identifier 

205 ), 

206 None, 

207 ) 

208 

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 

220 

221 resource = self.get(identifier) 

222 if resource is None: 

223 raise KeyError(identifier) 

224 

225 return resource 

226 

227 @override 

228 def __iter__(self) -> Generator[T]: 

229 yield from self._resources 

230 

231 @override 

232 def __reversed__(self) -> Generator[T]: 

233 yield from reversed(self._resources) 

234 

235 @override 

236 def count(self, value: T) -> int: 

237 return self._resources.count(value) 

238 

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) 

243 

244 @override 

245 def __contains__(self, value: Resource) -> bool: # type: ignore[reportIncompatibleMethodOverride] 

246 return value in self._resources 

247 

248 @override 

249 def __len__(self) -> int: 

250 return len(self._resources) 

251 

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] 

259 

260 @override 

261 def __delitem__(self, i: int | slice) -> None: 

262 del self._resources[i] 

263 

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 

290 

291 @staticmethod 

292 def _should_be_manifested(resource: Resource) -> bool: 

293 return Path(resource.filename).parts[0] != "META-INF" 

294 

295 @staticmethod 

296 def _should_be_in_spine(resource: Resource) -> bool: 

297 return isinstance(resource, ContentDocument) 

298 

299 @staticmethod 

300 def _should_be_spine_linear(_resource: Resource) -> bool: 

301 return True 

302 

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

326 

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 

335 

336 resource = new_resource 

337 

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) 

351 

352 return resource, manifest_item 

353 

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) 

372 

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) 

377 

378 position = self._resolve_position(len(self._resources), position, after, before) 

379 if position == 0: 

380 position += 1 # mimetype must be first resource 

381 

382 self._resources.insert(position, resource) 

383 

384 if add_to_manifest is False and add_to_spine: 

385 raise EPUBError("Cannot add to spine without adding to manifest") 

386 

387 if add_to_manifest is False and add_to_toc: 

388 raise EPUBError( 

389 "Cannot update navigation document without adding to manifest" 

390 ) 

391 

392 if add_to_manifest is None: 

393 add_to_manifest = add_to_spine or self._should_be_manifested(resource) 

394 

395 if add_to_spine is None: 

396 add_to_spine = add_to_manifest and self._should_be_in_spine(resource) 

397 

398 if add_to_toc is None: 

399 add_to_toc = bool(add_to_spine and self.nav and self.nav.toc) 

400 

401 if add_to_ncx and not self.ncx: 

402 raise EPUBError.missing_ncx(self, "add_resource", "add_to_ncx") 

403 

404 if add_to_ncx is None: 

405 add_to_ncx = self.ncx is not None and add_to_toc 

406 

407 if ncx_position is None: 

408 ncx_position = toc_position 

409 

410 manifest_item: None | ManifestItem = None 

411 

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 ) 

420 

421 if spine_position is None: 

422 spine_position = len(self.spine.items) 

423 

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) 

433 

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 ) 

441 

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 ) 

448 

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) 

457 

458 def append( # type: ignore[reportIncompatibleMethodOverride] 

459 self, 

460 resource: T, 

461 **kwargs: Unpack[AddResourceOptions], 

462 ) -> None: 

463 return self.add(resource, **kwargs) 

464 

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

476 

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 ) 

483 

484 resource = res 

485 

486 elif resource not in self: 

487 raise EPUBError(f"Resource '{resource}' not in EPUB") 

488 

489 if resource is self.package_document: 

490 raise EPUBError("Can't remove package document") 

491 

492 if resource is self.container_file: 

493 raise EPUBError("Can't remove container file") 

494 

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 ) 

500 

501 self.nav.remove(resource.filename) 

502 

503 if self.ncx and resource is not self.ncx: 

504 self.ncx.remove(resource.filename) 

505 

506 if self.guide and self.guide.get(resource.filename): 

507 self.guide.remove(resource.filename) 

508 

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 ) 

514 

515 self.package_document.remove(resource.filename) 

516 self._resources.remove(resource) 

517 

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 ) 

526 

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

540 

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

551 

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 ) 

558 

559 resource = res 

560 

561 elif resource not in self: 

562 raise EPUBError( 

563 f"Can't rename resource '{resource}' not in this epub ('{self}')" 

564 ) 

565 

566 if resource is self.container_file: 

567 raise EPUBError("Can't rename container file") 

568 

569 if reference_attrs is None: 

570 reference_attrs = list(self.default_reference_attrs) 

571 

572 selector = ", ".join(f"[{attr.replace(':', '|')}]" for attr in reference_attrs) 

573 

574 new_filename = str(new_filename) 

575 

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 ) 

607 

608 tag[attr] = new_ref + ( 

609 f"#{identifier}" if identifier else "" 

610 ) 

611 

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 ) 

634 

635 if isinstance(other_resource, SoupChanging): 

636 other_resource.on_soup_change() 

637 

638 resource.filename = new_filename 

639 

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: ... 

680 

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

693 

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) 

700 

701 filename = get_absolute_href(relative_to, href) 

702 

703 filename, identifier = split_fragment(filename) 

704 resource = self.get(filename, query) 

705 

706 if not with_tag: 

707 return resource 

708 

709 if resource is None: 

710 return None, None 

711 

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 

716 

717 return resource, None 

718 

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

724 

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 ) 

731 

732 resource = res 

733 

734 elif resource not in self: 

735 raise EPUBError(f"Resource '{resource}' not in EPUB") 

736 

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

742 

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

747 

748 metadata_item = self.metadata.get("cover", ValuedMetadataItem) 

749 if manifest_item and metadata_item: 

750 metadata_item.value = manifest_item.id 

751 

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

761 

762 filename = str(filename) 

763 

764 if reference_attrs is None: 

765 reference_attrs = list(self.default_reference_attrs) 

766 reference_attrs.remove("full-path") 

767 

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 

778 

779 @override 

780 def __repr__(self) -> str: 

781 return f"{self.__class__.__name__}({len(self)} resources)" 

782 

783 

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]: ... 

799 

800 @override 

801 def filter( # type: ignore[reportIncompatibleMethodOverride] 

802 self, 

803 query: ResourceQuery[Resource] | None = None, 

804 ) -> Generator[Resource]: 

805 return super().filter(query) 

806 

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) 

832 

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) 

874 

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) 

888 

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 

893 

894 

895class WindowedResourceManager[T: PublicationResource](GenericResourceManager[T], ABC): 

896 base_class: type[T] 

897 

898 def _is_in_window(self, resource: Resource) -> bool: 

899 return isinstance(resource, self.base_class) 

900 

901 @abstractmethod 

902 def _error_message(self, resource: Resource) -> str: ... 

903 

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 ) 

923 

924 @override 

925 def __repr__(self) -> str: 

926 return f"{self.__class__.__name__}[{self.base_class}]({len(self)} {self.base_class.__name__} resources)" 

927 

928 

929class PublicationResourceManager(WindowedResourceManager[PublicationResource]): 

930 base_class: type[PublicationResource] = PublicationResource 

931 

932 @override 

933 def _error_message(self, resource: Resource) -> str: 

934 return f"Resource '{resource}' is not a publication resource" 

935 

936 

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 ) 

944 

945 @override 

946 def _error_message(self, resource: Resource) -> str: 

947 return f"Resource '{resource}' is not an image" 

948 

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 

953 

954 

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

959 

960 @override 

961 def _error_message(self, resource: Resource) -> str: 

962 return f"Resource '{resource}' is not a script" 

963 

964 

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 ) 

971 

972 @override 

973 def _error_message(self, resource: Resource) -> str: 

974 return f"Resource '{resource}' is not a style" 

975 

976 

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 ) 

984 

985 @override 

986 def _error_message(self, resource: Resource) -> str: 

987 return f"Resource '{resource}' is not a font" 

988 

989 

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 ) 

997 

998 @override 

999 def _error_message(self, resource: Resource) -> str: 

1000 return f"Resource '{resource}' is not audio" 

1001 

1002 

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 ) 

1009 

1010 @override 

1011 def _error_message(self, resource: Resource) -> str: 

1012 return f"Resource '{resource}' is not video" 

1013 

1014 

1015class ContentDocumentManager(WindowedResourceManager[ContentDocument]): 

1016 base_class: type[ContentDocument] = ContentDocument 

1017 

1018 @override 

1019 def _error_message(self, resource: Resource) -> str: 

1020 return f"Resource '{resource}' is not a content document"