From 661831a0dd269a12246109878538d75f1be5761d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 19 Jan 2026 17:09:45 +0100 Subject: [PATCH 1/3] Add the `offsetFirstPage` EPUB preference --- .../EPUB/EPUBNavigatorViewController.swift | 3 +- .../EPUB/EPUBNavigatorViewModel.swift | 2 + Sources/Navigator/EPUB/EPUBSpread.swift | 30 +++++++++--- .../EPUB/Preferences/EPUBPreferences.swift | 11 +++++ .../Preferences/EPUBPreferencesEditor.swift | 19 ++++++++ .../EPUB/Preferences/EPUBSettings.swift | 8 ++++ .../PDF/Preferences/PDFPreferences.swift | 5 +- .../Preferences/PDFPreferencesEditor.swift | 3 +- .../Common/Preferences/UserPreferences.swift | 46 ++++++++++++++++--- 9 files changed, 110 insertions(+), 17 deletions(-) diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 382a08083..323e444d0 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -604,7 +604,8 @@ open class EPUBNavigatorViewController: InputObservableViewController, for: publication, readingOrder: readingOrder, readingProgression: viewModel.readingProgression, - spread: viewModel.spreadEnabled + spread: viewModel.spreadEnabled, + offsetFirstPage: viewModel.offsetFirstPage ) let initialIndex: ReadingOrder.Index = { diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index c0db2b036..57e4c7e9d 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -225,6 +225,7 @@ final class EPUBNavigatorViewModel: Loggable { || oldSettings.scroll != newSettings.scroll || oldSettings.spread != newSettings.spread || oldSettings.fit != newSettings.fit + || oldSettings.offsetFirstPage != newSettings.offsetFirstPage // We don't commit the CSS changes if we invalidate the pagination, as // the resources will be reloaded anyway. @@ -248,6 +249,7 @@ final class EPUBNavigatorViewModel: Loggable { var scroll: Bool { settings.scroll } var verticalText: Bool { settings.verticalText } var spread: Spread { settings.spread } + var offsetFirstPage: Bool? { settings.offsetFirstPage } // MARK: Spread diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 938eddda1..68d2571ca 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -115,14 +115,16 @@ struct EPUBSpread: Loggable { /// - publication: The Publication to build the spreads for. /// - readingProgression: Reading progression direction used to layout the pages. /// - spread: Indicates whether two pages are displayed side-by-side. + /// - offsetFirstPage: Indicates if the first page should be displayed in its own spread. static func makeSpreads( for publication: Publication, readingOrder: [Link], readingProgression: ReadingProgression, - spread: Bool + spread: Bool, + offsetFirstPage: Bool? = nil ) -> [EPUBSpread] { spread - ? makeTwoPagesSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression) + ? makeTwoPagesSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression, offsetFirstPage: offsetFirstPage) : makeOnePageSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression) } @@ -145,7 +147,8 @@ struct EPUBSpread: Loggable { private static func makeTwoPagesSpreads( for publication: Publication, readingOrder: [Link], - readingProgression: ReadingProgression + readingProgression: ReadingProgression, + offsetFirstPage: Bool? ) -> [EPUBSpread] { var spreads: [EPUBSpread] = [] @@ -166,7 +169,7 @@ struct EPUBSpread: Loggable { if let second = readingOrder.getOrNil(nextIndex), publication.metadata.layout == .fixed, - publication.areConsecutive(first, second, index: index) + publication.areConsecutive(first, second, index: index, offsetFirstPage: offsetFirstPage) { spread.readingOrderIndices = index ... nextIndex index += 1 // Skips the consumed "second" page @@ -192,9 +195,22 @@ extension Array where Element == EPUBSpread { private extension Publication { /// Two resources are consecutive if their position hint (Properties.Page) /// are paired according to the reading progression. - func areConsecutive(_ first: Link, _ second: Link, index: Int) -> Bool { - guard index > 0 || first.properties.page != nil else { - return false + func areConsecutive(_ first: Link, _ second: Link, index: Int, offsetFirstPage: Bool?) -> Bool { + // Handle first page (index 0) based on offsetFirstPage setting + if index == 0 { + switch offsetFirstPage { + case true: + // Explicit true: always show first page alone + return false + case false: + // Explicit false: allow pairing (skip the metadata check) + break + case nil: + // Nil: use page metadata - if no metadata, show alone (current behavior) + guard first.properties.page != nil else { + return false + } + } } // Here we use the default publication reading progression instead diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index fabc233fe..a0e643e0a 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift @@ -52,6 +52,12 @@ public struct EPUBPreferences: ConfigurablePreferences { /// Leading line height. public var lineHeight: Double? + /// Indicates whether the first page should be displayed alone and centered + /// instead of alongside the second page. + /// + /// This is only effective if spreads are enabled. + public var offsetFirstPage: Bool? + /// Factor applied to horizontal margins. public var pageMargins: Double? @@ -114,6 +120,7 @@ public struct EPUBPreferences: ConfigurablePreferences { letterSpacing: Double? = nil, ligatures: Bool? = nil, lineHeight: Double? = nil, + offsetFirstPage: Bool? = nil, pageMargins: Double? = nil, paragraphIndent: Double? = nil, paragraphSpacing: Double? = nil, @@ -141,6 +148,7 @@ public struct EPUBPreferences: ConfigurablePreferences { self.letterSpacing = letterSpacing.map { max($0, 0) } self.ligatures = ligatures self.lineHeight = lineHeight + self.offsetFirstPage = offsetFirstPage self.pageMargins = pageMargins.map { max($0, 0) } self.paragraphIndent = paragraphIndent self.paragraphSpacing = paragraphSpacing.map { max($0, 0) } @@ -171,6 +179,7 @@ public struct EPUBPreferences: ConfigurablePreferences { letterSpacing: other.letterSpacing ?? letterSpacing, ligatures: other.ligatures ?? ligatures, lineHeight: other.lineHeight ?? lineHeight, + offsetFirstPage: other.offsetFirstPage ?? offsetFirstPage, pageMargins: other.pageMargins ?? pageMargins, paragraphIndent: other.paragraphIndent ?? paragraphIndent, paragraphSpacing: other.paragraphSpacing ?? paragraphSpacing, @@ -193,6 +202,7 @@ public struct EPUBPreferences: ConfigurablePreferences { public func filterSharedPreferences() -> EPUBPreferences { var prefs = self prefs.language = nil + prefs.offsetFirstPage = nil prefs.readingProgression = nil prefs.spread = nil prefs.verticalText = nil @@ -204,6 +214,7 @@ public struct EPUBPreferences: ConfigurablePreferences { public func filterPublicationPreferences() -> EPUBPreferences { EPUBPreferences( language: language, + offsetFirstPage: offsetFirstPage, readingProgression: readingProgression, spread: spread, verticalText: verticalText diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift index 6fa73203c..f06856714 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift @@ -232,6 +232,25 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = + preference( + preference: \.offsetFirstPage, + setting: \.offsetFirstPage, + isEffective: { [layout] in + layout == .fixed + && $0.settings.spread != .never + } + ) + /// Factor applied to horizontal margins. Default to 1. /// /// Only effective with reflowable publications. diff --git a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift index ae20df56c..85f08a3a0 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift @@ -23,6 +23,7 @@ public struct EPUBSettings: ConfigurableSettings { public var letterSpacing: Double? public var ligatures: Bool? public var lineHeight: Double? + public var offsetFirstPage: Bool? public var pageMargins: Double public var paragraphIndent: Double? public var paragraphSpacing: Double? @@ -57,6 +58,7 @@ public struct EPUBSettings: ConfigurableSettings { letterSpacing: Double?, ligatures: Bool?, lineHeight: Double?, + offsetFirstPage: Bool?, pageMargins: Double, paragraphIndent: Double?, paragraphSpacing: Double?, @@ -84,6 +86,7 @@ public struct EPUBSettings: ConfigurableSettings { self.letterSpacing = letterSpacing self.ligatures = ligatures self.lineHeight = lineHeight + self.offsetFirstPage = offsetFirstPage self.pageMargins = pageMargins self.paragraphIndent = paragraphIndent self.paragraphSpacing = paragraphSpacing @@ -162,6 +165,8 @@ public struct EPUBSettings: ConfigurableSettings { ?? defaults.ligatures, lineHeight: preferences.lineHeight ?? defaults.lineHeight, + offsetFirstPage: preferences.offsetFirstPage + ?? defaults.offsetFirstPage, pageMargins: preferences.pageMargins ?? defaults.pageMargins ?? 1.0, @@ -211,6 +216,7 @@ public struct EPUBDefaults { public var letterSpacing: Double? public var ligatures: Bool? public var lineHeight: Double? + public var offsetFirstPage: Bool? public var pageMargins: Double? public var paragraphIndent: Double? public var paragraphSpacing: Double? @@ -234,6 +240,7 @@ public struct EPUBDefaults { letterSpacing: Double? = nil, ligatures: Bool? = nil, lineHeight: Double? = nil, + offsetFirstPage: Bool? = nil, pageMargins: Double? = nil, paragraphIndent: Double? = nil, paragraphSpacing: Double? = nil, @@ -256,6 +263,7 @@ public struct EPUBDefaults { self.letterSpacing = letterSpacing self.ligatures = ligatures self.lineHeight = lineHeight + self.offsetFirstPage = offsetFirstPage self.pageMargins = pageMargins self.paragraphIndent = paragraphIndent self.paragraphSpacing = paragraphSpacing diff --git a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift index db9a57bae..723565f58 100644 --- a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift +++ b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift @@ -17,7 +17,10 @@ public struct PDFPreferences: ConfigurablePreferences { /// Method for fitting the pages within the viewport. public var fit: Fit? - /// Indicates if the first page should be displayed in its own spread. + /// Indicates whether the first page should be displayed alone instead of + /// alongside the second page. + /// + /// This is only effective if spreads are enabled. public var offsetFirstPage: Bool? /// Spacing between pages in points. diff --git a/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift b/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift index 3f249416a..912404119 100644 --- a/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift +++ b/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift @@ -44,7 +44,8 @@ public final class PDFPreferencesEditor: StatefulPreferencesEditor = diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index fa5c04522..6121a49db 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -125,6 +125,7 @@ struct UserPreferences< backgroundColor: editor.backgroundColor, fit: editor.fit, language: editor.language, + nullableOffsetFirstPage: editor.offsetFirstPage, readingProgression: editor.readingProgression, spread: editor.spread ) @@ -174,6 +175,7 @@ struct UserPreferences< fit: AnyEnumPreference? = nil, language: AnyPreference? = nil, offsetFirstPage: AnyPreference? = nil, + nullableOffsetFirstPage: AnyPreference? = nil, pageSpacing: AnyRangePreference? = nil, readingProgression: AnyEnumPreference? = nil, scroll: AnyPreference? = nil, @@ -255,14 +257,22 @@ struct UserPreferences< } } ) - } - if let offsetFirstPage = offsetFirstPage { - toggleRow( - title: "Offset first page", - preference: offsetFirstPage, - commit: commit - ) + if let offsetFirstPage = offsetFirstPage { + toggleRow( + title: "Offset first page", + preference: offsetFirstPage, + commit: commit + ) + } + + if let nullableOffsetFirstPage = nullableOffsetFirstPage { + nullableBoolPickerRow( + title: "Offset first page", + preference: nullableOffsetFirstPage, + commit: commit + ) + } } } @@ -651,6 +661,28 @@ struct UserPreferences< } } + /// Component for a nullable boolean `Preference` displayed in a `Picker` view + /// with three options: Auto, Yes, No. + @ViewBuilder func nullableBoolPickerRow( + title: String, + preference: AnyPreference, + commit: @escaping () -> Void + ) -> some View { + preferenceRow( + isActive: preference.isEffective, + onClear: { preference.clear(); commit() } + ) { + Picker(title, selection: Binding( + get: { preference.value ?? preference.effectiveValue }, + set: { preference.set($0); commit() } + )) { + Text("Auto").tag(nil as Bool?) + Text("Yes").tag(true as Bool?) + Text("No").tag(false as Bool?) + } + } + } + /// Component for an `EnumPreference` displayed in a `Picker` view. @ViewBuilder func pickerRow( title: String, From 5fa04da5932ff567bfb2525cff4f853edd00dd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 19 Jan 2026 17:44:15 +0100 Subject: [PATCH 2/3] Refactor epub spreads --- .../Navigator/EPUB/EPUBFixedSpreadView.swift | 4 +- .../EPUB/EPUBNavigatorViewController.swift | 16 +- .../EPUB/EPUBReflowableSpreadView.swift | 11 +- Sources/Navigator/EPUB/EPUBSpread.swift | 296 ++++++++++-------- Sources/Navigator/EPUB/EPUBSpreadView.swift | 6 +- 5 files changed, 183 insertions(+), 150 deletions(-) diff --git a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift index afd0b37ea..d88a938a4 100644 --- a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift @@ -47,7 +47,7 @@ final class EPUBFixedSpreadView: EPUBSpreadView { scrollView.backgroundColor = UIColor.clear // Loads the wrapper page into the web view. - let spreadFile = "fxl-spread-\(spread.spread ? "two" : "one")" + let spreadFile = "fxl-spread-\(viewModel.spreadEnabled ? "two" : "one")" if let wrapperPageURL = Bundle.module.url(forResource: spreadFile, withExtension: "html", subdirectory: "Assets"), var wrapperPage = try? String(contentsOf: wrapperPageURL, encoding: .utf8) @@ -107,7 +107,7 @@ final class EPUBFixedSpreadView: EPUBSpreadView { // to be executed before the spread is loaded. let spreadJSON = spread.jsonString( forBaseURL: viewModel.publicationBaseURL, - readingOrder: viewModel.readingOrder + readingProgression: viewModel.readingProgression ) webView.evaluateJavaScript("spread.load(\(spreadJSON));") } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 323e444d0..a88c2e890 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -399,7 +399,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, if needsReloadSpreadsOnActive { needsReloadSpreadsOnActive = false - reloadSpreads(force: true) + reloadSpreads() } } @@ -420,7 +420,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, applySettings() - _reloadSpreads(force: true) + _reloadSpreads() onInitializedCallbacks.complete() } @@ -556,7 +556,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, } paginationView.isScrollEnabled = isPaginationViewScrollingEnabled - reloadSpreads(force: true) + reloadSpreads() } private var spreads: [EPUBSpread] = [] @@ -568,7 +568,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, private var needsReloadSpreadsOnActive = false - private func reloadSpreads(force: Bool) { + private func reloadSpreads() { guard state != .initializing, isViewLoaded @@ -585,16 +585,14 @@ open class EPUBNavigatorViewController: InputObservableViewController, return } - _reloadSpreads(force: force) + _reloadSpreads() } - private func _reloadSpreads(force: Bool) { + private func _reloadSpreads() { let locator = currentLocation guard let paginationView = paginationView, - // Already loaded with the expected amount of spreads? - force || spreads.first?.spread != viewModel.spreadEnabled, on(.load(locator)) else { return @@ -1257,7 +1255,7 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { } func spreadViewDidTerminate() { - reloadSpreads(force: true) + reloadSpreads() } } diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 143654190..df308ca1f 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -81,8 +81,7 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { log(.error, "Only one document at a time can be displayed in a reflowable spread") return } - let link = viewModel.readingOrder[spread.leading] - let url = viewModel.url(to: link) + let url = viewModel.url(to: spread.first.link) webView.load(URLRequest(url: url.url)) } @@ -135,7 +134,7 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { override func progression(in index: ReadingOrder.Index) -> ClosedRange { guard - spread.leading == index, + spread.first.index == index, let progression = progression else { return 0 ... 0 @@ -144,10 +143,8 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { } override func spreadDidLoad() async { - if - let link = viewModel.readingOrder.getOrNil(spread.leading), - let linkJSON = serializeJSONString(link.json) - { + let link = spread.first.link + if let linkJSON = serializeJSONString(link.json) { await evaluateScript("readium.link = \(linkJSON);") } diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 68d2571ca..8716d0f7f 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -7,106 +7,71 @@ import Foundation import ReadiumShared -/// A list of EPUB resources to be displayed together on the screen, as one-page -/// or two-pages spread. -struct EPUBSpread: Loggable { - /// Indicates whether two pages are displayed side by side. - var spread: Bool +protocol EPUBSpreadProtocol { + /// Returns whether the spread contains the resource at the given reading + /// order index + func contains(index: ReadingOrder.Index) -> Bool - /// Indices for the resources displayed in the spread, in reading order. - /// - /// Note: it's possible to have less links than the amount of `pageCount` - /// available, because a single page might be displayed in a two-page spread - /// (eg. with Properties.Page center, left or right). - var readingOrderIndices: ReadingOrderIndices + /// Return the number of positions contained in the spread. + func positionCount(in readingOrder: ReadingOrder, positionsByReadingOrder: [[Locator]]) -> Int - /// Spread reading progression direction. - var readingProgression: ReadingProgression + /// Returns a JSON representation of the links in the spread. + /// + /// The JSON is an array of link objects in reading progression order. + /// Each link object contains: + /// - link: Link object of the resource in the Publication + /// - url: Full URL to the resource. + /// - page [left|center|right]: (optional) Page position of the linked resource in the spread. + func json(forBaseURL baseURL: HTTPURL, readingProgression: ReadingProgression) -> [[String: Any]] +} - init(spread: Bool, readingOrderIndices: ReadingOrderIndices, readingProgression: ReadingProgression) { - precondition(!readingOrderIndices.isEmpty, "A spread must have at least one page") - precondition(spread || readingOrderIndices.count == 1, "A one-page spread must have only one page") - precondition(!spread || 1 ... 2 ~= readingOrderIndices.count, "A two-pages spread must have one or two pages max") - self.spread = spread - self.readingOrderIndices = readingOrderIndices - self.readingProgression = readingProgression - } +/// Represents a spread of EPUB resources displayed in the viewport. A spread +/// can contain one or two resources (for FXL). +enum EPUBSpread: EPUBSpreadProtocol { + case single(EPUBSingleSpread) + case double(EPUBDoubleSpread) - /// Returns the left-most reading order index in the spread. - var left: ReadingOrder.Index { - switch readingProgression { - case .ltr: - readingOrderIndices.lowerBound - case .rtl: - readingOrderIndices.upperBound + var readingOrderIndices: ReadingOrderIndices { + switch self { + case let .single(spread): + return spread.resource.index ... spread.resource.index + case let .double(spread): + return spread.first.index ... spread.second.index } } - /// Returns the right-most reading order index in the spread. - var right: ReadingOrder.Index { - switch readingProgression { - case .ltr: - readingOrderIndices.upperBound - case .rtl: - readingOrderIndices.lowerBound + var first: EPUBSpreadResource { + switch self { + case let .single(spread): + return spread.resource + case let .double(spread): + return spread.first } } - /// Returns the leading reading order index in the reading progression. - var leading: ReadingOrder.Index { - readingOrderIndices.lowerBound + var spread: EPUBSpreadProtocol { + switch self { + case let .single(spread): + return spread + case let .double(spread): + return spread + } } - /// Returns whether the spread contains the resource at the given reading - /// order index func contains(index: ReadingOrder.Index) -> Bool { - readingOrderIndices.contains(index) + spread.contains(index: index) } - /// Return the number of positions contained in the spread. func positionCount(in readingOrder: ReadingOrder, positionsByReadingOrder: [[Locator]]) -> Int { - readingOrderIndices - .map { index in - positionsByReadingOrder[index].count - } - .reduce(0, +) + spread.positionCount(in: readingOrder, positionsByReadingOrder: positionsByReadingOrder) } - /// Returns a JSON representation of the links in the spread. - /// The JSON is an array of link objects in reading progression order. - /// Each link object contains: - /// - link: Link object of the resource in the Publication - /// - url: Full URL to the resource. - /// - page [left|center|right]: (optional) Page position of the linked resource in the spread. - func json(forBaseURL baseURL: HTTPURL, readingOrder: ReadingOrder) -> [[String: Any]] { - func makeLinkJSON(_ index: ReadingOrder.Index, page: Properties.Page? = nil) -> [String: Any]? { - guard let link = readingOrder.getOrNil(index) else { - return nil - } - - let page = page ?? link.properties.page ?? readingProgression.startingPage - return [ - "index": index, - "link": link.json, - "url": link.url(relativeTo: baseURL).string, - "page": page.rawValue, - ] - } - - var json: [[String: Any]?] = [] - - if readingOrderIndices.count == 1 { - json.append(makeLinkJSON(leading)) - } else { - json.append(makeLinkJSON(left, page: .left)) - json.append(makeLinkJSON(right, page: .right)) - } - - return json.compactMap { $0 } + func json(forBaseURL baseURL: HTTPURL, readingProgression: ReadingProgression) -> [[String: Any]] { + spread.json(forBaseURL: baseURL, readingProgression: readingProgression) } - func jsonString(forBaseURL baseURL: HTTPURL, readingOrder: ReadingOrder) -> String { - serializeJSONString(json(forBaseURL: baseURL, readingOrder: readingOrder)) ?? "[]" + func jsonString(forBaseURL baseURL: HTTPURL, readingProgression: ReadingProgression) -> String { + serializeJSONString(json(forBaseURL: baseURL, readingProgression: readingProgression)) ?? "[]" } /// Builds a list of spreads for the given Publication. @@ -125,21 +90,17 @@ struct EPUBSpread: Loggable { ) -> [EPUBSpread] { spread ? makeTwoPagesSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression, offsetFirstPage: offsetFirstPage) - : makeOnePageSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression) + : makeOnePageSpreads(readingOrder: readingOrder) } /// Builds a list of one-page spreads for the given Publication. private static func makeOnePageSpreads( - for publication: Publication, - readingOrder: [Link], - readingProgression: ReadingProgression + readingOrder: [Link] ) -> [EPUBSpread] { - readingOrder.enumerated().map { index, _ in - EPUBSpread( - spread: false, - readingOrderIndices: index ... index, - readingProgression: readingProgression - ) + readingOrder.enumerated().map { index, link in + .single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: index, link: link) + )) } } @@ -154,69 +115,59 @@ struct EPUBSpread: Loggable { var index = 0 while index < readingOrder.count { - let first = readingOrder[index] + var first = readingOrder[index] - var spread = EPUBSpread( - spread: true, - readingOrderIndices: index ... index, - readingProgression: readingProgression - ) + // If the `offsetFirstPage` is set, we override the default + // position of the first resource to display it either: + // - (true) on its own and centered + // - (false) next to the second resource + if index == 0, let offsetFirstPage = offsetFirstPage { + first.properties.page = offsetFirstPage ? .center : nil + } 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). if let second = readingOrder.getOrNil(nextIndex), publication.metadata.layout == .fixed, - publication.areConsecutive(first, second, index: index, offsetFirstPage: offsetFirstPage) + areConsecutive(first, second, readingProgression: publication.metadata.readingProgression) { - spread.readingOrderIndices = index ... nextIndex + spreads.append(.double( + EPUBDoubleSpread( + first: EPUBSpreadResource(index: index, link: first), + second: EPUBSpreadResource(index: nextIndex, link: second) + ) + )) index += 1 // Skips the consumed "second" page + + } else { + spreads.append(.single( + EPUBSingleSpread( + resource: EPUBSpreadResource(index: index, link: first) + ) + )) } - spreads.append(spread) index += 1 } return spreads } -} -extension Array where Element == EPUBSpread { - /// Returns the index of the first spread containing a resource with the given `href`. - func firstIndexWithReadingOrderIndex(_ index: ReadingOrder.Index) -> Int? { - firstIndex { spread in - spread.contains(index: index) - } - } -} - -private extension Publication { /// Two resources are consecutive if their position hint (Properties.Page) /// are paired according to the reading progression. - func areConsecutive(_ first: Link, _ second: Link, index: Int, offsetFirstPage: Bool?) -> Bool { - // Handle first page (index 0) based on offsetFirstPage setting - if index == 0 { - switch offsetFirstPage { - case true: - // Explicit true: always show first page alone - return false - case false: - // Explicit false: allow pairing (skip the metadata check) - break - case nil: - // Nil: use page metadata - if no metadata, show alone (current behavior) - guard first.properties.page != nil else { - return false - } - } - } - + private static func areConsecutive( + _ first: Link, + _ second: Link, + readingProgression: ReadiumShared.ReadingProgression + ) -> Bool { // Here we use the default publication reading progression instead // of the custom one provided, otherwise the page position hints // might be wrong, and we could end up with only one-page spreads. - switch metadata.readingProgression { + switch readingProgression { case .ltr, .ttb, .auto: let firstPosition = first.properties.page ?? .left let secondPosition = second.properties.page ?? .right @@ -228,3 +179,90 @@ private extension Publication { } } } + +struct EPUBSpreadResource { + let index: ReadingOrder.Index + let link: Link + + func json(forBaseURL baseURL: HTTPURL, page: Properties.Page) -> [String: Any] { + [ + "index": index, + "link": link.json, + "url": link.url(relativeTo: baseURL).string, + "page": page.rawValue, + ] + } +} + +struct EPUBSingleSpread: EPUBSpreadProtocol, Loggable { + var resource: EPUBSpreadResource + + func contains(index: ReadingOrder.Index) -> Bool { + resource.index == index + } + + func positionCount(in readingOrder: ReadingOrder, positionsByReadingOrder: [[Locator]]) -> Int { + positionsByReadingOrder.getOrNil(resource.index)?.count ?? 0 + } + + func json(forBaseURL baseURL: HTTPURL, readingProgression: ReadingProgression) -> [[String: Any]] { + [ + resource.json( + forBaseURL: baseURL, + page: resource.link.properties.page ?? readingProgression.startingPage + ), + ] + } +} + +struct EPUBDoubleSpread: EPUBSpreadProtocol, Loggable { + var first: EPUBSpreadResource + var second: EPUBSpreadResource + + /// Returns the left resource in the spread. + func left(for readingProgression: ReadingProgression) -> EPUBSpreadResource { + switch readingProgression { + case .ltr: + first + case .rtl: + second + } + } + + /// Returns the right resource in the spread. + func right(for readingProgression: ReadingProgression) -> EPUBSpreadResource { + switch readingProgression { + case .ltr: + second + case .rtl: + first + } + } + + func contains(index: ReadingOrder.Index) -> Bool { + first.index == index || second.index == index + } + + func positionCount(in readingOrder: ReadingOrder, positionsByReadingOrder: [[Locator]]) -> Int { + let firstPositions = positionsByReadingOrder.getOrNil(first.index)?.count ?? 0 + let secondPositions = positionsByReadingOrder.getOrNil(second.index)?.count ?? 0 + return firstPositions + secondPositions + } + + func json(forBaseURL baseURL: HTTPURL, readingProgression: ReadingProgression) -> [[String: Any]] { + [ + left(for: readingProgression).json(forBaseURL: baseURL, page: .left), + right(for: readingProgression).json(forBaseURL: baseURL, page: .right), + ] + } +} + +extension Array where Element == EPUBSpread { + /// Returns the index of the first spread containing a resource with the + /// given `href`. + func firstIndexWithReadingOrderIndex(_ index: ReadingOrder.Index) -> Int? { + firstIndex { spread in + spread.contains(index: index) + } + } +} diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index fbdd5abd5..414fdaf88 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -385,9 +385,9 @@ class EPUBSpreadView: UIView, Loggable, PageView { func findFirstVisibleElementLocator() async -> Locator? { let result = await evaluateScript("readium.findFirstVisibleLocator()") do { - let resource = viewModel.readingOrder[spread.leading] + let link = spread.first.link let locator = try Locator(json: result.get())? - .copy(href: resource.url(), mediaType: resource.mediaType ?? .xhtml) + .copy(href: link.url(), mediaType: link.mediaType ?? .xhtml) return locator } catch { log(.error, error) @@ -614,7 +614,7 @@ private extension EPUBSpreadView { return } - trace("stopping activity indicator because spread \(viewModel.readingOrder[spread.leading].href) did not load") + trace("stopping activity indicator because spread \(spread.first.link.href) did not load") activityIndicatorView?.stopAnimating() } From ad057748e72be2a5a1de7b83d7eb17cea59b22f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 19 Jan 2026 18:10:07 +0100 Subject: [PATCH 3/3] Update changelog --- CHANGELOG.md | 1 + Sources/Navigator/EPUB/EPUBSpread.swift | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fe83668..a35e8919a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. Take a look * Support for displaying Divina (image-based publications like CBZ) in the fixed-layout EPUB navigator. * Bitmap images in the EPUB reading order are now supported as a fixed layout resource. +* Added `offsetFirstPage` preference for fixed-layout EPUBs to control whether the first page is displayed alone or alongside the second page when spreads are enabled. #### Streamer diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 8716d0f7f..320af5c69 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -7,9 +7,10 @@ import Foundation import ReadiumShared +/// Common interface for spread types. protocol EPUBSpreadProtocol { /// Returns whether the spread contains the resource at the given reading - /// order index + /// order index. func contains(index: ReadingOrder.Index) -> Bool /// Return the number of positions contained in the spread. @@ -28,9 +29,12 @@ protocol EPUBSpreadProtocol { /// Represents a spread of EPUB resources displayed in the viewport. A spread /// can contain one or two resources (for FXL). enum EPUBSpread: EPUBSpreadProtocol { + /// A spread displaying a single resource. case single(EPUBSingleSpread) + /// A spread displaying two resources side by side (FXL only). case double(EPUBDoubleSpread) + /// Range of reading order indices contained in this spread. var readingOrderIndices: ReadingOrderIndices { switch self { case let .single(spread): @@ -40,6 +44,7 @@ enum EPUBSpread: EPUBSpreadProtocol { } } + /// The leading resource in the reading progression. var first: EPUBSpreadResource { switch self { case let .single(spread): @@ -49,7 +54,7 @@ enum EPUBSpread: EPUBSpreadProtocol { } } - var spread: EPUBSpreadProtocol { + private var spread: EPUBSpreadProtocol { switch self { case let .single(spread): return spread @@ -180,10 +185,14 @@ enum EPUBSpread: EPUBSpreadProtocol { } } +/// A resource displayed in a spread, with its reading order index. struct EPUBSpreadResource { + /// Index of the resource in the reading order. let index: ReadingOrder.Index + /// Link to the resource. let link: Link + /// Returns a JSON representation of the resource for the spread scripts. func json(forBaseURL baseURL: HTTPURL, page: Properties.Page) -> [String: Any] { [ "index": index, @@ -194,7 +203,9 @@ struct EPUBSpreadResource { } } +/// A spread displaying a single resource. struct EPUBSingleSpread: EPUBSpreadProtocol, Loggable { + /// The resource displayed in the spread. var resource: EPUBSpreadResource func contains(index: ReadingOrder.Index) -> Bool { @@ -215,8 +226,11 @@ struct EPUBSingleSpread: EPUBSpreadProtocol, Loggable { } } +/// A spread displaying two resources side by side (FXL only). struct EPUBDoubleSpread: EPUBSpreadProtocol, Loggable { + /// The leading resource in the reading progression. var first: EPUBSpreadResource + /// The trailing resource in the reading progression. var second: EPUBSpreadResource /// Returns the left resource in the spread.