Skip to content

Commit 566d13d

Browse files
authored
Add fallback cover from reading order and refactor default cover service (#710)
1 parent 2edd431 commit 566d13d

File tree

11 files changed

+179
-56
lines changed

11 files changed

+179
-56
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. Take a look
99
#### Shared
1010

1111
* Added support for JXL (JPEG XL) bitmap images. JXL is decoded natively on iOS 17+.
12+
* `Publication.cover()` now falls back on the first reading order resource if it's a bitmap image and no cover is declared.
1213

1314
#### Navigator
1415

Sources/Shared/Publication/Services/Cover/CoverService.swift

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -49,35 +49,18 @@ public extension CoverService {
4949
public extension Publication {
5050
/// Returns the publication cover as a bitmap at its maximum size.
5151
func cover() async -> ReadResult<UIImage?> {
52-
if let service = findService(CoverService.self) {
53-
return await service.cover()
54-
} else {
55-
return await coverFromManifest()
52+
guard let service = findService(CoverService.self) else {
53+
return .success(nil)
5654
}
55+
return await service.cover()
5756
}
5857

5958
/// Returns the publication cover as a bitmap, scaled down to fit the given `maxSize`.
6059
func coverFitting(maxSize: CGSize) async -> ReadResult<UIImage?> {
61-
if let service = findService(CoverService.self) {
62-
return await service.coverFitting(maxSize: maxSize)
63-
} else {
64-
return await coverFromManifest()
65-
.map { $0?.scaleToFit(maxSize: maxSize) }
66-
}
67-
}
68-
69-
/// Extracts the first valid cover from the manifest links with `cover` relation.
70-
private func coverFromManifest() async -> ReadResult<UIImage?> {
71-
for link in linksWithRel(.cover) {
72-
guard let image = await get(link)?
73-
.read().getOrNil()
74-
.flatMap({ UIImage(data: $0) })
75-
else {
76-
continue
77-
}
78-
return .success(image)
60+
guard let service = findService(CoverService.self) else {
61+
return .success(nil)
7962
}
80-
return .success(nil)
63+
return await service.coverFitting(maxSize: maxSize)
8164
}
8265
}
8366

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// Copyright 2026 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
import Foundation
8+
import UIKit
9+
10+
/// A `CoverService` which retrieves the cover from the publication container.
11+
///
12+
/// It will look for:
13+
/// 1. Links with explicit `cover` relation in the resources.
14+
/// 2. First `readingOrder` resource if it's a bitmap, or if it has a bitmap
15+
/// `alternates`.
16+
public final class ResourceCoverService: CoverService {
17+
private let context: PublicationServiceContext
18+
19+
public init(context: PublicationServiceContext) {
20+
self.context = context
21+
}
22+
23+
public func cover() async -> ReadResult<UIImage?> {
24+
// Try resources with explicit `cover` relation
25+
for link in context.manifest.linksWithRel(.cover) {
26+
if let image = await loadImage(from: link) {
27+
return .success(image)
28+
}
29+
}
30+
31+
// Fallback: first reading order bitmap or alternate
32+
if let firstLink = context.manifest.readingOrder.first {
33+
if firstLink.mediaType?.isBitmap == true {
34+
if let image = await loadImage(from: firstLink) {
35+
return .success(image)
36+
}
37+
}
38+
for alternate in firstLink.alternates {
39+
if alternate.mediaType?.isBitmap == true {
40+
if let image = await loadImage(from: alternate) {
41+
return .success(image)
42+
}
43+
}
44+
}
45+
}
46+
47+
return .success(nil)
48+
}
49+
50+
private func loadImage(from link: Link) async -> UIImage? {
51+
guard
52+
let resource = context.container[link.url()],
53+
let data = try? await resource.read().get()
54+
else {
55+
return nil
56+
}
57+
return UIImage(data: data)
58+
}
59+
60+
public static func makeFactory() -> (PublicationServiceContext) -> ResourceCoverService {
61+
{ ResourceCoverService(context: $0) }
62+
}
63+
}

Sources/Shared/Publication/Services/PublicationServicesBuilder.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public struct PublicationServicesBuilder {
1515
public init(
1616
content: ContentServiceFactory? = nil,
1717
contentProtection: ContentProtectionServiceFactory? = nil,
18-
cover: CoverServiceFactory? = nil,
18+
cover: CoverServiceFactory? = ResourceCoverService.makeFactory(),
1919
locator: LocatorServiceFactory? = { DefaultLocatorService(publication: $0.publication) },
2020
positions: PositionsServiceFactory? = nil,
2121
search: SearchServiceFactory? = nil,

Sources/Shared/Toolkit/Format/MediaType.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ public struct MediaType: Hashable, Loggable, Sendable {
188188

189189
/// Returns whether this media type is of a bitmap image, so excluding vectorial formats.
190190
public var isBitmap: Bool {
191-
matchesAny(.bmp, .gif, .jpeg, .jxl, .png, .tiff, .webp)
191+
matchesAny(.avif, .bmp, .gif, .jpeg, .jxl, .png, .tiff, .webp)
192192
}
193193

194194
/// Returns whether this media type is of an audio clip.

Sources/Streamer/Parser/Image/ImageParser.swift

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,19 +142,16 @@ public final class ImageParser: PublicationParser {
142142
)
143143
}
144144

145-
// Determine cover page index
146-
let coverIndex: Int
145+
// Set cover if explicitly declared in ComicInfo.xml
146+
var coverIndex: Int?
147147
if
148148
let coverPage = comicInfo?.firstPageWithType(.frontCover),
149149
coverPage.image >= 0,
150150
coverPage.image < readingOrder.count
151151
{
152152
coverIndex = coverPage.image
153-
} else {
154-
// Default: first resource is the cover
155-
coverIndex = 0
153+
readingOrder[coverPage.image].rels.append(.cover)
156154
}
157-
readingOrder[coverIndex].rels.append(.cover)
158155

159156
// Determine story start index (where actual content begins)
160157
// Only set if different from cover page (prefer .cover if same page)

Support/Carthage/.xcodegen

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@
662662
../../Sources/Shared/Publication/Services/Cover
663663
../../Sources/Shared/Publication/Services/Cover/CoverService.swift
664664
../../Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift
665+
../../Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift
665666
../../Sources/Shared/Publication/Services/Locator
666667
../../Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift
667668
../../Sources/Shared/Publication/Services/Locator/LocatorService.swift

Support/Carthage/Readium.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@
142142
5240984F642C951743FB153F /* CBZNavigatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 239A56BB0E6DAF17E0A13447 /* CBZNavigatorViewController.swift */; };
143143
540E43EC30EEDDB740ADE046 /* BufferingResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9B7B0A5A1B891BA3D9B9C0 /* BufferingResource.swift */; };
144144
5591563FD08A956B80C37716 /* XMLFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF20C1D3C33365D25704663 /* XMLFormatSniffer.swift */; };
145+
559F3EF06F73E78848C772EA /* ResourceCoverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */; };
145146
56A9C67C15BD88FBE576ADF8 /* HTTPProblemDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05E365EBAFDA0CF841F583B /* HTTPProblemDetails.swift */; };
146147
56CB87DACCA10F737710BFF6 /* Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68FF131876FA3A63025F2662 /* Language.swift */; };
147148
5730E84475195005D1291672 /* Publication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF03272C07D6951ADC1311E /* Publication.swift */; };
@@ -835,6 +836,7 @@
835836
E6CB6D3B390CC927AE547A5C /* DebugError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugError.swift; sourceTree = "<group>"; };
836837
E6E97CCA91F910315C260373 /* ReadiumWebPubParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumWebPubParser.swift; sourceTree = "<group>"; };
837838
E7D002FDDAD1A21AC5BB84CE /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = "<group>"; };
839+
E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceCoverService.swift; sourceTree = "<group>"; };
838840
EC329362A0E8AC6CC018452A /* ReadiumOPDS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumOPDS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
839841
EC59A963F316359DF8B119AC /* Metadata+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+Presentation.swift"; sourceTree = "<group>"; };
840842
EC5ED9E15482AED288A6634F /* EPUBNavigatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBNavigatorViewController.swift; sourceTree = "<group>"; };
@@ -1253,6 +1255,7 @@
12531255
children = (
12541256
A4F0C112656C4786F3861973 /* CoverService.swift */,
12551257
925CDE3176715EBEBF40B21F /* GeneratedCoverService.swift */,
1258+
E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */,
12561259
);
12571260
path = Cover;
12581261
sourceTree = "<group>";
@@ -2740,6 +2743,7 @@
27402743
31909E8E0CB313AA7C390762 /* RelativeURL.swift in Sources */,
27412744
977C8677BEB5B235E8F82A4C /* Resource.swift in Sources */,
27422745
94E5D205567FEBB52E38F318 /* ResourceContentExtractor.swift in Sources */,
2746+
559F3EF06F73E78848C772EA /* ResourceCoverService.swift in Sources */,
27432747
92C06DC4CF7986B15F1C82B3 /* ResourceFactory.swift in Sources */,
27442748
30F89196BD5163B0A09BF9F7 /* ResourceProperties.swift in Sources */,
27452749
01E785BEA7F30AD1C8A5F3DE /* SearchService.swift in Sources */,

Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift

Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,54 +14,125 @@ class CoverServiceTests: XCTestCase {
1414
lazy var cover = UIImage(contentsOfFile: coverURL.path)!
1515
lazy var cover2 = UIImage(data: fixtures.data(at: "cover2.jpg"))!
1616

17-
/// `Publication.cover` will use the `CoverService` if there's one.
18-
func testCoverHelperUsesCoverService() async {
17+
/// `Publication.cover` will use a custom `CoverService` if provided.
18+
func testCoverHelperUsesCustomCoverService() async {
1919
let publication = makePublication { _ in TestCoverService(cover: self.cover2) }
2020
let result = await publication.cover()
2121
AssertImageEqual(result, .success(cover2))
2222
}
2323

24-
/// `Publication.cover` will try to fetch the cover from a manifest link with rel `cover`, if
25-
/// no `CoverService` is provided.
26-
func testCoverHelperFallsBackOnManifest() async {
24+
/// `Publication.cover` uses `ResourceCoverService` by default.
25+
func testCoverHelperUsesResourceCoverServiceByDefault() async {
2726
let publication = makePublication()
2827
let result = await publication.cover()
2928
AssertImageEqual(result, .success(cover))
3029
}
3130

32-
/// `Publication.coverFitting` will use the `CoverService` if there's one.
33-
func testCoverFittingHelperUsesCoverService() async {
31+
/// `Publication.coverFitting` will use a custom `CoverService` if provided.
32+
func testCoverFittingHelperUsesCustomCoverService() async {
3433
let size = CGSize(width: 100, height: 100)
3534
let publication = makePublication { _ in TestCoverService(cover: self.cover2) }
3635
let result = await publication.coverFitting(maxSize: size)
3736
AssertImageEqual(result, .success(cover2.scaleToFit(maxSize: size)))
3837
}
3938

40-
/// `Publication.coverFitting` will try to fetch the cover from a manifest link with rel `cover`, if
41-
/// no `CoverService` is provided.
42-
func testCoverFittingHelperFallsBackOnManifest() async {
39+
/// `Publication.coverFitting` uses `ResourceCoverService` by default.
40+
func testCoverFittingHelperUsesResourceCoverServiceByDefault() async {
4341
let size = CGSize(width: 100, height: 100)
4442
let publication = makePublication()
4543
let result = await publication.coverFitting(maxSize: size)
4644
AssertImageEqual(result, .success(cover.scaleToFit(maxSize: size)))
4745
}
4846

49-
private func makePublication(cover: CoverServiceFactory? = nil) -> Publication {
50-
let coverPath = "cover.jpg"
51-
return Publication(
52-
manifest: Manifest(
53-
metadata: Metadata(
54-
title: "title"
47+
/// `ResourceCoverService` uses the first bitmap reading order item when no explicit `.cover`
48+
/// link is declared.
49+
func testResourceCoverServiceUsesFirstBitmapReadingOrderItem() async {
50+
let publication = makePublication(
51+
readingOrder: [
52+
Link(href: "cover.jpg", mediaType: .jpeg),
53+
Link(href: "page2.jpg", mediaType: .jpeg),
54+
],
55+
resources: []
56+
)
57+
let result = await publication.cover()
58+
AssertImageEqual(result, .success(cover))
59+
}
60+
61+
/// `ResourceCoverService` uses the first bitmap alternate of the first reading order item
62+
/// when that item is not a bitmap.
63+
func testResourceCoverServiceUsesFirstReadingOrderBitmapAlternate() async {
64+
let publication = makePublication(
65+
readingOrder: [
66+
Link(
67+
href: "chapter1.xhtml",
68+
mediaType: .xhtml,
69+
alternates: [
70+
Link(href: "cover.jpg", mediaType: .jpeg),
71+
]
5572
),
73+
],
74+
resources: []
75+
)
76+
let result = await publication.cover()
77+
AssertImageEqual(result, .success(cover))
78+
}
79+
80+
/// `ResourceCoverService` returns nil when no explicit `.cover` link is declared and no bitmap
81+
/// is available.
82+
func testResourceCoverServiceReturnsNilWhenNoBitmapAvailable() async {
83+
let publication = makePublication(
84+
readingOrder: [Link(href: "chapter1.xhtml", mediaType: .xhtml)],
85+
resources: []
86+
)
87+
let result = await publication.cover()
88+
AssertImageEqual(result, .success(nil))
89+
}
90+
91+
/// `ResourceCoverService` prioritizes explicit `.cover` links over first reading order item.
92+
func testResourceCoverServicePrioritizesExplicitCoverLink() async {
93+
let publication = Publication(
94+
manifest: Manifest(
95+
metadata: Metadata(title: "title"),
5696
readingOrder: [
57-
Link(href: "titlepage.xhtml", rels: [.cover]),
97+
Link(href: "page1.jpg", mediaType: .jpeg),
5898
],
5999
resources: [
60-
Link(href: coverPath, rels: [.cover]),
100+
Link(href: "cover2.jpg", rels: [.cover]),
61101
]
62102
),
63-
container: FileContainer(href: RelativeURL(path: coverPath)!, file: coverURL),
64-
servicesBuilder: PublicationServicesBuilder(cover: cover)
103+
container: CompositeContainer(
104+
SingleResourceContainer(
105+
resource: FileResource(file: fixtures.url(for: "cover.jpg")),
106+
at: AnyURL(string: "page1.jpg")!
107+
),
108+
SingleResourceContainer(
109+
resource: FileResource(file: fixtures.url(for: "cover2.jpg")),
110+
at: AnyURL(string: "cover2.jpg")!
111+
)
112+
)
113+
)
114+
let result = await publication.cover()
115+
AssertImageEqual(result, .success(cover2))
116+
}
117+
118+
private func makePublication(
119+
readingOrder: [Link] = [],
120+
resources: [Link] = [Link(href: "cover.jpg", rels: [.cover])],
121+
cover: CoverServiceFactory? = nil
122+
) -> Publication {
123+
var builder = PublicationServicesBuilder()
124+
if let cover { builder.setCoverServiceFactory(cover) }
125+
return Publication(
126+
manifest: Manifest(
127+
metadata: Metadata(title: "title"),
128+
readingOrder: readingOrder,
129+
resources: resources
130+
),
131+
container: SingleResourceContainer(
132+
resource: FileResource(file: coverURL),
133+
at: AnyURL(string: "cover.jpg")!
134+
),
135+
servicesBuilder: builder
65136
)
66137
}
67138
}

Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,17 @@ class PublicationServicesBuilderTests: XCTestCase {
4242

4343
let services = builder.build(context: context)
4444

45-
XCTAssert(services.count == 3)
45+
XCTAssert(services.count == 4)
4646
XCTAssert(services.contains { $0 is FooServiceA })
4747
XCTAssert(services.contains { $0 is BarServiceA })
4848
}
4949

5050
func testBuildDefault() {
5151
let builder = PublicationServicesBuilder()
5252
let services = builder.build(context: context)
53-
XCTAssertEqual(services.count, 1)
53+
XCTAssertEqual(services.count, 2)
5454
XCTAssert(services.contains { $0 is DefaultLocatorService })
55+
XCTAssert(services.contains { $0 is ResourceCoverService })
5556
}
5657

5758
func testSetOverwrite() {

0 commit comments

Comments
 (0)