diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a2c2bed9..4365c251ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. Take a look ## [Unreleased] +### Added + +#### Navigator + +* The EPUB navigator supports publications mixing reflowable and fixed-layout resources, rendering each resource according to its own layout. + ### Changed #### Shared diff --git a/Sources/Navigator/EPUB/EPUBExtensions.swift b/Sources/Navigator/EPUB/EPUBExtensions.swift deleted file mode 100644 index f8d43769ad..0000000000 --- a/Sources/Navigator/EPUB/EPUBExtensions.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// 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 ReadiumShared - -extension Metadata { - var epubLayout: EPUBLayout { - layout == .fixed ? .fixed : .reflowable - } -} diff --git a/Sources/Navigator/EPUB/EPUBLayouts.swift b/Sources/Navigator/EPUB/EPUBLayouts.swift new file mode 100644 index 0000000000..6307c2ffbd --- /dev/null +++ b/Sources/Navigator/EPUB/EPUBLayouts.swift @@ -0,0 +1,31 @@ +// +// 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 + +/// The set of EPUB layouts used by the resources of a reading order. +/// +/// A publication can mix reflowable and fixed-layout resources, so knowing +/// which layouts are actually rendered is what determines whether a preference +/// is effective. +struct EPUBLayouts { + /// Layout used by the resources which don't override it. + let `default`: EPUBLayout + + private let layouts: Set + + init(readingOrder: [Link], metadata: Metadata) { + `default` = metadata.epubLayout + layouts = Set(readingOrder.map { metadata.epubLayout(of: $0) }) + } + + /// Indicates whether the reading order contains at least one resource + /// rendered with the given `layout`. + func contains(_ layout: EPUBLayout) -> Bool { + layouts.contains(layout) + } +} diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 5c21f07cd5..fd739d895a 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -1036,7 +1036,7 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { // the application's bars. var insets = view.window?.safeAreaInsets ?? .zero - switch publication.metadata.epubLayout { + switch spreadView.spread.layout(in: publication) { 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 @@ -1258,7 +1258,7 @@ extension EPUBNavigatorViewController: EditingActionsControllerDelegate { extension EPUBNavigatorViewController: PaginationViewDelegate { func paginationView(_ paginationView: PaginationView, pageViewAtIndex index: Int) -> (UIView & PageView)? { let spread = spreads[index] - let spreadViewType = (publication.metadata.layout == .fixed) ? EPUBFixedSpreadView.self : EPUBReflowableSpreadView.self + let spreadViewType = (spread.layout(in: publication) == .fixed) ? EPUBFixedSpreadView.self : EPUBReflowableSpreadView.self let spreadView = spreadViewType.init( viewModel: viewModel, spread: spread, diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index cf9aece9ce..a1689be0a4 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -243,7 +243,8 @@ enum EPUBScriptScope { func editor(of preferences: EPUBPreferences) -> EPUBPreferencesEditor { EPUBPreferencesEditor( initialPreferences: preferences, - metadata: publication.metadata, + publication: publication, + readingOrder: readingOrder, defaults: config.defaults ) } @@ -313,7 +314,7 @@ enum EPUBScriptScope { guard let link = publication.linkWithHREF(href), link.mediaType?.isHTML == true, - publication.metadata.epubLayout == .reflowable + publication.metadata.epubLayout(of: link) == .reflowable else { return resource } diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index e067390383..9b7f769544 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -57,6 +57,19 @@ enum EPUBSpread: EPUBSpreadProtocol { } } + /// Returns the layout used to render this spread. + /// + /// A double spread is always fixed-layout. A single spread uses the layout + /// of its resource, resolved from the publication `metadata`. + func layout(in publication: Publication) -> EPUBLayout { + switch self { + case let .single(spread): + return publication.metadata.epubLayout(of: spread.resource.link) + case .double: + return .fixed + } + } + private var spread: EPUBSpreadProtocol { switch self { case let .single(spread): @@ -133,13 +146,14 @@ enum EPUBSpread: EPUBSpreadProtocol { var first = readingOrder[index] // The first resource (often the cover) has special rules for its - // position in the spread. - if index == 0 { + // position in the spread. They only apply to fixed-layout + // resources, as a reflowable one is never paired with another. + if index == 0, publication.metadata.epubLayout(of: first) == .fixed { if let offsetFirstPage = offsetFirstPage { // User explicitly chose to offset (or not) the first page. first.properties.page = offsetFirstPage ? .center : nil - } else if first.properties.page == nil, publication.metadata.layout == .fixed { - // For FXL publications, default to displaying the first + } else if first.properties.page == nil { + // For FXL resources, default to displaying the first // page (typically a cover) on its own when the publication // doesn't provide an explicit page position. This is the // behavior of Apple Books, so it's expected by publishers. @@ -152,12 +166,12 @@ enum EPUBSpread: EPUBSpreadProtocol { let nextIndex = index + 1 - // To be displayed together, two pages must be part of a fixed - // layout publication and have consecutive position hints - // (Properties.Page). + // To be displayed together, two pages must both be fixed-layout + // resources and have consecutive position hints (Properties.Page). if let second = readingOrder.getOrNil(nextIndex), - publication.metadata.layout == .fixed, + publication.metadata.epubLayout(of: first) == .fixed, + publication.metadata.epubLayout(of: second) == .fixed, areConsecutive(first, second, readingProgression: publication.metadata.readingProgression) { spreads.append(.double( diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index 248620fd53..a04fef4de4 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift @@ -18,7 +18,7 @@ public struct EPUBPreferences: ConfigurablePreferences, Sendable { /// spread). public var columnCount: ColumnCount? - /// Method for fitting the content of a fixed-layout publication within the + /// Method for fitting the content of fixed-layout resources within the /// viewport. /// /// - `auto` or `page`: Fit entire page within viewport (default). @@ -79,7 +79,7 @@ public struct EPUBPreferences: ConfigurablePreferences, Sendable { /// scrolling instead of synthetic pagination. public var scroll: Bool? - /// Indicates if the fixed-layout publication should be rendered with a + /// Indicates if the fixed-layout resources should be rendered with a /// synthetic spread (dual-page). public var spread: Spread? diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift index 986a05af65..e58023964f 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift @@ -13,15 +13,44 @@ import ReadiumShared /// interface or modifying existing preferences. It includes rules for /// adjusting preferences, such as the supported values or ranges. public final class EPUBPreferencesEditor: StatefulPreferencesEditor { - public let layout: EPUBLayout + /// Default layout of the publication. + /// + /// Individual resources can override it, so this alone does not determine + /// whether a preference is effective. Check `isEffective` on each + /// preference instead. + public var defaultLayout: EPUBLayout { + layouts.default + } + + @available(*, unavailable, renamed: "defaultLayout") + public var layout: EPUBLayout { + fatalError() + } + + /// Layouts used by the resources of the reading order. + private let layouts: EPUBLayouts + private let defaults: EPUBDefaults + /// Creates an editor for the given `publication`. + /// + /// - Parameters: + /// - readingOrder: Custom reading order rendered by the navigator, when + /// it differs from `publication.readingOrder`. This determines which + /// preferences are effective, since a publication can mix reflowable + /// and fixed-layout resources. public init( initialPreferences: EPUBPreferences, - metadata: Metadata, + publication: Publication, + readingOrder: [Link]? = nil, defaults: EPUBDefaults ) { - layout = metadata.epubLayout + let metadata = publication.metadata + + layouts = EPUBLayouts( + readingOrder: readingOrder ?? publication.readingOrder, + metadata: metadata + ) self.defaults = defaults super.init( @@ -30,9 +59,18 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = enumPreference( preference: \.columnCount, setting: \.columnCount, defaultEffectiveValue: defaults.columnCount ?? .auto, - isEffective: { [layout] in - layout == .reflowable + isEffective: { [layouts] in + layouts.contains(.reflowable) && !$0.settings.scroll }, supportedValues: [.auto, .one, .two] @@ -67,24 +105,24 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = enumPreference( preference: \.fit, setting: \.fit, defaultEffectiveValue: defaults.fit ?? .auto, - isEffective: { [layout] _ in layout == .fixed }, + isEffective: { [layouts] _ in layouts.contains(.fixed) }, supportedValues: [.auto, .page, .width] ) /// Default typeface for the text. /// - /// Only effective with reflowable publications. + /// Only effective when the publication contains reflowable resources. public lazy var fontFamily: AnyPreference = preference( preference: \.fontFamily, setting: \.fontFamily, - isEffective: { [layout] _ in layout == .reflowable } + isEffective: { [layouts] _ in layouts.contains(.reflowable) } ) /// Base text font size as a percentage. Default to 100%. @@ -92,13 +130,13 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = rangePreference( preference: \.fontSize, setting: \.fontSize, defaultEffectiveValue: defaults.fontSize ?? 1.0, - isEffective: { [layout] _ in layout == .reflowable }, + isEffective: { [layouts] _ in layouts.contains(.reflowable) }, supportedRange: 0.1 ... 5.0, progressionStrategy: .increment(0.1), format: \.percentageString @@ -109,14 +147,14 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = rangePreference( preference: \.fontWeight, effectiveValue: { $0.settings.fontWeight }, defaultEffectiveValue: defaults.fontWeight ?? 1.0, - isEffective: { [layout] in - layout == .reflowable + isEffective: { [layouts] in + layouts.contains(.reflowable) && $0.preferences.fontWeight != nil }, supportedRange: 0.0 ... 2.5, @@ -127,7 +165,7 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = @@ -135,8 +173,8 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = enumPreference( preference: \.imageFilter, setting: \.imageFilter, - isEffective: { $0.settings.theme == .dark }, + isEffective: { [layouts] in + layouts.contains(.reflowable) + && $0.settings.theme == .dark + }, supportedValues: [nil, .darken, .invert] ) @@ -169,7 +210,7 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = @@ -177,8 +218,8 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = @@ -199,8 +240,8 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = rangePreference( preference: \.lineHeight, effectiveValue: { $0.settings.lineHeight }, defaultEffectiveValue: defaults.lineHeight ?? 1.2, - isEffective: { [layout] in - layout == .reflowable + isEffective: { [layouts] in + layouts.contains(.reflowable) && !$0.settings.publisherStyles && $0.preferences.lineHeight != nil }, @@ -234,27 +275,27 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = preference( preference: \.offsetFirstPage, setting: \.offsetFirstPage, - isEffective: { [layout] in - layout == .fixed + isEffective: { [layouts] in + layouts.contains(.fixed) && $0.settings.spread != .never } ) /// Factor applied to horizontal margins. Default to 1. /// - /// Only effective with reflowable publications. + /// Only effective when the publication contains reflowable resources. public lazy var pageMargins: AnyRangePreference = rangePreference( preference: \.pageMargins, setting: \.pageMargins, defaultEffectiveValue: defaults.pageMargins ?? 1.0, - isEffective: { [layout] _ in layout == .reflowable }, + isEffective: { [layouts] _ in layouts.contains(.reflowable) }, supportedRange: 0.0 ... 4.0, progressionStrategy: .increment(0.3), format: { $0.formatDecimal(maximumFractionDigits: 5) } @@ -263,7 +304,7 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = @@ -271,8 +312,8 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = rangePreference( preference: \.paragraphSpacing, effectiveValue: { $0.settings.paragraphSpacing }, defaultEffectiveValue: defaults.paragraphSpacing ?? 0.0, - isEffective: { [layout] in - layout == .reflowable + isEffective: { [layouts] in + layouts.contains(.reflowable) && !$0.settings.publisherStyles && $0.preferences.paragraphSpacing != nil }, @@ -305,13 +346,13 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = preference( preference: \.publisherStyles, setting: \.publisherStyles, defaultEffectiveValue: defaults.publisherStyles ?? true, - isEffective: { [layout] _ in layout == .reflowable } + isEffective: { [layouts] _ in layouts.contains(.reflowable) } ) /// Direction of the reading progression across resources. @@ -329,42 +370,42 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = preference( preference: \.scroll, setting: \.scroll, defaultEffectiveValue: defaults.scroll ?? false, - isEffective: { [layout] in - layout == .reflowable && !$0.settings.verticalText + isEffective: { [layouts] in + layouts.contains(.reflowable) && !$0.settings.verticalText } ) - /// Indicates if the fixed-layout publication should be rendered with a + /// Indicates if the fixed-layout resources should be rendered with a /// synthetic spread (dual-page). /// - /// Only effective with fixed-layout publications. + /// Only effective when the publication contains fixed-layout resources. public lazy var spread: AnyEnumPreference = enumPreference( preference: \.spread, setting: \.spread, defaultEffectiveValue: defaults.spread ?? .auto, - isEffective: { [layout] _ in layout == .fixed }, + isEffective: { [layouts] _ in layouts.contains(.fixed) }, supportedValues: [.auto, .never, .always] ) /// Page text alignment. /// /// Only effective when: - /// - the publication is reflowable + /// - the publication contains reflowable resources /// - `publisherStyles` is off /// - the layout is LTR or RTL public lazy var textAlign: AnyEnumPreference = enumPreference( preference: \.textAlign, setting: \.textAlign, - isEffective: { [layout] in - layout == .reflowable + isEffective: { [layouts] in + layouts.contains(.reflowable) && [.default, .rtl].contains($0.settings.cssLayout.stylesheets) && !$0.settings.publisherStyles && $0.preferences.textAlign != nil @@ -375,7 +416,7 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = preference( preference: \.textColor, @@ -384,47 +425,47 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = preference( preference: \.textNormalization, setting: \.textNormalization, defaultEffectiveValue: defaults.textNormalization ?? false, - isEffective: { [layout] _ in layout == .reflowable } + isEffective: { [layouts] _ in layouts.contains(.reflowable) } ) /// Reader theme (light, dark, sepia). /// - /// Only effective with reflowable publications. + /// Only effective when the publication contains reflowable resources. public lazy var theme: AnyEnumPreference = enumPreference( preference: \.theme, setting: \.theme, defaultEffectiveValue: .light, - isEffective: { [layout] _ in layout == .reflowable }, + isEffective: { [layouts] _ in layouts.contains(.reflowable) }, supportedValues: [.light, .dark, .sepia] ) /// Scale applied to all element font sizes. /// /// Only effective when: - /// - the publication is reflowable + /// - the publication contains reflowable resources /// - `publisherStyles` is off public lazy var typeScale: AnyRangePreference = rangePreference( preference: \.typeScale, effectiveValue: { $0.settings.typeScale }, defaultEffectiveValue: defaults.typeScale ?? 1.2, - isEffective: { [layout] in - layout == .reflowable + isEffective: { [layouts] in + layouts.contains(.reflowable) && !$0.settings.publisherStyles && $0.preferences.typeScale != nil }, @@ -437,27 +478,27 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = preference( preference: \.verticalText, setting: \.verticalText, defaultEffectiveValue: false, - isEffective: { [layout] _ in layout == .reflowable } + isEffective: { [layouts] _ in layouts.contains(.reflowable) } ) /// Space between words. /// /// Only effective when: - /// - the publication is reflowable + /// - the publication contains reflowable resources /// - the layout is LTR public lazy var wordSpacing: AnyRangePreference = rangePreference( preference: \.wordSpacing, effectiveValue: { $0.settings.wordSpacing }, defaultEffectiveValue: defaults.wordSpacing ?? 0.0, - isEffective: { [layout] in - layout == .reflowable + isEffective: { [layouts] in + layouts.contains(.reflowable) && $0.settings.cssLayout.stylesheets == .default && !$0.settings.publisherStyles && $0.preferences.wordSpacing != nil diff --git a/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift index 79c2d79310..bb8eb74e92 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift @@ -14,3 +14,18 @@ public extension Metadata { try? otherMetadata[mediaOverlayKey]?.decode() } } + +package extension Metadata { + /// Default EPUB layout of the publication, derived from `layout`. + var epubLayout: EPUBLayout { + layout == .fixed ? .fixed : .reflowable + } + + /// Resolves the EPUB layout of the given `link`. + /// + /// The per-resource `Properties.epubLayout` override wins over the + /// publication default. + func epubLayout(of link: Link) -> EPUBLayout { + link.properties.epubLayout ?? epubLayout + } +} diff --git a/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift index d8bd00a4ef..600486882b 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift @@ -6,12 +6,31 @@ import Foundation +private let layoutKey: String = "layout" + /// EPUB Link Properties Extension /// https://readium.org/webpub-manifest/schema/extensions/epub/properties.schema.json public extension Properties { - /// Identifies content contained in the linked resource, that cannot be strictly identified - /// using a media type. + /// Identifies content contained in the linked resource, that cannot be + /// strictly identified using a media type. var contains: [String] { otherProperties["contains"]?.decode() ?? [] } + + /// Hint about the nature of the layout for the linked resource, overriding + /// the publication-wide `Metadata.layout`. + /// + /// In an EPUB, this is set from the `rendition:layout-*` properties of a + /// spine `itemref`, allowing a publication to mix reflowable and + /// fixed-layout resources. + var epubLayout: EPUBLayout? { + get { otherProperties[layoutKey]?.decode() } + set { + if let newValue = newValue { + otherProperties[layoutKey] = .string(newValue.rawValue) + } else { + otherProperties.removeValue(forKey: layoutKey) + } + } + } } diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 62c5406b54..647a2c7f6f 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -282,6 +282,7 @@ final class OPFParser: Loggable { private func parseStringProperties(_ properties: [String]) -> [String: JSONValue] { var contains: [String] = [] var page: Properties.Page? + var layout: EPUBLayout? for property in properties { switch property { @@ -299,12 +300,17 @@ final class OPFParser: Loggable { case "remote-resources": contains.append("remote-resources") // Page - case "page-spread-left": + case "page-spread-left", "rendition:page-spread-left": page = .left - case "page-spread-right": + case "page-spread-right", "rendition:page-spread-right": page = .right case "page-spread-center", "rendition:page-spread-center": page = .center + // Layout + case "rendition:layout-reflowable": + layout = .reflowable + case "rendition:layout-pre-paginated": + layout = .fixed default: continue } @@ -317,6 +323,9 @@ final class OPFParser: Loggable { if let jsonPage = page?.jsonValue { otherProperties["page"] = jsonPage } + if let layout = layout { + otherProperties["layout"] = .string(layout.rawValue) + } return otherProperties } diff --git a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift index 4f35164458..7165afbe07 100644 --- a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +++ b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift @@ -9,8 +9,8 @@ import ReadiumShared /// Positions Service for an EPUB from its `readingOrder` and `fetcher`. /// -/// The `presentation` is used to apply different calculation strategy if the resource has a -/// reflowable or fixed layout. +/// A different calculation strategy is applied depending on whether the +/// resource has a reflowable or fixed layout. /// /// https://github.com/readium/architecture/blob/master/models/locators/best-practices/format.md#epub /// https://github.com/readium/architecture/issues/101 @@ -22,7 +22,7 @@ public actor EPUBPositionsService: PositionsService { { context in EPUBPositionsService( readingOrder: context.manifest.readingOrder, - layout: context.manifest.metadata.layout, + metadata: context.manifest.metadata, container: context.container, reflowableStrategy: reflowableStrategy ) @@ -61,18 +61,18 @@ public actor EPUBPositionsService: PositionsService { } private let readingOrder: [Link] - private let layout: Layout? + private let metadata: Metadata private let container: Container private let reflowableStrategy: ReflowableStrategy init( readingOrder: [Link], - layout: Layout?, + metadata: Metadata, container: Container, reflowableStrategy: ReflowableStrategy ) { self.readingOrder = readingOrder - self.layout = layout + self.metadata = metadata self.container = container self.reflowableStrategy = reflowableStrategy } @@ -94,10 +94,10 @@ public actor EPUBPositionsService: PositionsService { for link in readingOrder { let lastPosition: Int let resourcePositions: [Locator] - switch layout { + switch metadata.epubLayout(of: link) { case .fixed: (lastPosition, resourcePositions) = makePositions(ofFixedResource: link, from: lastPositionOfPreviousResource) - case nil, .reflowable, .scrolled: + case .reflowable: (lastPosition, resourcePositions) = await makePositions(ofReflowableResource: link, from: lastPositionOfPreviousResource) } lastPositionOfPreviousResource = lastPosition diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index 01b91297e3..2d78d080b2 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -100,7 +100,7 @@ struct UserPreferences< ) case let editor as EPUBPreferencesEditor: - switch editor.layout { + switch editor.defaultLayout { case .reflowable: reflowableUserPreferences( commit: commit, diff --git a/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift b/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift index 9509f82475..fc28a880ed 100644 --- a/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift +++ b/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift @@ -266,6 +266,84 @@ enum EPUBSpreadTests { } } } + + @Suite("Mixed layouts") struct MixedLayouts { + @Test("fixed resources in a reflowable publication are combined") + func fixedResourcesInReflowablePublicationAreCombined() { + let pub = reflowablePublication(readingOrder: [ + link("c1.html"), + link("p1.html", page: .left, layout: .fixed), + link("p2.html", page: .right, layout: .fixed), + link("c2.html"), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 3) + guard case .single = spreads[0], case let .double(pair) = spreads[1], case .single = spreads[2] else { + Issue.record("Expected .single, .double, .single") + return + } + #expect(pair.first.link.href == "p1.html") + #expect(pair.second.link.href == "p2.html") + } + + @Test("a fixed resource is not combined with a following reflowable one") + func fixedNotCombinedWithReflowable() { + let pub = reflowablePublication(readingOrder: [ + link("p1.html", page: .left, layout: .fixed), + link("c1.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true, offsetFirstPage: false) + + #expect(spreads.count == 2) + for spread in spreads { + guard case .single = spread else { + Issue.record("Expected all .single") + return + } + } + } + + @Test("a reflowable resource in a fixed publication is never combined") + func reflowableResourceInFixedPublicationIsNeverCombined() { + let pub = fxlPublication(readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .left), + link("c1.html", page: .right, layout: .reflowable), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 4) + for spread in spreads { + guard case .single = spread else { + Issue.record("Expected all .single") + return + } + } + } + + @Test("a reflowable first resource in a fixed publication is not centered by default") + func reflowableFirstResourceIsNotCentered() { + let pub = fxlPublication(readingOrder: [ + link("c1.html", layout: .reflowable), + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 2) + guard case let .single(first) = spreads[0] else { + Issue.record("Expected the reflowable first resource to be .single") + return + } + #expect(first.resource.link.properties.page == nil) + guard case .double = spreads[1] else { + Issue.record("Expected p1+p2 to be .double") + return + } + } + } } enum Properties { @@ -431,9 +509,10 @@ enum EPUBSpreadTests { // MARK: - Helpers -private func link(_ href: String, page: Properties.Page? = nil) -> Link { +private func link(_ href: String, page: Properties.Page? = nil, layout: EPUBLayout? = nil) -> Link { var properties = Properties() properties.page = page + properties.epubLayout = layout return Link(href: href, properties: properties) } diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift index 8df03e7f50..0e16bd16b3 100644 --- a/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift +++ b/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift @@ -87,4 +87,44 @@ enum MetadataEPUBTests { #expect(metadata.mediaOverlay?.activeClass == "-epub-media-overlay-active") } } + + @Suite("Metadata.epubLayout") struct EPUBLayoutTests { + @Test("defaults to reflowable", arguments: [nil, Layout.reflowable, Layout.scrolled]) + func defaultsToReflowable(layout: Layout?) { + let metadata = Metadata(title: "Test", layout: layout) + #expect(metadata.epubLayout == .reflowable) + } + + @Test("is fixed when the layout is fixed") + func fixed() { + let metadata = Metadata(title: "Test", layout: .fixed) + #expect(metadata.epubLayout == .fixed) + } + + @Test("of a link uses the link override") + func linkOverrideWins() { + var fixedLink = Link(href: "fixed.xhtml") + fixedLink.properties.epubLayout = .fixed + var reflowableLink = Link(href: "reflowable.xhtml") + reflowableLink.properties.epubLayout = .reflowable + + let fixedMetadata = Metadata(title: "Test", layout: .fixed) + let reflowableMetadata = Metadata(title: "Test", layout: .reflowable) + + #expect(fixedMetadata.epubLayout(of: reflowableLink) == .reflowable) + #expect(reflowableMetadata.epubLayout(of: fixedLink) == .fixed) + } + + @Test("of a link falls back to fixed metadata") + func linkFallsBackToFixed() { + let metadata = Metadata(title: "Test", layout: .fixed) + #expect(metadata.epubLayout(of: Link(href: "res.xhtml")) == .fixed) + } + + @Test("of a link falls back to reflowable", arguments: [nil, Layout.reflowable, Layout.scrolled]) + func linkFallsBackToReflowable(layout: Layout?) { + let metadata = Metadata(title: "Test", layout: layout) + #expect(metadata.epubLayout(of: Link(href: "res.xhtml")) == .reflowable) + } + } } diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift index 0d2f07fd55..9328d3ef4a 100644 --- a/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift +++ b/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift @@ -5,16 +5,44 @@ // @testable import ReadiumShared -import XCTest +import Testing -class PropertiesEPUBTests: XCTestCase { - func testNoContains() { - let sut = Properties() - XCTAssertEqual(sut.contains, []) +enum PropertiesEPUBTests { + struct Contains { + @Test func noContains() { + let sut = Properties() + #expect(sut.contains == []) + } + + @Test func contains() { + let sut = Properties(["contains": ["mathml", "onix"]]) + #expect(sut.contains == ["mathml", "onix"]) + } } - func testContains() { - let sut = Properties(["contains": ["mathml", "onix"]]) - XCTAssertEqual(sut.contains, ["mathml", "onix"]) + struct EPUBLayoutProperty { + @Test func noLayout() { + let sut = Properties() + #expect(sut.epubLayout == nil) + } + + @Test func layout() { + let sut = Properties(["layout": "fixed"]) + #expect(sut.epubLayout == .fixed) + } + + @Test func unknownLayoutValueIsIgnored() { + let sut = Properties(["layout": "scrolled"]) + #expect(sut.epubLayout == nil) + } + + @Test func setLayout() { + var sut = Properties() + sut.epubLayout = .reflowable + #expect(sut.otherProperties["layout"] == "reflowable") + + sut.epubLayout = nil + #expect(sut.otherProperties["layout"] == nil) + } } } diff --git a/Tests/SharedTests/Publication/ManifestTests.swift b/Tests/SharedTests/Publication/ManifestTests.swift index 8530c82e8d..14bab73c03 100644 --- a/Tests/SharedTests/Publication/ManifestTests.swift +++ b/Tests/SharedTests/Publication/ManifestTests.swift @@ -321,13 +321,13 @@ struct ManifestTests { ) } - @Test func linkWithFragment() throws { + @Test func linkWithFragment() { let sut = makeManifest(readingOrder: [ Link(href: "/href", mediaType: .html, title: "Resource"), ]) #expect( - try sut.locator(for: Link(href: "/href#page=42", mediaType: #require(MediaType("text/xml")), title: "My link")) == + sut.locator(for: Link(href: "/href#page=42", mediaType: MediaType("text/xml"), title: "My link")) == Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(fragments: ["page=42"])) ) } diff --git a/Tests/StreamerTests/Fixtures/OPF/links-properties.opf b/Tests/StreamerTests/Fixtures/OPF/links-properties.opf index 4a6682742e..2f21e1cb19 100644 --- a/Tests/StreamerTests/Fixtures/OPF/links-properties.opf +++ b/Tests/StreamerTests/Fixtures/OPF/links-properties.opf @@ -25,8 +25,8 @@ - - + + diff --git a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift index a8ac0d333e..3d3c36a4e9 100644 --- a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift @@ -6,15 +6,13 @@ import ReadiumShared @testable import ReadiumStreamer -import XCTest +import Testing -class OPFParserTests: XCTestCase { - let fixtures = Fixtures(path: "OPF") - - func testParseMinimalOPF() throws { +struct OPFParserTests { + @Test func parseMinimalOPF() throws { let sut = try parseManifest("minimal", at: "EPUB/content.opf") - XCTAssertEqual(sut.manifest, Manifest( + #expect(sut.manifest == Manifest( metadata: Metadata( conformsTo: [.epub], title: "Alice's Adventures in Wonderland", @@ -26,243 +24,257 @@ class OPFParserTests: XCTestCase { )) } - func testParseEPUB2Version() throws { - let sut = try parseManifest("version-epub2") - XCTAssertEqual(sut.version, "2.0.1") - } - - func testParseEPUB3Version() throws { - let sut = try parseManifest("version-epub3") - XCTAssertEqual(sut.version, "3.0") - } - - func testParseDefaultEPUBVersion() throws { - let sut = try parseManifest("version-default") - XCTAssertEqual(sut.version, "1.2") - } - - func testParseLinks() throws { - let sut = try parseManifest("links", at: "EPUB/content.opf").manifest - - XCTAssertEqual(sut.links, []) - XCTAssertEqual(sut.readingOrder, [ - link(href: "titlepage.xhtml", mediaType: .xhtml), - Link( - href: "EPUB/chapter01.xhtml", - mediaType: .xhtml, - alternates: [ - Link(href: "EPUB/chapter01.smil", mediaType: .smil), - ] - ), - ]) - XCTAssertEqual(sut.resources, try [ - link(href: "EPUB/fonts/MinionPro.otf", mediaType: XCTUnwrap(MediaType("application/vnd.ms-opentype"))), - link(href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), - link(href: "style.css", mediaType: .css), - link(href: "EPUB/chapter02.xhtml", mediaType: .xhtml), - Link(href: "EPUB/chapter02.smil", mediaType: .smil, duration: 1949.0), - link(href: "EPUB/images/alice01a.png", mediaType: .png, rels: [.cover]), - link(href: "EPUB/images/alice02a.gif", mediaType: .gif), - link(href: "EPUB/nomediatype.txt"), - ]) - } - - func testParseLinksFromSpine() throws { - let sut = try parseManifest("links-spine", at: "EPUB/content.opf").manifest - - XCTAssertEqual(sut.readingOrder, [ - link(href: "EPUB/titlepage.xhtml"), - ]) - } - - func testParseLinkProperties() throws { - let sut = try parseManifest("links-properties", at: "EPUB/content.opf").manifest - - XCTAssertEqual(sut.readingOrder.count, 8) - XCTAssertEqual(sut.readingOrder[0], link(href: "EPUB/chapter01.xhtml", rels: [.contents], properties: Properties([ - "contains": ["mathml"], - "page": "right", - ]))) - XCTAssertEqual(sut.readingOrder[1], link(href: "EPUB/chapter02.xhtml", properties: Properties([ - "contains": ["remote-resources"], - "page": "left", - ]))) - XCTAssertEqual(sut.readingOrder[2], link(href: "EPUB/chapter03.xhtml", properties: Properties([ - "contains": ["js", "svg"], - "page": "center", - ]))) - XCTAssertEqual(sut.readingOrder[3], link(href: "EPUB/chapter04.xhtml", properties: Properties([ - "contains": ["onix", "xmp"], - ]))) - XCTAssertEqual(sut.readingOrder[4], link(href: "EPUB/chapter05.xhtml")) - XCTAssertEqual(sut.readingOrder[5], link(href: "EPUB/chapter06.xhtml")) - XCTAssertEqual(sut.readingOrder[6], link(href: "EPUB/chapter07.xhtml")) - XCTAssertEqual(sut.readingOrder[7], link(href: "EPUB/chapter08.xhtml")) - } - - func testParseEPUB2Cover() throws { - let sut = try parseManifest("cover-epub2", at: "EPUB/content.opf").manifest - - XCTAssertEqual(sut.resources, [ - link(href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), - ]) + struct Version { + @Test func parseEPUB2Version() throws { + let sut = try parseManifest("version-epub2") + #expect(sut.version == "2.0.1") + } + + @Test func parseEPUB3Version() throws { + let sut = try parseManifest("version-epub3") + #expect(sut.version == "3.0") + } + + @Test func parseDefaultEPUBVersion() throws { + let sut = try parseManifest("version-default") + #expect(sut.version == "1.2") + } } - func testParseEPUB3Cover() throws { - let sut = try parseManifest("cover-epub3", at: "EPUB/content.opf").manifest - - XCTAssertEqual(sut.resources, [ - link(href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), - ]) + struct Links { + @Test func parseLinks() throws { + let sut = try parseManifest("links", at: "EPUB/content.opf").manifest + + #expect(sut.links == []) + #expect(sut.readingOrder == [ + link(href: "titlepage.xhtml", mediaType: .xhtml), + Link( + href: "EPUB/chapter01.xhtml", + mediaType: .xhtml, + alternates: [ + Link(href: "EPUB/chapter01.smil", mediaType: .smil), + ] + ), + ]) + #expect(sut.resources == [ + link(href: "EPUB/fonts/MinionPro.otf", mediaType: MediaType("application/vnd.ms-opentype")!), + link(href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), + link(href: "style.css", mediaType: .css), + link(href: "EPUB/chapter02.xhtml", mediaType: .xhtml), + Link(href: "EPUB/chapter02.smil", mediaType: .smil, duration: 1949.0), + link(href: "EPUB/images/alice01a.png", mediaType: .png, rels: [.cover]), + link(href: "EPUB/images/alice02a.gif", mediaType: .gif), + link(href: "EPUB/nomediatype.txt"), + ]) + } + + @Test func parseLinksFromSpine() throws { + let sut = try parseManifest("links-spine", at: "EPUB/content.opf").manifest + + #expect(sut.readingOrder == [ + link(href: "EPUB/titlepage.xhtml"), + ]) + } + + @Test func parseLinkProperties() throws { + let sut = try parseManifest("links-properties", at: "EPUB/content.opf").manifest + + #expect(sut.readingOrder.count == 8) + #expect(sut.readingOrder[0] == link(href: "EPUB/chapter01.xhtml", rels: [.contents], properties: Properties([ + "contains": ["mathml"], + "layout": "fixed", + "page": "right", + ]))) + #expect(sut.readingOrder[1] == link(href: "EPUB/chapter02.xhtml", properties: Properties([ + "contains": ["remote-resources"], + "layout": "reflowable", + "page": "left", + ]))) + #expect(sut.readingOrder[2] == link(href: "EPUB/chapter03.xhtml", properties: Properties([ + "contains": ["js", "svg"], + "page": "center", + ]))) + #expect(sut.readingOrder[3] == link(href: "EPUB/chapter04.xhtml", properties: Properties([ + "contains": ["onix", "xmp"], + ]))) + #expect(sut.readingOrder[4] == link(href: "EPUB/chapter05.xhtml", properties: Properties([ + "page": "left", + ]))) + #expect(sut.readingOrder[5] == link(href: "EPUB/chapter06.xhtml", properties: Properties([ + "page": "right", + ]))) + #expect(sut.readingOrder[6] == link(href: "EPUB/chapter07.xhtml")) + #expect(sut.readingOrder[7] == link(href: "EPUB/chapter08.xhtml")) + } } - // MARK: - Fallback Handling - - /// When an image is in the spine with an HTML fallback, the image should be - /// in readingOrder and HTML should be added as an alternate. - func testParseImageInSpineWithHTMLFallback() throws { - let sut = try parseManifest("fallback-image-in-spine", at: "EPUB/content.opf").manifest - - XCTAssertEqual(sut.readingOrder.count, 2) + struct Cover { + @Test func parseEPUB2Cover() throws { + let sut = try parseManifest("cover-epub2", at: "EPUB/content.opf").manifest - // First image in spine - XCTAssertEqual(sut.readingOrder[0].href, "EPUB/page1.jpg") - XCTAssertEqual(sut.readingOrder[0].mediaType, .jpeg) - XCTAssertEqual(sut.readingOrder[0].alternates, [ - Link(href: "EPUB/page1.xhtml", mediaType: .xhtml), - ]) + #expect(sut.resources == [ + link(href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), + ]) + } - // Second image in spine - XCTAssertEqual(sut.readingOrder[1].href, "EPUB/page2.png") - XCTAssertEqual(sut.readingOrder[1].mediaType, .png) - XCTAssertEqual(sut.readingOrder[1].alternates, [ - Link(href: "EPUB/page2.xhtml", mediaType: .xhtml), - ]) + @Test func parseEPUB3Cover() throws { + let sut = try parseManifest("cover-epub3", at: "EPUB/content.opf").manifest - // HTML fallbacks should not be in resources - XCTAssertTrue(sut.resources.isEmpty) + #expect(sut.resources == [ + link(href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), + ]) + } } - /// 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. - func testParseHTMLInSpineWithImageFallback() throws { - let sut = try parseManifest("fallback-html-in-spine", at: "EPUB/content.opf").manifest - - XCTAssertEqual(sut.readingOrder.count, 2) - - // First item: image swapped into readingOrder, HTML as alternate - XCTAssertEqual(sut.readingOrder[0].href, "EPUB/page1.jpg") - XCTAssertEqual(sut.readingOrder[0].mediaType, .jpeg) - XCTAssertEqual(sut.readingOrder[0].alternates, [ - Link(href: "EPUB/page1.xhtml", mediaType: .xhtml), - ]) - - // Second item: image swapped into readingOrder, HTML as alternate - XCTAssertEqual(sut.readingOrder[1].href, "EPUB/page2.png") - XCTAssertEqual(sut.readingOrder[1].mediaType, .png) - XCTAssertEqual(sut.readingOrder[1].alternates, [ - Link(href: "EPUB/page2.xhtml", mediaType: .xhtml), - ]) - - // Fallback images should not be in resources - XCTAssertTrue(sut.resources.isEmpty) + struct FallbackHandling { + /// When an image is in the spine with an HTML fallback, the image should be + /// in readingOrder and HTML should be added as an alternate. + @Test func parseImageInSpineWithHTMLFallback() throws { + let sut = try parseManifest("fallback-image-in-spine", at: "EPUB/content.opf").manifest + + #expect(sut.readingOrder.count == 2) + + // First image in spine + #expect(sut.readingOrder[0].href == "EPUB/page1.jpg") + #expect(sut.readingOrder[0].mediaType == .jpeg) + #expect(sut.readingOrder[0].alternates == [ + Link(href: "EPUB/page1.xhtml", mediaType: .xhtml), + ]) + + // Second image in spine + #expect(sut.readingOrder[1].href == "EPUB/page2.png") + #expect(sut.readingOrder[1].mediaType == .png) + #expect(sut.readingOrder[1].alternates == [ + Link(href: "EPUB/page2.xhtml", mediaType: .xhtml), + ]) + + // HTML fallbacks should not be in resources + #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. + @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) + #expect(sut.readingOrder[0].alternates == [ + Link(href: "EPUB/page1.xhtml", mediaType: .xhtml), + ]) + + // Second item: image swapped into readingOrder, HTML as alternate + #expect(sut.readingOrder[1].href == "EPUB/page2.png") + #expect(sut.readingOrder[1].mediaType == .png) + #expect(sut.readingOrder[1].alternates == [ + Link(href: "EPUB/page2.xhtml", mediaType: .xhtml), + ]) + + // Fallback images should not be in resources + #expect(sut.resources.isEmpty) + } + + /// General fallback handling: any fallback should be translated to an + /// alternate. + @Test func parseGeneralFallbackAsAlternate() throws { + let sut = try parseManifest("fallback-general", at: "EPUB/content.opf").manifest + + #expect(sut.readingOrder.count == 2) + + // First item: XHTML with XHTML fallback + #expect(sut.readingOrder[0].href == "EPUB/chapter1.xhtml") + #expect(sut.readingOrder[0].mediaType == .xhtml) + #expect(sut.readingOrder[0].alternates == [ + Link(href: "EPUB/chapter1-alt.xhtml", mediaType: .xhtml), + ]) + + // Second item: XHTML with PDF fallback + #expect(sut.readingOrder[1].href == "EPUB/chapter2.xhtml") + #expect(sut.readingOrder[1].mediaType == .xhtml) + #expect(sut.readingOrder[1].alternates == [ + Link(href: "EPUB/chapter2.pdf", mediaType: .pdf), + ]) + + // Fallback resources should not be in resources + #expect(sut.resources.isEmpty) + } } - /// General fallback handling: any fallback should be translated to an - /// alternate. - func testParseGeneralFallbackAsAlternate() throws { - let sut = try parseManifest("fallback-general", at: "EPUB/content.opf").manifest - - XCTAssertEqual(sut.readingOrder.count, 2) - - // First item: XHTML with XHTML fallback - XCTAssertEqual(sut.readingOrder[0].href, "EPUB/chapter1.xhtml") - XCTAssertEqual(sut.readingOrder[0].mediaType, .xhtml) - XCTAssertEqual(sut.readingOrder[0].alternates, [ - Link(href: "EPUB/chapter1-alt.xhtml", mediaType: .xhtml), - ]) - - // Second item: XHTML with PDF fallback - XCTAssertEqual(sut.readingOrder[1].href, "EPUB/chapter2.xhtml") - XCTAssertEqual(sut.readingOrder[1].mediaType, .xhtml) - XCTAssertEqual(sut.readingOrder[1].alternates, [ - Link(href: "EPUB/chapter2.pdf", mediaType: .pdf), - ]) - - // Fallback resources should not be in resources - XCTAssertTrue(sut.resources.isEmpty) + struct DivinaInference { + /// When all spine items are bitmaps, the metadata should have: + /// - `layout = .fixed` to use the FXL navigator + /// - `.divina` added to `conformsTo` + @Test func parseAllImagesInSpineSetsFixedLayoutAndDivinaProfile() throws { + let sut = try parseManifest("all-images-in-spine", at: "EPUB/content.opf").manifest + + // Should have fixed layout + #expect(sut.metadata.layout == .fixed) + + // Should conform to both EPUB and Divina + #expect(sut.metadata.conformsTo.contains(.epub)) + #expect(sut.metadata.conformsTo.contains(.divina)) + + // Reading order should contain all images + #expect(sut.readingOrder.count == 3) + #expect(sut.readingOrder[0].mediaType == .jpeg) + #expect(sut.readingOrder[1].mediaType == .png) + #expect(sut.readingOrder[2].mediaType == .gif) + } + + /// When not all spine items are bitmaps, the metadata should NOT have + /// `.divina` profile and layout should remain reflowable. + @Test func parseMixedSpineDoesNotSetDivinaProfile() throws { + let sut = try parseManifest("fallback-image-html-mixed", at: "EPUB/content.opf").manifest + + // Should have reflowable layout (default) + #expect(sut.metadata.layout == .reflowable) + + // Should only conform to EPUB, not Divina + #expect(sut.metadata.conformsTo.contains(.epub)) + #expect(!sut.metadata.conformsTo.contains(.divina)) + } } - // MARK: - Divina Inference - - /// When all spine items are bitmaps, the metadata should have: - /// - `layout = .fixed` to use the FXL navigator - /// - `.divina` added to `conformsTo` - func testParseAllImagesInSpineSetsFixedLayoutAndDivinaProfile() throws { - let sut = try parseManifest("all-images-in-spine", at: "EPUB/content.opf").manifest - - // Should have fixed layout - XCTAssertEqual(sut.metadata.layout, .fixed) - - // Should conform to both EPUB and Divina - XCTAssertTrue(sut.metadata.conformsTo.contains(.epub)) - XCTAssertTrue(sut.metadata.conformsTo.contains(.divina)) - - // Reading order should contain all images - XCTAssertEqual(sut.readingOrder.count, 3) - XCTAssertEqual(sut.readingOrder[0].mediaType, .jpeg) - XCTAssertEqual(sut.readingOrder[1].mediaType, .png) - XCTAssertEqual(sut.readingOrder[2].mediaType, .gif) + struct MediaOverlays { + @Test func parseMediaOverlaysSmilAsAlternate() throws { + let sut = try parseManifest("media-overlays", at: "EPUB/content.opf").manifest + + // SMIL should be an alternate of each reading order item, not in resources + #expect(sut.readingOrder[0].href == "EPUB/chapter01.xhtml") + #expect(sut.readingOrder[0].alternates == [ + Link(href: "EPUB/chapter01.smil", mediaType: .smil, duration: 1425.0), + ]) + #expect(sut.readingOrder[1].href == "EPUB/chapter02.xhtml") + #expect(sut.readingOrder[1].alternates == [ + Link(href: "EPUB/chapter02.smil", mediaType: .smil, duration: 524.0), + ]) + #expect(sut.resources.isEmpty) + } } +} - /// When not all spine items are bitmaps, the metadata should NOT have - /// `.divina` profile and layout should remain reflowable. - func testParseMixedSpineDoesNotSetDivinaProfile() throws { - let sut = try parseManifest("fallback-image-html-mixed", at: "EPUB/content.opf").manifest - - // Should have reflowable layout (default) - XCTAssertEqual(sut.metadata.layout, .reflowable) +// MARK: - Helpers - // Should only conform to EPUB, not Divina - XCTAssertTrue(sut.metadata.conformsTo.contains(.epub)) - XCTAssertFalse(sut.metadata.conformsTo.contains(.divina)) - } +private let fixtures = Fixtures(path: "OPF") - // MARK: - Media Overlays - - func testParseMediaOverlaysSmilAsAlternate() throws { - let sut = try parseManifest("media-overlays", at: "EPUB/content.opf").manifest - - // SMIL should be an alternate of each reading order item, not in resources - XCTAssertEqual(sut.readingOrder[0].href, "EPUB/chapter01.xhtml") - XCTAssertEqual(sut.readingOrder[0].alternates, [ - Link(href: "EPUB/chapter01.smil", mediaType: .smil, duration: 1425.0), - ]) - XCTAssertEqual(sut.readingOrder[1].href, "EPUB/chapter02.xhtml") - XCTAssertEqual(sut.readingOrder[1].alternates, [ - Link(href: "EPUB/chapter02.smil", mediaType: .smil, duration: 524.0), - ]) - XCTAssertTrue(sut.resources.isEmpty) - } +private func parseManifest(_ name: String, at path: String = "EPUB/content.opf", displayOptions: String? = nil) throws -> (manifest: Manifest, version: String) { + let parts = try OPFParser( + baseURL: RelativeURL(path: path)!, + data: fixtures.data(at: "\(name).opf"), + displayOptionsData: displayOptions.map { fixtures.data(at: "\($0).xml") }, + encryptions: [:] + ).parsePublication() - // MARK: - Helpers - - func parseManifest(_ name: String, at path: String = "EPUB/content.opf", displayOptions: String? = nil) throws -> (manifest: Manifest, version: String) { - let parts = try OPFParser( - baseURL: XCTUnwrap(RelativeURL(path: path)), - data: fixtures.data(at: "\(name).opf"), - displayOptionsData: displayOptions.map { fixtures.data(at: "\($0).xml") }, - encryptions: [:] - ).parsePublication() - - return (Manifest( - metadata: parts.metadata, - readingOrder: parts.readingOrder, - resources: parts.resources - ), parts.version) - } + return (Manifest( + metadata: parts.metadata, + readingOrder: parts.readingOrder, + resources: parts.resources + ), parts.version) +} - func link(href: String, mediaType: MediaType? = nil, templated: Bool = false, title: String? = nil, rels: [LinkRelation] = [], properties: Properties = .init(), children: [Link] = []) -> Link { - Link(href: href, mediaType: mediaType, templated: templated, title: title, rels: rels, properties: properties, children: children) - } +private func link(href: String, mediaType: MediaType? = nil, templated: Bool = false, title: String? = nil, rels: [LinkRelation] = [], properties: Properties = .init(), children: [Link] = []) -> Link { + Link(href: href, mediaType: mediaType, templated: templated, title: title, rels: rels, properties: properties, children: children) } diff --git a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift index 9c2a7ef1ba..699b0830d7 100644 --- a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift @@ -4,293 +4,263 @@ // available in the top-level LICENSE file of the project. // +import Foundation import ReadiumShared @testable import ReadiumStreamer -import XCTest +import Testing -class EPUBPositionsServiceTests: XCTestCase { - func testFromEmptyReadingOrder() async { - let service = makeService(readingOrder: []) - let result = await service.positionsByReadingOrder() - XCTAssertEqual(result, .success([])) - } - - func testFromReadingOrderWithOneResource() async { - let service = makeService(readingOrder: [(1, Link(href: "res", mediaType: .xml), nil)]) - - let result = await service.positionsByReadingOrder() - XCTAssertEqual(result, .success([[ - Locator( - href: "res", - mediaType: .xml, - locations: Locator.Locations( - progression: 0, - totalProgression: 0, - position: 1 - ) - ), - ]])) - } - - func testFromReadingOrderWithFewResources() async { - let service = makeService(readingOrder: [ - (1, Link(href: "res"), nil), - (2, Link(href: "chap1", mediaType: .xml), nil), - (2, Link(href: "chap2", mediaType: .html, title: "Chapter 2"), nil), - ]) - - let result = await service.positionsByReadingOrder() - XCTAssertEqual(result, .success([ - [Locator( - href: "res", - mediaType: .html, - locations: Locator.Locations( - progression: 0.0, - totalProgression: 0.0, - position: 1 - ) - )], - [Locator( - href: "chap1", - mediaType: .xml, - locations: Locator.Locations( - progression: 0.0, - totalProgression: 1.0 / 3.0, - position: 2 - ) - )], - [Locator( - href: "chap2", - mediaType: .html, - title: "Chapter 2", - locations: Locator.Locations( - progression: 0.0, - totalProgression: 2.0 / 3.0, - position: 3 - ) - )], - ])) - } +enum EPUBPositionsServiceTests { + struct ReadingOrder { + @Test func fromEmptyReadingOrder() async { + let service = makeService(readingOrder: []) + let result = await service.positionsByReadingOrder() + #expect(result == .success([])) + } - func testTypeFallsBackOnHTML() async { - let service = makeService(readingOrder: [ - (1, Link(href: "chap1", properties: makeProperties(layout: .reflowable)), nil), - (1, Link(href: "chap2", properties: makeProperties(layout: .fixed)), nil), - ]) + @Test func fromReadingOrderWithOneResource() async { + let service = makeService(readingOrder: [(1, Link(href: "res", mediaType: .xml), nil)]) - let result = await service.positionsByReadingOrder() - XCTAssertEqual(result, .success([ - [Locator( - href: "chap1", - mediaType: .html, - locations: Locator.Locations( - progression: 0.0, - totalProgression: 0.0, - position: 1 - ) - )], - [Locator( - href: "chap2", - mediaType: .html, - locations: Locator.Locations( - progression: 0.0, - totalProgression: 0.5, - position: 2 - ) - )], - ])) - } + let result = await service.positionsByReadingOrder() + #expect(result == .success([[ + Locator( + href: "res", + mediaType: .xml, + locations: Locator.Locations( + progression: 0, + totalProgression: 0, + position: 1 + ) + ), + ]])) + } - func testOnePositionPerFixedLayoutResource() async { - let service = makeService( - layout: .fixed, - readingOrder: [ - (10000, Link(href: "res"), nil), - (20000, Link(href: "chap1", mediaType: .xml), nil), - (40000, Link(href: "chap2", mediaType: .html, title: "Chapter 2"), nil), - ] - ) - - let result = await service.positionsByReadingOrder() - XCTAssertEqual(result, .success([ - [Locator( - href: "res", - mediaType: .html, - locations: Locator.Locations( - progression: 0.0, - totalProgression: 0.0, - position: 1 - ) - )], - [Locator( - href: "chap1", - mediaType: .xml, - locations: Locator.Locations( - progression: 0.0, - totalProgression: 1.0 / 3.0, - position: 2 - ) - )], - [Locator( - href: "chap2", - mediaType: .html, - title: "Chapter 2", - locations: Locator.Locations( - progression: 0.0, - totalProgression: 2.0 / 3.0, - position: 3 - ) - )], - ])) - } + @Test func fromReadingOrderWithFewResources() async { + let service = makeService(readingOrder: [ + (1, Link(href: "res"), nil), + (2, Link(href: "chap1", mediaType: .xml), nil), + (2, Link(href: "chap2", mediaType: .html, title: "Chapter 2"), nil), + ]) - func testSplitReflowableResourcesByProvidedLength() async { - let service = makeService( - layout: .reflowable, - readingOrder: [ - (0, Link(href: "chap1"), nil), - (49, Link(href: "chap2", mediaType: .xml), nil), - (50, Link(href: "chap3", mediaType: .html, title: "Chapter 3"), nil), - (51, Link(href: "chap4"), nil), - (120, Link(href: "chap5"), nil), - ], - reflowableStrategy: .archiveEntryLength(pageLength: 50) - ) - - let result = await service.positionsByReadingOrder() - XCTAssertEqual(result, .success([ - [ - Locator( - href: "chap1", + let result = await service.positionsByReadingOrder() + #expect(result == .success([ + [Locator( + href: "res", mediaType: .html, locations: Locator.Locations( progression: 0.0, totalProgression: 0.0, position: 1 ) - ), - ], - [ - Locator( - href: "chap2", + )], + [Locator( + href: "chap1", mediaType: .xml, locations: Locator.Locations( progression: 0.0, - totalProgression: 1.0 / 8.0, + totalProgression: 1.0 / 3.0, position: 2 ) - ), - ], - [ - Locator( - href: "chap3", + )], + [Locator( + href: "chap2", mediaType: .html, - title: "Chapter 3", + title: "Chapter 2", locations: Locator.Locations( progression: 0.0, - totalProgression: 2.0 / 8.0, + totalProgression: 2.0 / 3.0, position: 3 ) - ), - ], - [ - Locator( - href: "chap4", + )], + ])) + } + + @Test func typeFallsBackOnHTML() async { + let service = makeService(readingOrder: [ + (1, Link(href: "chap1", properties: makeProperties(layout: .reflowable)), nil), + (1, Link(href: "chap2", properties: makeProperties(layout: .fixed)), nil), + ]) + + let result = await service.positionsByReadingOrder() + #expect(result == .success([ + [Locator( + href: "chap1", mediaType: .html, locations: Locator.Locations( progression: 0.0, - totalProgression: 3.0 / 8.0, - position: 4 + totalProgression: 0.0, + position: 1 ) - ), - Locator( - href: "chap4", + )], + [Locator( + href: "chap2", mediaType: .html, locations: Locator.Locations( - progression: 0.5, - totalProgression: 4.0 / 8.0, - position: 5 + progression: 0.0, + totalProgression: 0.5, + position: 2 ) - ), - ], - [ - Locator( - href: "chap5", + )], + ])) + } + } + + struct Layouts { + @Test func onePositionPerFixedLayoutResource() async { + let service = makeService( + layout: .fixed, + readingOrder: [ + (10000, Link(href: "res"), nil), + (20000, Link(href: "chap1", mediaType: .xml), nil), + (40000, Link(href: "chap2", mediaType: .html, title: "Chapter 2"), nil), + ] + ) + + let result = await service.positionsByReadingOrder() + #expect(result == .success([ + [Locator( + href: "res", mediaType: .html, locations: Locator.Locations( progression: 0.0, - totalProgression: 5.0 / 8.0, - position: 6 + totalProgression: 0.0, + position: 1 ) - ), - Locator( - href: "chap5", - mediaType: .html, + )], + [Locator( + href: "chap1", + mediaType: .xml, locations: Locator.Locations( - progression: 1.0 / 3.0, - totalProgression: 6.0 / 8.0, - position: 7 + progression: 0.0, + totalProgression: 1.0 / 3.0, + position: 2 ) - ), - Locator( - href: "chap5", + )], + [Locator( + href: "chap2", mediaType: .html, + title: "Chapter 2", locations: Locator.Locations( - progression: 2.0 / 3.0, - totalProgression: 7.0 / 8.0, - position: 8 + progression: 0.0, + totalProgression: 2.0 / 3.0, + position: 3 ) - ), - ], - ])) - } + )], + ])) + } - func testLayoutFallsBackToReflowable() async { - // We check this by verifying that the resource will be split every 50 bytes - let service = makeService( - layout: nil, - readingOrder: [ - (60, Link(href: "chap1"), nil), - ], - reflowableStrategy: .archiveEntryLength(pageLength: 50) - ) - - let result = await service.positionsByReadingOrder() - XCTAssertEqual(result, .success([[ - Locator( - href: "chap1", - mediaType: .html, - locations: Locator.Locations( - progression: 0.0, - totalProgression: 0.0, - position: 1 - ) - ), - Locator( - href: "chap1", - mediaType: .html, - locations: Locator.Locations( - progression: 0.5, - totalProgression: 0.5, - position: 2 - ) - ), - ]])) - } + @Test func splitReflowableResourcesByProvidedLength() async { + let service = makeService( + layout: .reflowable, + readingOrder: [ + (0, Link(href: "chap1"), nil), + (49, Link(href: "chap2", mediaType: .xml), nil), + (50, Link(href: "chap3", mediaType: .html, title: "Chapter 3"), nil), + (51, Link(href: "chap4"), nil), + (120, Link(href: "chap5"), nil), + ], + reflowableStrategy: .archiveEntryLength(pageLength: 50) + ) - func testArchiveEntryLengthStrategy() async { - let service = makeService( - layout: .reflowable, - readingOrder: [ - (60, Link(href: "chap1"), ArchiveProperties(entryLength: 20, isEntryCompressed: false)), - (60, Link(href: "chap2"), nil), - ], - reflowableStrategy: .archiveEntryLength(pageLength: 50) - ) - - let result = await service.positionsByReadingOrder() - XCTAssertEqual(result, .success([ - [ + let result = await service.positionsByReadingOrder() + #expect(result == .success([ + [ + Locator( + href: "chap1", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 0.0, + position: 1 + ) + ), + ], + [ + Locator( + href: "chap2", + mediaType: .xml, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 1.0 / 8.0, + position: 2 + ) + ), + ], + [ + Locator( + href: "chap3", + mediaType: .html, + title: "Chapter 3", + locations: Locator.Locations( + progression: 0.0, + totalProgression: 2.0 / 8.0, + position: 3 + ) + ), + ], + [ + Locator( + href: "chap4", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 3.0 / 8.0, + position: 4 + ) + ), + Locator( + href: "chap4", + mediaType: .html, + locations: Locator.Locations( + progression: 0.5, + totalProgression: 4.0 / 8.0, + position: 5 + ) + ), + ], + [ + Locator( + href: "chap5", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 5.0 / 8.0, + position: 6 + ) + ), + Locator( + href: "chap5", + mediaType: .html, + locations: Locator.Locations( + progression: 1.0 / 3.0, + totalProgression: 6.0 / 8.0, + position: 7 + ) + ), + Locator( + href: "chap5", + mediaType: .html, + locations: Locator.Locations( + progression: 2.0 / 3.0, + totalProgression: 7.0 / 8.0, + position: 8 + ) + ), + ], + ])) + } + + @Test func layoutFallsBackToReflowable() async { + // We check this by verifying that the resource will be split every 50 bytes + let service = makeService( + layout: nil, + readingOrder: [ + (60, Link(href: "chap1"), nil), + ], + reflowableStrategy: .archiveEntryLength(pageLength: 50) + ) + + let result = await service.positionsByReadingOrder() + #expect(result == .success([[ Locator( href: "chap1", mediaType: .html, @@ -300,35 +270,205 @@ class EPUBPositionsServiceTests: XCTestCase { position: 1 ) ), - ], - [ Locator( - href: "chap2", - mediaType: .html, - locations: Locator.Locations( - progression: 0.0, - totalProgression: 1.0 / 3.0, - position: 2 - ) - ), - Locator( - href: "chap2", + href: "chap1", mediaType: .html, locations: Locator.Locations( progression: 0.5, - totalProgression: 2.0 / 3.0, - position: 3 + totalProgression: 0.5, + position: 2 ) ), - ], - ])) + ]])) + } + } + + struct MixedLayouts { + @Test func reflowableAndFixedOverridesInFixedPublication() async { + let service = makeService( + layout: .fixed, + readingOrder: [ + (20000, Link(href: "chap1"), nil), + (60, Link(href: "chap2", properties: makeProperties(layout: .reflowable)), nil), + (20000, Link(href: "chap3", properties: makeProperties(layout: .fixed)), nil), + ], + reflowableStrategy: .archiveEntryLength(pageLength: 50) + ) + + let result = await service.positionsByReadingOrder() + #expect(result == .success([ + [ + Locator( + href: "chap1", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 0.0, + position: 1 + ) + ), + ], + [ + Locator( + href: "chap2", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 1.0 / 4.0, + position: 2 + ) + ), + Locator( + href: "chap2", + mediaType: .html, + locations: Locator.Locations( + progression: 0.5, + totalProgression: 2.0 / 4.0, + position: 3 + ) + ), + ], + [ + Locator( + href: "chap3", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 3.0 / 4.0, + position: 4 + ) + ), + ], + ])) + } + + @Test func fixedAndReflowableOverridesInReflowablePublication() async { + let service = makeService( + layout: .reflowable, + readingOrder: [ + (60, Link(href: "chap1"), nil), + (20000, Link(href: "chap2", properties: makeProperties(layout: .fixed)), nil), + (60, Link(href: "chap3", properties: makeProperties(layout: .reflowable)), nil), + ], + reflowableStrategy: .archiveEntryLength(pageLength: 50) + ) + + let result = await service.positionsByReadingOrder() + #expect(result == .success([ + [ + Locator( + href: "chap1", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 0.0, + position: 1 + ) + ), + Locator( + href: "chap1", + mediaType: .html, + locations: Locator.Locations( + progression: 0.5, + totalProgression: 1.0 / 5.0, + position: 2 + ) + ), + ], + [ + Locator( + href: "chap2", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 2.0 / 5.0, + position: 3 + ) + ), + ], + [ + Locator( + href: "chap3", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 3.0 / 5.0, + position: 4 + ) + ), + Locator( + href: "chap3", + mediaType: .html, + locations: Locator.Locations( + progression: 0.5, + totalProgression: 4.0 / 5.0, + position: 5 + ) + ), + ], + ])) + } + } + + struct ReflowableStrategy { + @Test func archiveEntryLengthStrategy() async { + let service = makeService( + layout: .reflowable, + readingOrder: [ + (60, Link(href: "chap1"), ArchiveProperties(entryLength: 20, isEntryCompressed: false)), + (60, Link(href: "chap2"), nil), + ], + reflowableStrategy: .archiveEntryLength(pageLength: 50) + ) + + let result = await service.positionsByReadingOrder() + #expect(result == .success([ + [ + Locator( + href: "chap1", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 0.0, + position: 1 + ) + ), + ], + [ + Locator( + href: "chap2", + mediaType: .html, + locations: Locator.Locations( + progression: 0.0, + totalProgression: 1.0 / 3.0, + position: 2 + ) + ), + Locator( + href: "chap2", + mediaType: .html, + locations: Locator.Locations( + progression: 0.5, + totalProgression: 2.0 / 3.0, + position: 3 + ) + ), + ], + ])) + } } } -func makeService(layout: Layout? = nil, readingOrder: [(UInt64, Link, ArchiveProperties?)], reflowableStrategy: EPUBPositionsService.ReflowableStrategy = .archiveEntryLength(pageLength: 50)) -> EPUBPositionsService { +// MARK: - Helpers + +private func makeService( + layout: ReadiumShared.Layout? = nil, + readingOrder: [(UInt64, Link, ArchiveProperties?)], + reflowableStrategy: EPUBPositionsService.ReflowableStrategy = .archiveEntryLength(pageLength: 50) +) -> EPUBPositionsService { EPUBPositionsService( readingOrder: readingOrder.map { _, l, _ in l }, - layout: layout, + metadata: Metadata(title: "Test", layout: layout), container: MockContainer(readingOrder: readingOrder), reflowableStrategy: reflowableStrategy ) diff --git a/docs/Guides/Navigator/Preferences.md b/docs/Guides/Navigator/Preferences.md index f971d0cae3..892294d284 100644 --- a/docs/Guides/Navigator/Preferences.md +++ b/docs/Guides/Navigator/Preferences.md @@ -123,7 +123,7 @@ struct UserPreferences< ) case let editor as EPUBPreferencesEditor: - switch editor.layout { + switch editor.defaultLayout { case .reflowable: reflowableUserPreferences( commit: commit, @@ -375,6 +375,8 @@ let combinedPrefs = publicationPrefs.merging(sharedPrefs) EPUB comes in two very different flavors: **reflowable** which allows a lot of customization, and **fixed-layout** which is similar to a PDF or a comic book. Depending on the EPUB being rendered, the Navigator will ignore some of the preferences. +An EPUB can also mix both kinds of resources in a single publication. In this case, a preference is effective as soon as at least one resource of the matching kind is in the reading order, and it applies only to those resources. For example, `fontSize` is effective in a fixed-layout publication containing a reflowable chapter, but it changes only that chapter. + | Setting | Reflowable | Fixed Layout | |----------------------|--------------------|--------------------| | `backgroundColor` | :white_check_mark: | :white_check_mark: |