From 80711ec892b58fd403033dbf04f78fc24cb42d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Thu, 29 Jan 2026 13:01:01 +0100 Subject: [PATCH 1/2] Refactor the default cover service, and add a fallback on a the first reading order resource if it's a bitmap --- .../Services/Cover/CoverService.swift | 29 +---- .../Services/Cover/ResourceCoverService.swift | 63 ++++++++++ .../Services/PublicationServicesBuilder.swift | 2 +- Sources/Shared/Toolkit/Format/MediaType.swift | 2 +- .../Streamer/Parser/Image/ImageParser.swift | 9 +- Support/Carthage/.xcodegen | 1 + .../Readium.xcodeproj/project.pbxproj | 4 + .../Services/Cover/CoverServiceTests.swift | 111 ++++++++++++++---- .../PublicationServicesBuilderTests.swift | 5 +- .../Parser/Image/ImageParserTests.swift | 8 +- 10 files changed, 178 insertions(+), 56 deletions(-) create mode 100644 Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift diff --git a/Sources/Shared/Publication/Services/Cover/CoverService.swift b/Sources/Shared/Publication/Services/Cover/CoverService.swift index c38e71c8d..dd1d1a9a6 100644 --- a/Sources/Shared/Publication/Services/Cover/CoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/CoverService.swift @@ -49,35 +49,18 @@ public extension CoverService { public extension Publication { /// Returns the publication cover as a bitmap at its maximum size. func cover() async -> ReadResult { - if let service = findService(CoverService.self) { - return await service.cover() - } else { - return await coverFromManifest() + guard let service = findService(CoverService.self) else { + return .success(nil) } + return await service.cover() } /// Returns the publication cover as a bitmap, scaled down to fit the given `maxSize`. func coverFitting(maxSize: CGSize) async -> ReadResult { - if let service = findService(CoverService.self) { - return await service.coverFitting(maxSize: maxSize) - } else { - return await coverFromManifest() - .map { $0?.scaleToFit(maxSize: maxSize) } - } - } - - /// Extracts the first valid cover from the manifest links with `cover` relation. - private func coverFromManifest() async -> ReadResult { - for link in linksWithRel(.cover) { - guard let image = await get(link)? - .read().getOrNil() - .flatMap({ UIImage(data: $0) }) - else { - continue - } - return .success(image) + guard let service = findService(CoverService.self) else { + return .success(nil) } - return .success(nil) + return await service.coverFitting(maxSize: maxSize) } } diff --git a/Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift b/Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift new file mode 100644 index 000000000..0f79f0bce --- /dev/null +++ b/Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift @@ -0,0 +1,63 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import UIKit + +/// A `CoverService` which retrieves the cover from the publication container. +/// +/// It will look for: +/// 1. Links with explicit `cover` relation in the resources. +/// 2. First `readingOrder` resource if it's a bitmap, or if it has a bitmap +/// `alternates`. +public final class ResourceCoverService: CoverService { + private let context: PublicationServiceContext + + public init(context: PublicationServiceContext) { + self.context = context + } + + public func cover() async -> ReadResult { + // Try resources with explicit `cover` relation + for link in context.manifest.linksWithRel(.cover) { + if let image = await loadImage(from: link) { + return .success(image) + } + } + + // Fallback: first reading order bitmap or alternate + if let firstLink = context.manifest.readingOrder.first { + if firstLink.mediaType?.isBitmap == true { + if let image = await loadImage(from: firstLink) { + return .success(image) + } + } + for alternate in firstLink.alternates { + if alternate.mediaType?.isBitmap == true { + if let image = await loadImage(from: alternate) { + return .success(image) + } + } + } + } + + return .success(nil) + } + + private func loadImage(from link: Link) async -> UIImage? { + guard + let resource = context.container[link.url()], + let data = try? await resource.read().get() + else { + return nil + } + return UIImage(data: data) + } + + public static func makeFactory() -> (PublicationServiceContext) -> ResourceCoverService { + { ResourceCoverService(context: $0) } + } +} diff --git a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift index bb05a34ce..7fc019103 100644 --- a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift +++ b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift @@ -15,7 +15,7 @@ public struct PublicationServicesBuilder { public init( content: ContentServiceFactory? = nil, contentProtection: ContentProtectionServiceFactory? = nil, - cover: CoverServiceFactory? = nil, + cover: CoverServiceFactory? = ResourceCoverService.makeFactory(), locator: LocatorServiceFactory? = { DefaultLocatorService(publication: $0.publication) }, positions: PositionsServiceFactory? = nil, search: SearchServiceFactory? = nil, diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index 45212bb79..0ad3be2a1 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -188,7 +188,7 @@ public struct MediaType: Hashable, Loggable, Sendable { /// Returns whether this media type is of a bitmap image, so excluding vectorial formats. public var isBitmap: Bool { - matchesAny(.bmp, .gif, .jpeg, .jxl, .png, .tiff, .webp) + matchesAny(.avif, .bmp, .gif, .jpeg, .jxl, .png, .tiff, .webp) } /// Returns whether this media type is of an audio clip. diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index eea46d692..a1b25fa4d 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -142,19 +142,16 @@ public final class ImageParser: PublicationParser { ) } - // Determine cover page index - let coverIndex: Int + // Set cover if explicitly declared in ComicInfo.xml + var coverIndex: Int? if let coverPage = comicInfo?.firstPageWithType(.frontCover), coverPage.image >= 0, coverPage.image < readingOrder.count { coverIndex = coverPage.image - } else { - // Default: first resource is the cover - coverIndex = 0 + readingOrder[coverPage.image].rels.append(.cover) } - readingOrder[coverIndex].rels.append(.cover) // Determine story start index (where actual content begins) // Only set if different from cover page (prefer .cover if same page) diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 534c32b8a..1f25687e6 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -662,6 +662,7 @@ ../../Sources/Shared/Publication/Services/Cover ../../Sources/Shared/Publication/Services/Cover/CoverService.swift ../../Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +../../Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift ../../Sources/Shared/Publication/Services/Locator ../../Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift ../../Sources/Shared/Publication/Services/Locator/LocatorService.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index a19bcedc0..7572eb63d 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -142,6 +142,7 @@ 5240984F642C951743FB153F /* CBZNavigatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 239A56BB0E6DAF17E0A13447 /* CBZNavigatorViewController.swift */; }; 540E43EC30EEDDB740ADE046 /* BufferingResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9B7B0A5A1B891BA3D9B9C0 /* BufferingResource.swift */; }; 5591563FD08A956B80C37716 /* XMLFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF20C1D3C33365D25704663 /* XMLFormatSniffer.swift */; }; + 559F3EF06F73E78848C772EA /* ResourceCoverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */; }; 56A9C67C15BD88FBE576ADF8 /* HTTPProblemDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05E365EBAFDA0CF841F583B /* HTTPProblemDetails.swift */; }; 56CB87DACCA10F737710BFF6 /* Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68FF131876FA3A63025F2662 /* Language.swift */; }; 5730E84475195005D1291672 /* Publication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF03272C07D6951ADC1311E /* Publication.swift */; }; @@ -835,6 +836,7 @@ E6CB6D3B390CC927AE547A5C /* DebugError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugError.swift; sourceTree = ""; }; E6E97CCA91F910315C260373 /* ReadiumWebPubParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumWebPubParser.swift; sourceTree = ""; }; E7D002FDDAD1A21AC5BB84CE /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; + E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceCoverService.swift; sourceTree = ""; }; EC329362A0E8AC6CC018452A /* ReadiumOPDS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumOPDS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EC59A963F316359DF8B119AC /* Metadata+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+Presentation.swift"; sourceTree = ""; }; EC5ED9E15482AED288A6634F /* EPUBNavigatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBNavigatorViewController.swift; sourceTree = ""; }; @@ -1253,6 +1255,7 @@ children = ( A4F0C112656C4786F3861973 /* CoverService.swift */, 925CDE3176715EBEBF40B21F /* GeneratedCoverService.swift */, + E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */, ); path = Cover; sourceTree = ""; @@ -2740,6 +2743,7 @@ 31909E8E0CB313AA7C390762 /* RelativeURL.swift in Sources */, 977C8677BEB5B235E8F82A4C /* Resource.swift in Sources */, 94E5D205567FEBB52E38F318 /* ResourceContentExtractor.swift in Sources */, + 559F3EF06F73E78848C772EA /* ResourceCoverService.swift in Sources */, 92C06DC4CF7986B15F1C82B3 /* ResourceFactory.swift in Sources */, 30F89196BD5163B0A09BF9F7 /* ResourceProperties.swift in Sources */, 01E785BEA7F30AD1C8A5F3DE /* SearchService.swift in Sources */, diff --git a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift index ede34be53..b833cba9d 100644 --- a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift @@ -14,54 +14,125 @@ class CoverServiceTests: XCTestCase { lazy var cover = UIImage(contentsOfFile: coverURL.path)! lazy var cover2 = UIImage(data: fixtures.data(at: "cover2.jpg"))! - /// `Publication.cover` will use the `CoverService` if there's one. - func testCoverHelperUsesCoverService() async { + /// `Publication.cover` will use a custom `CoverService` if provided. + func testCoverHelperUsesCustomCoverService() async { let publication = makePublication { _ in TestCoverService(cover: self.cover2) } let result = await publication.cover() AssertImageEqual(result, .success(cover2)) } - /// `Publication.cover` will try to fetch the cover from a manifest link with rel `cover`, if - /// no `CoverService` is provided. - func testCoverHelperFallsBackOnManifest() async { + /// `Publication.cover` uses `ResourceCoverService` by default. + func testCoverHelperUsesResourceCoverServiceByDefault() async { let publication = makePublication() let result = await publication.cover() AssertImageEqual(result, .success(cover)) } - /// `Publication.coverFitting` will use the `CoverService` if there's one. - func testCoverFittingHelperUsesCoverService() async { + /// `Publication.coverFitting` will use a custom `CoverService` if provided. + func testCoverFittingHelperUsesCustomCoverService() async { let size = CGSize(width: 100, height: 100) let publication = makePublication { _ in TestCoverService(cover: self.cover2) } let result = await publication.coverFitting(maxSize: size) AssertImageEqual(result, .success(cover2.scaleToFit(maxSize: size))) } - /// `Publication.coverFitting` will try to fetch the cover from a manifest link with rel `cover`, if - /// no `CoverService` is provided. - func testCoverFittingHelperFallsBackOnManifest() async { + /// `Publication.coverFitting` uses `ResourceCoverService` by default. + func testCoverFittingHelperUsesResourceCoverServiceByDefault() async { let size = CGSize(width: 100, height: 100) let publication = makePublication() let result = await publication.coverFitting(maxSize: size) AssertImageEqual(result, .success(cover.scaleToFit(maxSize: size))) } - private func makePublication(cover: CoverServiceFactory? = nil) -> Publication { - let coverPath = "cover.jpg" - return Publication( - manifest: Manifest( - metadata: Metadata( - title: "title" + /// `ResourceCoverService` uses the first bitmap reading order item when no explicit `.cover` + /// link is declared. + func testResourceCoverServiceUsesFirstBitmapReadingOrderItem() async { + let publication = makePublication( + readingOrder: [ + Link(href: "cover.jpg", mediaType: .jpeg), + Link(href: "page2.jpg", mediaType: .jpeg), + ], + resources: [] + ) + let result = await publication.cover() + AssertImageEqual(result, .success(cover)) + } + + /// `ResourceCoverService` uses the first bitmap alternate of the first reading order item + /// when that item is not a bitmap. + func testResourceCoverServiceUsesFirstReadingOrderBitmapAlternate() async { + let publication = makePublication( + readingOrder: [ + Link( + href: "chapter1.xhtml", + mediaType: .xhtml, + alternates: [ + Link(href: "cover.jpg", mediaType: .jpeg), + ] ), + ], + resources: [] + ) + let result = await publication.cover() + AssertImageEqual(result, .success(cover)) + } + + /// `ResourceCoverService` returns nil when no explicit `.cover` link is declared and no bitmap + /// is available. + func testResourceCoverServiceReturnsNilWhenNoBitmapAvailable() async { + let publication = makePublication( + readingOrder: [Link(href: "chapter1.xhtml", mediaType: .xhtml)], + resources: [] + ) + let result = await publication.cover() + AssertImageEqual(result, .success(nil)) + } + + /// `ResourceCoverService` prioritizes explicit `.cover` links over first reading order item. + func testResourceCoverServicePrioritizesExplicitCoverLink() async { + let publication = Publication( + manifest: Manifest( + metadata: Metadata(title: "title"), readingOrder: [ - Link(href: "titlepage.xhtml", rels: [.cover]), + Link(href: "page1.jpg", mediaType: .jpeg), ], resources: [ - Link(href: coverPath, rels: [.cover]), + Link(href: "cover2.jpg", rels: [.cover]), ] ), - container: FileContainer(href: RelativeURL(path: coverPath)!, file: coverURL), - servicesBuilder: PublicationServicesBuilder(cover: cover) + container: CompositeContainer( + SingleResourceContainer( + resource: FileResource(file: fixtures.url(for: "cover.jpg")), + at: AnyURL(string: "page1.jpg")! + ), + SingleResourceContainer( + resource: FileResource(file: fixtures.url(for: "cover2.jpg")), + at: AnyURL(string: "cover2.jpg")! + ) + ) + ) + let result = await publication.cover() + AssertImageEqual(result, .success(cover2)) + } + + private func makePublication( + readingOrder: [Link] = [], + resources: [Link] = [Link(href: "cover.jpg", rels: [.cover])], + cover: CoverServiceFactory? = nil + ) -> Publication { + var builder = PublicationServicesBuilder() + if let cover { builder.setCoverServiceFactory(cover) } + return Publication( + manifest: Manifest( + metadata: Metadata(title: "title"), + readingOrder: readingOrder, + resources: resources + ), + container: SingleResourceContainer( + resource: FileResource(file: coverURL), + at: AnyURL(string: "cover.jpg")! + ), + servicesBuilder: builder ) } } diff --git a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift index 616b3ab83..845c9b692 100644 --- a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift +++ b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift @@ -42,7 +42,7 @@ class PublicationServicesBuilderTests: XCTestCase { let services = builder.build(context: context) - XCTAssert(services.count == 3) + XCTAssert(services.count == 4) XCTAssert(services.contains { $0 is FooServiceA }) XCTAssert(services.contains { $0 is BarServiceA }) } @@ -50,8 +50,9 @@ class PublicationServicesBuilderTests: XCTestCase { func testBuildDefault() { let builder = PublicationServicesBuilder() let services = builder.build(context: context) - XCTAssertEqual(services.count, 1) + XCTAssertEqual(services.count, 2) XCTAssert(services.contains { $0 is DefaultLocatorService }) + XCTAssert(services.contains { $0 is ResourceCoverService }) } func testSetOverwrite() { diff --git a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index fbb87e234..6e26add56 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -80,10 +80,12 @@ class ImageParserTests: XCTestCase { ]) } - func testFirstReadingOrderItemIsCover() async throws { + /// When no ComicInfo.xml declares a cover, no `cover` rel should be set. + /// The cover will be determined at runtime with the default + /// `ResourceCoverService`. + func testNoCoverRelWhenNoExplicitCover() async throws { let publication = try await parser.parse(asset: cbzAsset, warnings: nil).get().build() - let cover = try XCTUnwrap(publication.linkWithRel(.cover)) - XCTAssertEqual(publication.readingOrder.first, cover) + XCTAssertNil(publication.linkWithRel(.cover)) } func testPositions() async throws { From 3a898383b07fc9eb49174d76c030299bd8c9a980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Thu, 29 Jan 2026 13:14:03 +0100 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae38e5b6..86547aeea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. Take a look #### Shared * Added support for JXL (JPEG XL) bitmap images. JXL is decoded natively on iOS 17+. +* `Publication.cover()` now falls back on the first reading order resource if it's a bitmap image and no cover is declared. #### Navigator