diff --git a/CHANGELOG.md b/CHANGELOG.md index 65716b38ee..d503ee4988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ All notable changes to this project will be documented in this file. Take a look #### Navigator * The EPUB navigator supports publications mixing reflowable and fixed-layout resources, rendering each resource according to its own layout. +* New `EPUBNavigatorViewController.Configuration.preferredResourceVariant` to choose which variant of each resource is rendered among its `alternates`, such as the XHTML page or its bitmap fallback. + * Bitmap resources in the reading order are rendered as fixed-layout, even in a reflowable publication. +* New `EPUBNavigatorDelegate.navigator(_:contentInsetFor:)` to customize the content insets of a spread according to its `EPUBLayout`, for example to add margins only around the reflowable resources of a mixed-layout publication. It takes precedence over `navigatorContentInset(_:)`. ### Changed @@ -17,6 +20,16 @@ All notable changes to this project will be documented in this file. Take a look * Converting a `Link` to a `Locator` is now synchronous: `await publication.locate(link)` becomes `publication.locator(for: link)`. The logic moved to `Manifest`, so it is also available as `manifest.locator(for: link)` without a `Publication`. +#### Navigator + +* In the EPUB navigator, a fixed-layout resource displayed on its own when spreads are enabled is now centered, unless the publication provides an explicit page position (e.g. `page-spread-left`). It was previously displayed on the left or right half of the viewport, depending on the reading progression. + +#### Streamer + +* EPUB spine items with a bitmap fallback are no longer swapped with their fallback. The spine item stays in the reading order and the bitmap is available in its `alternates`. + * This changes the resources rendered by default and the href of the reported locations. Locations saved with the previous version are still restored by the EPUB navigator. + * Use `EPUBNavigatorViewController.Configuration.preferredResourceVariant = .image` in the EPUB navigator configuration to render the bitmaps like before. + #### LCP * Opening an LCP publication is no longer delayed by the CRL used to validate its license. The CRL is now downloaded when creating the `LCPService`, and an expired one is refreshed in the background instead of making the user wait for the response. diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index fd739d895a..237e51ca96 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -16,10 +16,35 @@ import WebKit // MARK: - WebView Customization func navigator(_ navigator: EPUBNavigatorViewController, setupUserScripts userContentController: WKUserContentController) + + // MARK: - Layout + + /// Returns the content insets that the navigator applies around the content + /// of a spread with the given `layout`. + /// + /// Implement this method to customize the margins around the publication + /// content and to control which areas may be covered by the app's UI or + /// system bars. + /// + /// Consider the view's safe area insets to prevent notches, the status bar, + /// or other overlays from obscuring the content. + /// + /// This method takes precedence over `navigatorContentInset(_:)`, and offer + /// per-layout tuning to support publications mixing reflowable and fixed- + /// layout resources. + /// + /// - Returns: The insets to apply, or `nil` to fall back on + /// `navigatorContentInset(_:)`, then on the navigator's default + /// behavior. + func navigator(_ navigator: EPUBNavigatorViewController, contentInsetFor layout: EPUBLayout) -> UIEdgeInsets? } public extension EPUBNavigatorDelegate { func navigator(_ navigator: EPUBNavigatorViewController, setupUserScripts userContentController: WKUserContentController) {} + + func navigator(_ navigator: EPUBNavigatorViewController, contentInsetFor layout: EPUBLayout) -> UIEdgeInsets? { + nil + } } public typealias EPUBContentInsets = (top: CGFloat, bottom: CGFloat) @@ -65,9 +90,9 @@ open class EPUBNavigatorViewController: InputObservableViewController, /// The insets can be configured for each size class to allow smaller /// margins on compact screens. /// - /// For more control, implement the `navigatorContentInset()` delegate - /// method, which takes precedence over this configuration property - /// when implemented. + /// For more control, implement the `navigator(_:contentInsetFor:)` or + /// `navigatorContentInset(_:)` delegate methods, which take precedence + /// over this configuration property when implemented. public var contentInset: [UIUserInterfaceSizeClass: EPUBContentInsets] /// Number of positions (as in `Publication.positionList`) to preload before the current page. @@ -76,6 +101,18 @@ open class EPUBNavigatorViewController: InputObservableViewController, /// Number of positions (as in `Publication.positionList`) to preload after the current page. public var preloadNextPositionCount: Int + /// Variant of each reading order resource to render, among the link + /// and its alternates. For example, an EPUB can provide a bitmap + /// fallback for each XHTML page. + /// + /// Only one level of alternates is considered. A rendered bitmap is + /// always laid out as fixed-layout. + /// + /// Locators reported by the navigator use the href of the rendered + /// resource. Locators referencing another variant are still resolved + /// when navigating. + public var preferredResourceVariant: EPUBResourceVariant + /// Supported HTML decoration templates. public var decorationTemplates: [Decoration.Style.Id: HTMLDecorationTemplate] @@ -102,6 +139,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, ], preloadPreviousPositionCount: Int = 2, preloadNextPositionCount: Int = 6, + preferredResourceVariant: EPUBResourceVariant = .default, decorationTemplates: [Decoration.Style.Id: HTMLDecorationTemplate] = HTMLDecorationTemplate.defaultTemplates(), fontFamilyDeclarations: [AnyHTMLFontFamilyDeclaration] = [], readiumCSSRSProperties: CSSRSProperties = CSSRSProperties(), @@ -114,6 +152,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, self.contentInset = contentInset self.preloadPreviousPositionCount = preloadPreviousPositionCount self.preloadNextPositionCount = preloadNextPositionCount + self.preferredResourceVariant = preferredResourceVariant self.decorationTemplates = decorationTemplates self.fontFamilyDeclarations = fontFamilyDeclarations self.readiumCSSRSProperties = readiumCSSRSProperties @@ -244,7 +283,6 @@ open class EPUBNavigatorViewController: InputObservableViewController, } } - private let readingOrder: [Link] public private(set) var currentLocation: Locator? private let loadPositionsByReadingOrder: () async -> ReadResult<[[Locator]]> private var positionsByReadingOrder: [[Locator]] = [] @@ -258,6 +296,11 @@ open class EPUBNavigatorViewController: InputObservableViewController, viewModel.config } + /// Reading order rendered by the navigator. + private var readingOrder: EPUBReadingOrder { + viewModel.readingOrder + } + /// Creates a new instance of `EPUBNavigatorViewController`. /// /// - Parameters: @@ -300,7 +343,6 @@ open class EPUBNavigatorViewController: InputObservableViewController, self.init( viewModel: viewModel, initialLocation: initialLocation, - readingOrder: viewModel.readingOrder, positionsByReadingOrder: positionsByReadingOrderClosure ) } @@ -308,12 +350,12 @@ open class EPUBNavigatorViewController: InputObservableViewController, private init( viewModel: EPUBNavigatorViewModel, initialLocation: Locator?, - readingOrder: [Link], positionsByReadingOrder: @escaping () async -> ReadResult<[[Locator]]> ) { self.viewModel = viewModel - currentLocation = initialLocation - self.readingOrder = readingOrder + // Resolved up front, so the first location notified to the delegate + // uses the href of the rendered resource. + currentLocation = initialLocation.map { viewModel.readingOrder.resolve($0)?.locator ?? $0 } loadPositionsByReadingOrder = positionsByReadingOrder super.init(nibName: nil, bundle: nil) @@ -536,7 +578,10 @@ open class EPUBNavigatorViewController: InputObservableViewController, isScrollEnabled: isPaginationViewScrollingEnabled ) view.delegate = self - view.backgroundColor = .clear + // The background color must be painted by the pagination view + // itself, so that it is part of the snapshot used during page + // transitions. + view.backgroundColor = settings.effectiveBackgroundColor.uiColor return view } @@ -590,7 +635,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, spreads = EPUBSpread.makeSpreads( for: publication, - readingOrder: readingOrder, + readingOrder: readingOrder.links, readingProgression: viewModel.readingProgression, spread: viewModel.spreadEnabled, offsetFirstPage: viewModel.offsetFirstPage @@ -599,7 +644,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, let initialIndex: ReadingOrder.Index = { if let href = locator?.href, - let index = readingOrder.firstIndexWithHREF(href), + let index = readingOrder.index(of: href), let foundIndex = self.spreads.firstIndexWithReadingOrderIndex(index) { return foundIndex @@ -618,10 +663,15 @@ open class EPUBNavigatorViewController: InputObservableViewController, on(.loaded) } + /// Returns the loaded spread view rendering the resource at `href`. + /// + /// Only the rendered links are searched, as the JavaScript side only knows + /// their hrefs. For example, a decoration on another variant of a + /// resource is not rendered. private func loadedSpreadViewForHREF(_ href: T) -> EPUBSpreadView? { guard let loadedViews = paginationView?.loadedViews, - let index = readingOrder.firstIndexWithHREF(href) + let index = readingOrder.links.firstIndexWithHREF(href) else { return nil } @@ -667,7 +717,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, readingOrderIndices: spreadView.spread.readingOrderIndices, progression: { spreadView.progression(in: $0) }, manifest: publication.manifest, - readingOrder: readingOrder, + readingOrder: readingOrder.links, positionsByReadingOrder: positionsByReadingOrder, tableOfContentsTitleByHref: tableOfContentsTitleByHref ) @@ -707,16 +757,15 @@ open class EPUBNavigatorViewController: InputObservableViewController, } public func go(to locator: Locator, options: NavigatorGoOptions) async -> Bool { - let locator = publication.normalizeLocator(locator) - guard let paginationView = paginationView, - let index = readingOrder.firstIndexWithHREF(locator.href), - let spreadIndex = spreads.firstIndexWithReadingOrderIndex(index), - on(.jump(locator)) + let resolved = readingOrder.resolve(publication.normalizeLocator(locator)), + let spreadIndex = spreads.firstIndexWithReadingOrderIndex(resolved.index), + on(.jump(resolved.locator)) else { return false } + let locator = resolved.locator let success = await paginationView.goToIndex(spreadIndex, location: .locator(locator), options: options) on(.jumped) @@ -906,7 +955,9 @@ open class EPUBNavigatorViewController: InputObservableViewController, return } - view.backgroundColor = settings.effectiveBackgroundColor.uiColor + let backgroundColor = settings.effectiveBackgroundColor.uiColor + view.backgroundColor = backgroundColor + paginationView?.backgroundColor = backgroundColor paginationView?.isScrollEnabled = isPaginationViewScrollingEnabled } @@ -1002,7 +1053,7 @@ extension EPUBNavigatorViewController: EPUBNavigatorViewModelDelegate { for (_, view) in paginationView.loadedViews { guard let view = view as? EPUBSpreadView, - let index = readingOrder.firstIndexWithHREF(href), + let index = readingOrder.links.firstIndexWithHREF(href), view.spread.contains(index: index) else { continue @@ -1027,7 +1078,9 @@ extension EPUBNavigatorViewController: EPUBNavigatorViewModelDelegate { extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { func spreadViewContentInset(_ spreadView: EPUBSpreadView) -> UIEdgeInsets { - if let inset = delegate?.navigatorContentInset(self) { + let layout = spreadView.spread.layout(in: publication) + + if let inset = delegate?.navigator(self, contentInsetFor: layout) ?? delegate?.navigatorContentInset(self) { return inset } @@ -1036,7 +1089,7 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { // the application's bars. var insets = view.window?.safeAreaInsets ?? .zero - switch spreadView.spread.layout(in: publication) { + switch layout { case .fixed: // With iPadOS and macOS, we aim to display content edge-to-edge // since there are no physical notches or Dynamic Island like on the @@ -1075,7 +1128,7 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { .joined(separator: "\n") let links = spreadView.spread.readingOrderIndices - .compactMap { readingOrder.getOrNil($0) } + .compactMap { readingOrder.links.getOrNil($0) } for link in links { let href = link.url() @@ -1281,6 +1334,6 @@ extension EPUBNavigatorViewController: PaginationViewDelegate { } func paginationView(_ paginationView: PaginationView, positionCountAtIndex index: Int) -> Int { - spreads[index].positionCount(in: readingOrder, positionsByReadingOrder: positionsByReadingOrder) + spreads[index].positionCount(in: readingOrder.links, positionsByReadingOrder: positionsByReadingOrder) } } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index a1689be0a4..ba6f365e7b 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -42,7 +42,8 @@ enum EPUBScriptScope { weak var delegate: EPUBNavigatorViewModelDelegate? - let readingOrder: ReadingOrder + /// Reading order rendered by the navigator. + let readingOrder: EPUBReadingOrder convenience init( publication: Publication, @@ -117,7 +118,10 @@ enum EPUBScriptScope { } self.publication = publication - self.readingOrder = readingOrder + self.readingOrder = EPUBReadingOrder( + readingOrder: readingOrder, + preferredVariant: config.preferredResourceVariant + ) self.config = config editingActions = EditingActionsController( actions: config.editingActions, @@ -244,7 +248,7 @@ enum EPUBScriptScope { EPUBPreferencesEditor( initialPreferences: preferences, publication: publication, - readingOrder: readingOrder, + readingOrder: readingOrder.links, defaults: config.defaults ) } diff --git a/Sources/Navigator/EPUB/EPUBReadingOrder.swift b/Sources/Navigator/EPUB/EPUBReadingOrder.swift new file mode 100644 index 0000000000..fb5a6c62b2 --- /dev/null +++ b/Sources/Navigator/EPUB/EPUBReadingOrder.swift @@ -0,0 +1,124 @@ +// +// 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 ReadiumShared + +/// Reading order rendered by the EPUB navigator, after picking the preferred +/// variant of each resource among its alternates. +/// +/// Only one level of alternates is considered. SMIL, audio, video and other +/// media types are never rendered. +/// +/// Use the alternates-aware `index(of:)` and `resolve(_:)` to look up a +/// resource, so that hrefs referencing another variant of a rendered link are +/// still found. +struct EPUBReadingOrder { + /// Links rendered by the navigator. + let links: [Link] + + /// - Parameters: + /// - readingOrder: Links to render, either the publication's reading + /// order or a custom one. + /// - preferredVariant: Variant to render among each link and its + /// alternates. + init(readingOrder: [Link], preferredVariant: EPUBResourceVariant) { + links = readingOrder.map { renderedLink(for: $0, preferredVariant: preferredVariant) } + } + + /// Finds the index of the rendered link matching `href`, looking first + /// at the rendered links, then at one level of their alternates. + /// + /// - Parameters: + /// - href: HREF of the resource to find, which can reference any + /// variant of a rendered link. + func index(of href: T) -> Int? { + links.firstIndexWithHREF(href) + ?? links.firstIndex { $0.alternates.firstIndexWithHREF(href) != nil } + } + + /// Resolves `locator` against the rendered links. + /// + /// The returned locator targets the rendered link. When the locator + /// references another variant, its fragments and other locations (e.g. + /// `cssSelector`) are dropped as they are not meaningful anymore. + /// + /// - Parameters: + /// - locator: Locator to resolve, which can reference any variant of a + /// rendered link. + func resolve(_ locator: Locator) -> (index: Int, locator: Locator)? { + guard let index = index(of: locator.href) else { + return nil + } + + let link = links[index] + let href = link.url() + guard !href.isEquivalentTo(locator.href) else { + return (index, locator) + } + + var resolved = locator.copy(href: href, mediaType: link.mediaType) + resolved.locations.fragments = [] + resolved.locations.otherLocations = [:] + return (index, resolved) + } +} + +/// Returns the link to render for `main`, according to the +/// `preferredVariant`. +/// +/// - Parameters: +/// - main: Reading order link, with its alternates. +/// - preferredVariant: Variant to render among `main` and its alternates. +private func renderedLink(for main: Link, preferredVariant: EPUBResourceVariant) -> Link { + // Candidates are handled by index, as duplicate links are possible. + let candidates = [main] + main.alternates + let pick = preferredIndex(in: candidates, for: preferredVariant) ?? 0 + return (pick == 0) ? main : promote(candidates: candidates, at: pick) +} + +/// Returns the index of the first candidate matching `preferredVariant`. +/// +/// - Parameters: +/// - candidates: Main link followed by its alternates. +/// - preferredVariant: Variant to find among `candidates`. +private func preferredIndex(in candidates: [Link], for preferredVariant: EPUBResourceVariant) -> Int? { + switch preferredVariant { + case .default: + return 0 + case .html: + return candidates.firstIndex { $0.mediaType?.isHTML == true } + case .image: + return candidates.firstIndex { $0.mediaType?.isBitmap == true } + } +} + +/// Promotes the alternate at `index` as the main link. +/// +/// The link describes the alternate's resource, while the title, relations and +/// properties (e.g. the page spread) are transferred from the main link. The +/// former main link becomes the first alternate. +/// +/// - Parameters: +/// - candidates: Main link followed by its alternates. +/// - index: Index of the candidate to promote, greater than 0. +private func promote(candidates: [Link], at index: Int) -> Link { + let main = candidates[0] + let pick = candidates[index] + + var link = pick + link.title = main.title + link.rels = main.rels + link.properties = main.properties + + var demoted = main + demoted.alternates = [] + var alternates = main.alternates + alternates.remove(at: index - 1) + link.alternates = [demoted] + alternates + + return link +} diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 74684baf8e..0623c58b78 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -308,7 +308,7 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { private func go(to locator: Locator, animated: Bool) async -> Bool { if !["", "#"].contains(locator.href.string) { guard - let index = viewModel.readingOrder.firstIndexWithHREF(locator.href), + let index = viewModel.readingOrder.index(of: locator.href), spread.contains(index: index) else { log(.warning, "The locator's href is not in the spread") diff --git a/Sources/Navigator/EPUB/EPUBResourceVariant.swift b/Sources/Navigator/EPUB/EPUBResourceVariant.swift new file mode 100644 index 0000000000..481a49b14f --- /dev/null +++ b/Sources/Navigator/EPUB/EPUBResourceVariant.swift @@ -0,0 +1,22 @@ +// +// 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 + +/// Variant of a resource rendered by the EPUB navigator, when a reading order +/// link has alternates (e.g. an XHTML page with a bitmap fallback). +public enum EPUBResourceVariant: Sendable, Hashable { + /// Render the reading order links as authored, without walking alternates. + case `default` + + /// Prefer the HTML/XHTML variant among a link and its alternates. + case html + + /// Prefer a bitmap image variant among a link and its alternates. + /// + /// A rendered bitmap is always laid out as fixed-layout. + case image +} diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 9b7f769544..7713335331 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -262,26 +262,13 @@ struct EPUBSingleSpread: EPUBSpreadProtocol, Loggable { [ .object(resource.json( forBaseURL: baseURL, - page: resource.link.properties.page ?? defaultPage(in: readingProgression) + // Without an explicit position from the publication, we + // center the resource to fill the viewport instead of leaving + // half of it empty. + page: resource.link.properties.page ?? .center )), ] } - - /// Returns the default spread position (left or right) for the single - /// resource, in the given reading progression. - /// - /// The first page (typically a cover) defaults to the starting page (right - /// for LTR). Other unpaired pages default to the leading position they - /// would have had in a spread pair. - private func defaultPage(in readingProgression: ReadingProgression) -> Properties.Page { - let isFirstPage = (resource.index == 0) - return switch readingProgression { - case .ltr: - isFirstPage ? .right : .left - case .rtl: - isFirstPage ? .left : .right - } - } } /// A spread displaying two resources side by side (FXL only). diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index c5395e5e0d..646f3eb1ad 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -493,7 +493,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { return } - focusedResource = viewModel.readingOrder.firstIndexWithHREF(href) + focusedResource = viewModel.readingOrder.index(of: href) frame.origin = convertPointToNavigatorSpace(frame.origin) delegate?.spreadView(self, selectionDidChange: text, frame: frame) } diff --git a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift index 4bdbaef7a1..3a87ca4cfd 100644 --- a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift +++ b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift @@ -45,7 +45,8 @@ enum EPUBViewportAndLocationCalculator { .map { index in NavigatorViewport.Resource( href: readingOrder[index].url(), - progression: progression(index) + progression: progression(index), + layout: manifest.metadata.epubLayout(of: readingOrder[index]).layout ) } @@ -94,7 +95,11 @@ enum EPUBViewportAndLocationCalculator { // Build the locator from the nearest position, then override // progression fields with the actual continuous scroll values. + // The href and media type are taken from the rendered link, as + // the positions might reference another variant of the resource. locator = positionsOfFirstResource[firstPositionIndex].copy( + href: link.url(), + mediaType: link.mediaType, title: tableOfContentsTitleByHref[link.url()], locations: { $0.progression = firstProgressionInFirstResource diff --git a/Sources/Navigator/PDF/PDFViewportCalculator.swift b/Sources/Navigator/PDF/PDFViewportCalculator.swift index 1928b1bc17..dae0227c0c 100644 --- a/Sources/Navigator/PDF/PDFViewportCalculator.swift +++ b/Sources/Navigator/PDF/PDFViewportCalculator.swift @@ -94,7 +94,8 @@ enum PDFViewportCalculator { resources: [ NavigatorViewport.Resource( href: href, - progression: resourceProgression + progression: resourceProgression, + layout: .fixed ), ], progression: totalProgression, diff --git a/Sources/Navigator/Viewport/ViewportObservingNavigator.swift b/Sources/Navigator/Viewport/ViewportObservingNavigator.swift index 370daa20b6..6c5514eff5 100644 --- a/Sources/Navigator/Viewport/ViewportObservingNavigator.swift +++ b/Sources/Navigator/Viewport/ViewportObservingNavigator.swift @@ -61,9 +61,13 @@ public struct NavigatorViewport: Equatable, Sendable { /// visible, this is `0.0...1.0`. public var progression: ClosedRange - public init(href: AnyURL, progression: ClosedRange) { + /// Layout used to render the resource. + public var layout: Layout + + public init(href: AnyURL, progression: ClosedRange, layout: Layout) { self.href = href self.progression = progression + self.layout = layout } } } diff --git a/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift b/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift index 5091719938..209cb64030 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift @@ -9,4 +9,15 @@ import Foundation /// Hint about the nature of the layout for the linked resources. public enum EPUBLayout: String, Sendable { case fixed, reflowable + + public var layout: Layout { + switch self { + case .reflowable: .reflowable + case .fixed: .fixed + } + } + + public init(_ layout: Layout?) { + self = (layout == .fixed) ? .fixed : .reflowable + } } diff --git a/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift index bb8eb74e92..4533d218ae 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift @@ -18,14 +18,19 @@ public extension Metadata { package extension Metadata { /// Default EPUB layout of the publication, derived from `layout`. var epubLayout: EPUBLayout { - layout == .fixed ? .fixed : .reflowable + EPUBLayout(layout) } /// Resolves the EPUB layout of the given `link`. /// - /// The per-resource `Properties.epubLayout` override wins over the + /// A bitmap is always fixed-layout, as it cannot be reflowed. Otherwise, + /// the per-resource `Properties.epubLayout` override wins over the /// publication default. func epubLayout(of link: Link) -> EPUBLayout { - link.properties.epubLayout ?? epubLayout + if link.mediaType?.isBitmap == true { + return .fixed + } + + return link.properties.epubLayout ?? epubLayout } } diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 647a2c7f6f..e95fd19105 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -93,10 +93,21 @@ final class OPFParser: Loggable { let (resources, readingOrder) = splitResourcesAndReadingOrderLinks(manifestItems) var metadata = try EPUBMetadataParser(document: document, displayOptions: displayOptions, metas: metas).parse() - // If all reading order items are bitmaps, we infer a Divina. + // If all reading order items are bitmaps, or have a bitmap in their + // direct alternates, we infer a Divina. Only one level of alternates + // is considered. + let hasBitmapVariant: (Link) -> Bool = { link in + link.mediaType?.isBitmap == true + || link.alternates.contains { $0.mediaType?.isBitmap == true } + } + if readingOrder.allSatisfy(hasBitmapVariant) { + metadata.conformsTo.append(.divina) + } + + // The publication is fixed-layout only when the reading order links + // themselves are bitmaps. if readingOrder.allAreBitmap { metadata.layout = .fixed - metadata.conformsTo.append(.divina) } return Package( @@ -225,9 +236,9 @@ final class OPFParser: Loggable { /// They are only composed of an `idref` referencing one of the previously /// parsed resource (XML: idref -> id). /// - /// Handles image spine items with HTML fallbacks (and vice versa) by - /// putting the image in the reading order and the HTML in `alternates`. - /// This is because we prefer treating it as a Divina to render it. + /// Spine items with a fallback keep the spine item in the reading order + /// and put the fallback in its `alternates`. Navigators can then choose + /// which variant to render. /// /// - Parameter manifestItems: The items parsed from the manifest. /// - Returns: The `Link` in `resources` and in `readingOrder`. @@ -250,17 +261,14 @@ final class OPFParser: Loggable { let item = items.remove(at: index) var spineLink = item.link - // Resolve fallback: prefer bitmaps as primary to treat image-based - // EPUBs as Divina + // Resolve fallback: the spine item is the primary link and the + // fallback becomes an alternate. if let fallbackId = item.fallbackId, let fallbackIndex = items.firstIndex(where: { $0.id == fallbackId }) { let fallbackItem = items.remove(at: fallbackIndex) - spineLink = resolveFallbackChain( - spineLink: spineLink, - fallbackLink: fallbackItem.link - ) + spineLink.alternates = [fallbackItem.link] } if @@ -329,25 +337,4 @@ final class OPFParser: Loggable { return otherProperties } - - /// Resolves which link should be primary vs alternate when a fallback is - /// present. - /// - /// We prefer bitmaps as primary to treat image-based EPUBs as Divina. - private func resolveFallbackChain( - spineLink: Link, - fallbackLink: Link - ) -> Link { - var link = spineLink - // If fallback is a bitmap and spine is HTML, swap them. - if spineLink.mediaType?.isHTML == true, fallbackLink.mediaType?.isBitmap == true { - link = fallbackLink - // Transfer spine properties (like page spread) to the image - link.properties = spineLink.properties - link.alternates = [spineLink] - } else { - link.alternates = [fallbackLink] - } - return link - } } diff --git a/Tests/NavigatorTests/EPUB/EPUBReadingOrderTests.swift b/Tests/NavigatorTests/EPUB/EPUBReadingOrderTests.swift new file mode 100644 index 0000000000..953147e78a --- /dev/null +++ b/Tests/NavigatorTests/EPUB/EPUBReadingOrderTests.swift @@ -0,0 +1,199 @@ +// +// 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. +// + +@testable import ReadiumNavigator +import ReadiumShared +import Testing + +enum EPUBReadingOrderTests { + @Suite("Init") struct Init { + let xhtml = Link(href: "p1.xhtml", mediaType: .xhtml) + let jpeg = Link(href: "p1.jpg", mediaType: .jpeg) + let smil = Link(href: "p1.smil", mediaType: .smil) + + @Test(".default renders the links as authored") + func defaultVariant() { + let main = link(xhtml, alternates: [jpeg]) + let sut = EPUBReadingOrder(readingOrder: [main], preferredVariant: .default) + #expect(sut.links == [main]) + } + + @Test(".html keeps an HTML main link") + func htmlVariantWithHTMLMain() { + let main = link(xhtml, alternates: [jpeg]) + let sut = EPUBReadingOrder(readingOrder: [main], preferredVariant: .html) + #expect(sut.links == [main]) + } + + @Test(".html promotes an HTML alternate") + func htmlVariantWithHTMLAlternate() { + let main = link(jpeg, alternates: [xhtml]) + let sut = EPUBReadingOrder(readingOrder: [main], preferredVariant: .html) + #expect(sut.links[0].href == "p1.xhtml") + #expect(sut.links[0].mediaType == .xhtml) + #expect(sut.links[0].alternates == [jpeg]) + #expect(sut.links[0].properties.epubLayout == nil) + } + + @Test(".image promotes a bitmap alternate") + func mediaVariant() { + let main = link(xhtml, alternates: [jpeg]) + let sut = EPUBReadingOrder(readingOrder: [main], preferredVariant: .image) + #expect(sut.links[0].href == "p1.jpg") + #expect(sut.links[0].mediaType == .jpeg) + #expect(sut.links[0].alternates == [xhtml]) + } + + @Test("keeps the main link when no alternate matches", arguments: [EPUBResourceVariant.html, .image]) + func noMatchingAlternate(variant: EPUBResourceVariant) { + let main = link(Link(href: "p1.pdf", mediaType: .pdf), alternates: [smil]) + let sut = EPUBReadingOrder(readingOrder: [main], preferredVariant: variant) + #expect(sut.links == [main]) + } + + @Test("transfers the title, rels and properties of the main link") + func transfersMainFields() { + var main = link(xhtml, alternates: [ + Link(href: "p1.jpg", mediaType: .jpeg, title: "Alternate", properties: Properties(["page": "right"]), height: 100, width: 50), + ]) + main.title = "Main" + main.rels = [.cover] + main.properties.page = .left + + let sut = EPUBReadingOrder(readingOrder: [main], preferredVariant: .image) + + #expect(sut.links[0].title == "Main") + #expect(sut.links[0].rels == [.cover]) + #expect(sut.links[0].properties.page == .left) + #expect(sut.links[0].height == 100) + #expect(sut.links[0].width == 50) + } + + @Test("keeps the other alternates after the demoted main link") + func reordersAlternates() { + let main = link(xhtml, alternates: [smil, jpeg]) + let sut = EPUBReadingOrder(readingOrder: [main], preferredVariant: .image) + #expect(sut.links[0].alternates == [xhtml, smil]) + } + + @Test("removes only the promoted alternate among duplicates") + func duplicateAlternates() { + let main = link(xhtml, alternates: [jpeg, jpeg]) + let sut = EPUBReadingOrder(readingOrder: [main], preferredVariant: .image) + #expect(sut.links[0].alternates == [xhtml, jpeg]) + } + } + + @Suite("Index of HREF") struct IndexOfHREF { + let sut = EPUBReadingOrder( + readingOrder: [ + link(Link(href: "p1.xhtml", mediaType: .xhtml), alternates: [Link(href: "p1.jpg", mediaType: .jpeg)]), + link(Link(href: "p2.xhtml", mediaType: .xhtml), alternates: [Link(href: "p3.xhtml", mediaType: .xhtml)]), + Link(href: "p3.xhtml", mediaType: .xhtml), + ], + preferredVariant: .default + ) + + @Test("matches a rendered link") + func matchesRenderedLink() { + #expect(sut.index(of: AnyURL(string: "p2.xhtml")!) == 1) + } + + @Test("matches an alternate") + func matchesAlternate() { + #expect(sut.index(of: AnyURL(string: "p1.jpg")!) == 0) + } + + @Test("a rendered link wins over an alternate of an earlier link") + func renderedLinkWins() { + #expect(sut.index(of: AnyURL(string: "p3.xhtml")!) == 2) + } + + @Test("ignores the query and fragment", arguments: [ + ("p2.xhtml#id", 1), + ("p1.jpg?q=1#id", 0), + ]) + func queryAndFragmentFallback(href: String, expected: Int) { + #expect(sut.index(of: AnyURL(string: href)!) == expected) + } + + @Test("returns nil for an unknown href") + func unknownHREF() { + #expect(sut.index(of: AnyURL(string: "unknown.xhtml")!) == nil) + } + } + + @Suite("Resolve locator") struct ResolveLocator { + let readingOrder = [ + Link(href: "p0.xhtml", mediaType: .xhtml), + link(Link(href: "p1.xhtml", mediaType: .xhtml), alternates: [Link(href: "p1.jpg", mediaType: .jpeg)]), + ] + + let textLocator = Locator( + href: AnyURL(string: "p1.xhtml")!, + mediaType: .xhtml, + locations: .init( + fragments: ["id"], + progression: 0.5, + totalProgression: 0.75, + position: 2, + otherLocations: ["cssSelector": "#id"] + ), + text: .init(highlight: "Hello") + ) + + @Test("resolves a text locator to the rendered bitmap") + func textLocatorToBitmap() throws { + let sut = EPUBReadingOrder(readingOrder: readingOrder, preferredVariant: .image) + let result = try #require(sut.resolve(textLocator)) + + #expect(result.index == 1) + #expect(result.locator.href.string == "p1.jpg") + #expect(result.locator.mediaType == .jpeg) + #expect(result.locator.locations.fragments.isEmpty) + #expect(result.locator.locations.otherLocations.isEmpty) + #expect(result.locator.locations.progression == 0.5) + #expect(result.locator.locations.totalProgression == 0.75) + #expect(result.locator.locations.position == 2) + } + + @Test("keeps a locator targeting the rendered link unchanged") + func unchangedLocator() throws { + let sut = EPUBReadingOrder(readingOrder: readingOrder, preferredVariant: .html) + let result = try #require(sut.resolve(textLocator)) + + #expect(result.index == 1) + #expect(result.locator == textLocator) + } + + @Test("resolves a bitmap locator to the rendered HTML") + func bitmapLocatorToHTML() throws { + let sut = EPUBReadingOrder(readingOrder: readingOrder, preferredVariant: .html) + let locator = Locator(href: AnyURL(string: "p1.jpg")!, mediaType: .jpeg, locations: .init(progression: 0)) + let result = try #require(sut.resolve(locator)) + + #expect(result.index == 1) + #expect(result.locator.href.string == "p1.xhtml") + #expect(result.locator.mediaType == .xhtml) + #expect(result.locator.locations.progression == 0) + } + + @Test("returns nil for an unknown href") + func unknownHREF() { + let sut = EPUBReadingOrder(readingOrder: readingOrder, preferredVariant: .image) + let locator = Locator(href: AnyURL(string: "unknown.xhtml")!, mediaType: .xhtml) + #expect(sut.resolve(locator) == nil) + } + } +} + +// MARK: - Helpers + +private func link(_ link: Link, alternates: [Link]) -> Link { + var link = link + link.alternates = alternates + return link +} diff --git a/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift b/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift index fc28a880ed..fe789d8a32 100644 --- a/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift +++ b/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift @@ -287,6 +287,30 @@ enum EPUBSpreadTests { #expect(pair.second.link.href == "p2.html") } + @Test("bitmap resources forced fixed in a reflowable publication are combined") + func renderedBitmapsInReflowablePublicationAreCombined() { + var p1 = link("p1.html", page: .left) + p1.alternates = [Link(href: "p1.jpg", mediaType: .jpeg)] + var p2 = link("p2.html", page: .right) + p2.alternates = [Link(href: "p2.jpg", mediaType: .jpeg)] + let pub = reflowablePublication(readingOrder: [link("c1.html"), p1, p2]) + let readingOrder = EPUBReadingOrder(readingOrder: pub.readingOrder, preferredVariant: .image) + let spreads = EPUBSpread.makeSpreads( + for: pub, + readingOrder: readingOrder.links, + readingProgression: .ltr, + spread: true + ) + + #expect(spreads.count == 2) + guard case .single = spreads[0], case let .double(pair) = spreads[1] else { + Issue.record("Expected .single, .double") + return + } + #expect(pair.first.link.href == "p1.jpg") + #expect(pair.second.link.href == "p2.jpg") + } + @Test("a fixed resource is not combined with a following reflowable one") func fixedNotCombinedWithReflowable() { let pub = reflowablePublication(readingOrder: [ @@ -423,6 +447,47 @@ enum EPUBSpreadTests { } } + @Suite("JSON") struct JSON { + let baseURL = AnyURL(string: "https://readium/publication/")!.absoluteURL! + + @Test( + "single spread without a page position is centered", + arguments: [ReadiumNavigator.ReadingProgression.ltr, .rtl], [0, 3] + ) + func singleWithoutPageIsCentered(readingProgression: ReadiumNavigator.ReadingProgression, index: Int) { + let spread = EPUBSingleSpread(resource: EPUBSpreadResource(index: index, link: link("p.html"))) + #expect(pages(in: spread.json(forBaseURL: baseURL, readingProgression: readingProgression)) == ["center"]) + } + + @Test( + "single spread keeps an explicit page position", + arguments: [ReadiumNavigator.ReadingProgression.ltr, .rtl], ["left", "right"] + ) + func singleKeepsExplicitPage(readingProgression: ReadiumNavigator.ReadingProgression, page: String) { + let spread = EPUBSingleSpread(resource: EPUBSpreadResource(index: 1, link: link("p.html", page: ReadiumShared.Properties.Page(rawValue: page)))) + #expect(pages(in: spread.json(forBaseURL: baseURL, readingProgression: readingProgression)) == [page]) + } + + @Test("unpaired first page with offsetFirstPage: false is centered") + func unpairedFirstPageIsCentered() { + let pub = fxlPublication(readingOrder: [ + link("cover.html"), + link("c1.html", layout: .reflowable), + ]) + let spreads = makeSpreads(publication: pub, spread: true, offsetFirstPage: false) + + guard case let .single(cover) = spreads.first else { + Issue.record("Expected cover to be .single") + return + } + #expect(pages(in: cover.json(forBaseURL: baseURL, readingProgression: .ltr)) == ["center"]) + } + + private func pages(in json: [JSONValue]) -> [String?] { + json.map { $0.object?["page"]?.string } + } + } + struct PositionCount { @Test("for a single spread") func single() { diff --git a/Tests/NavigatorTests/EPUB/EPUBViewportAndLocationCalculatorTests.swift b/Tests/NavigatorTests/EPUB/EPUBViewportAndLocationCalculatorTests.swift index 27ced20c38..604a1eb07d 100644 --- a/Tests/NavigatorTests/EPUB/EPUBViewportAndLocationCalculatorTests.swift +++ b/Tests/NavigatorTests/EPUB/EPUBViewportAndLocationCalculatorTests.swift @@ -55,6 +55,45 @@ enum EPUBViewportAndLocationCalculatorTests { #expect(viewport.resources.first(where: { $0.href.string == ro[1].href })?.progression == 0.0 ... 1.0) } + @Test("records the rendered layout of each visible resource") + func layout() { + var fixed = Link(href: "chap2.html", mediaType: .html) + fixed.properties.epubLayout = .fixed + let ro = [ + Link(href: "chap1.html", mediaType: .html), + fixed, + Link(href: "page3.jpg", mediaType: .jpeg), + ] + let manifest = makeManifest(readingOrder: ro) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( + readingOrderIndices: 0 ... 2, + progression: { _ in 0.0 ... 1.0 }, + manifest: manifest, + readingOrder: ro, + positionsByReadingOrder: [], + tableOfContentsTitleByHref: [:] + ) + #expect(viewport.resources.map(\.layout) == [.reflowable, .fixed, .fixed]) + } + + @Test("records a fixed layout for an HTML resource rendered as its bitmap alternate") + func layoutOfPromotedBitmap() { + var main = Link(href: "page1.xhtml", mediaType: .xhtml) + main.alternates = [Link(href: "page1.jpg", mediaType: .jpeg)] + let manifest = makeManifest(readingOrder: [main]) + let ro = EPUBReadingOrder(readingOrder: manifest.readingOrder, preferredVariant: .image).links + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( + readingOrderIndices: 0 ... 0, + progression: { _ in 0.0 ... 1.0 }, + manifest: manifest, + readingOrder: ro, + positionsByReadingOrder: [], + tableOfContentsTitleByHref: [:] + ) + #expect(viewport.resources.map(\.href.string) == ["page1.jpg"]) + #expect(viewport.resources.map(\.layout) == [.fixed]) + } + @Test("total progression range lower bound matches locator totalProgression") func totalProgressionLowerBoundMatchesLocator() { let manifest = makeManifest(count: 2) @@ -467,6 +506,27 @@ enum EPUBViewportAndLocationCalculatorTests { #expect(viewport.resources.first(where: { $0.href.string == ro[1].href })?.progression == 0.2 ... 0.8) } } + + @Suite("Locator - rendered variant") struct LocatorWithRenderedVariant { + @Test("uses the href and media type of the rendered link instead of the position") + func usesRenderedLinkHREFAndMediaType() { + let manifest = makeManifest(count: 2) + var readingOrder = manifest.readingOrder + readingOrder[0] = Link(href: "chap1.jpg", mediaType: .jpeg) + + let (locator, _) = EPUBViewportAndLocationCalculator.compute( + readingOrderIndices: 0 ... 0, + progression: { _ in 0.0 ... 1.0 }, + manifest: manifest, + readingOrder: readingOrder, + positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 1), + tableOfContentsTitleByHref: [:] + ) + #expect(locator?.href.string == "chap1.jpg") + #expect(locator?.mediaType == .jpeg) + #expect(locator?.locations.position == 1) + } + } } // MARK: - Helpers diff --git a/Tests/NavigatorTests/PDF/PDFViewportCalculatorTests.swift b/Tests/NavigatorTests/PDF/PDFViewportCalculatorTests.swift index ff431c9bac..4865512a7a 100644 --- a/Tests/NavigatorTests/PDF/PDFViewportCalculatorTests.swift +++ b/Tests/NavigatorTests/PDF/PDFViewportCalculatorTests.swift @@ -122,6 +122,19 @@ enum PDFViewportCalculatorTests { #expect(viewport?.resources.first?.progression == 0.0 ... 1.0) } + @Test("resources are fixed-layout") + func fixedLayout() { + let (_, viewport) = PDFViewportCalculator.compute( + currentPageNumber: 1, + visiblePageNumbers: 1 ... 1, + pageCount: 4, + currentResourceIndex: 0, + readingOrder: makeReadingOrder(count: 1), + positionsByReadingOrder: makePositions(resourceCount: 1, pagesPerResource: 4) + ) + #expect(viewport?.resources.first?.layout == .fixed) + } + @Test("middle pages — progression is an interior range") func middlePages() { let (_, viewport) = PDFViewportCalculator.compute( diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift index 0e16bd16b3..c2d6776dad 100644 --- a/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift +++ b/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift @@ -115,6 +115,20 @@ enum MetadataEPUBTests { #expect(reflowableMetadata.epubLayout(of: fixedLink) == .fixed) } + @Test("of a bitmap link is fixed", arguments: [nil, Layout.reflowable, Layout.fixed]) + func bitmapIsFixed(layout: Layout?) { + let metadata = Metadata(title: "Test", layout: layout) + #expect(metadata.epubLayout(of: Link(href: "page.jpg", mediaType: .jpeg)) == .fixed) + } + + @Test("of a bitmap link ignores a reflowable override") + func bitmapIgnoresReflowableOverride() { + var link = Link(href: "page.jpg", mediaType: .jpeg) + link.properties.epubLayout = .reflowable + let metadata = Metadata(title: "Test", layout: .reflowable) + #expect(metadata.epubLayout(of: link) == .fixed) + } + @Test("of a link falls back to fixed metadata") func linkFallsBackToFixed() { let metadata = Metadata(title: "Test", layout: .fixed) diff --git a/Tests/StreamerTests/Fixtures/OPF/fallback-html-image-partial.opf b/Tests/StreamerTests/Fixtures/OPF/fallback-html-image-partial.opf new file mode 100644 index 0000000000..a5975086da --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/fallback-html-image-partial.opf @@ -0,0 +1,15 @@ + + + + HTML in Spine with Partial Image Fallbacks Test + + + + + + + + + + + diff --git a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift index 3d3c36a4e9..5ea7a3e8e8 100644 --- a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift @@ -152,25 +152,25 @@ struct OPFParserTests { #expect(sut.resources.isEmpty) } - /// When HTML is in the spine with an image fallback, we swap: the image - /// should be in readingOrder and HTML should be added as an alternate. + /// When HTML is in the spine with an image fallback, the HTML should + /// stay in readingOrder and the image should be added as an alternate. @Test func parseHTMLInSpineWithImageFallback() throws { let sut = try parseManifest("fallback-html-in-spine", at: "EPUB/content.opf").manifest #expect(sut.readingOrder.count == 2) - // First item: image swapped into readingOrder, HTML as alternate - #expect(sut.readingOrder[0].href == "EPUB/page1.jpg") - #expect(sut.readingOrder[0].mediaType == .jpeg) + // First item: HTML in readingOrder, image as alternate + #expect(sut.readingOrder[0].href == "EPUB/page1.xhtml") + #expect(sut.readingOrder[0].mediaType == .xhtml) #expect(sut.readingOrder[0].alternates == [ - Link(href: "EPUB/page1.xhtml", mediaType: .xhtml), + Link(href: "EPUB/page1.jpg", mediaType: .jpeg), ]) - // Second item: image swapped into readingOrder, HTML as alternate - #expect(sut.readingOrder[1].href == "EPUB/page2.png") - #expect(sut.readingOrder[1].mediaType == .png) + // Second item: HTML in readingOrder, image as alternate + #expect(sut.readingOrder[1].href == "EPUB/page2.xhtml") + #expect(sut.readingOrder[1].mediaType == .xhtml) #expect(sut.readingOrder[1].alternates == [ - Link(href: "EPUB/page2.xhtml", mediaType: .xhtml), + Link(href: "EPUB/page2.png", mediaType: .png), ]) // Fallback images should not be in resources @@ -236,6 +236,28 @@ struct OPFParserTests { #expect(sut.metadata.conformsTo.contains(.epub)) #expect(!sut.metadata.conformsTo.contains(.divina)) } + + /// When all HTML spine items have a bitmap fallback, the publication + /// conforms to Divina but stays reflowable, as the reading order + /// links are not bitmaps. + @Test func parseAllHTMLWithImageFallbacksSetsDivinaProfileOnly() throws { + let sut = try parseManifest("fallback-html-in-spine", at: "EPUB/content.opf").manifest + + #expect(sut.metadata.layout == .reflowable) + #expect(sut.metadata.conformsTo.contains(.epub)) + #expect(sut.metadata.conformsTo.contains(.divina)) + #expect(sut.readingOrder.allSatisfy { $0.mediaType == .xhtml }) + #expect(sut.readingOrder.allSatisfy { $0.alternates.first?.mediaType?.isBitmap == true }) + } + + /// When only some HTML spine items have a bitmap fallback, the + /// publication doesn't conform to Divina. + @Test func parsePartialHTMLWithImageFallbacksDoesNotSetDivinaProfile() throws { + let sut = try parseManifest("fallback-html-image-partial", at: "EPUB/content.opf").manifest + + #expect(sut.metadata.layout == .reflowable) + #expect(!sut.metadata.conformsTo.contains(.divina)) + } } struct MediaOverlays {