From 7d904eec96a0983917634fbea174e42a24051488 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Thu, 6 Nov 2025 12:00:40 +0100 Subject: [PATCH 01/55] Add experimental EPUB decoration positioning (#665) --- CHANGELOG.md | 2 ++ .../EPUB/HTMLDecorationTemplate.swift | 25 ++++++++++++------- .../Reader/EPUB/EPUBViewController.swift | 3 ++- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2225c6ad2f..04da7bba73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file. Take a look * By default, the navigator uses the window's `safeAreaInsets`, which can cause content to shift when the status bar is shown or hidden (since those insets change). To avoid this, implement `navigatorContentInset(_:)` and return insets that remain stable across status bar visibility changes — for example, a top inset large enough to accommodate the maximum expected status bar height. * Added `[TTSVoice].filterByLanguage(_:)` to filter TTS voices by language and region. * Added `[TTSVoice].sorted()` to sort TTS voices by region, quality, and gender. +* New experimental positioning of EPUB decorations that places highlights behind text to improve legibility with opaque decorations (contributed by [@ddfreiling](https://github.com/readium/swift-toolkit/pull/665)). + * To opt-in, initialize the `EPUBNavigatorViewController.Configuration` object with `decorationTemplates: HTMLDecorationTemplate.defaultTemplates(alpha: 1.0, experimentalPositioning: true)`. #### LCP diff --git a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift index b5824197f8..846f4bb13f 100644 --- a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift +++ b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift @@ -59,27 +59,28 @@ public struct HTMLDecorationTemplate { defaultTint: UIColor = .yellow, lineWeight: Int = 2, cornerRadius: Int = 3, - alpha: Double = 0.3 + alpha: Double = 0.3, + experimentalPositioning: Bool = false ) -> [Decoration.Style.Id: HTMLDecorationTemplate] { let padding = UIEdgeInsets(top: 0, left: 1, bottom: 0, right: 1) return [ - .highlight: .highlight(defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha), - .underline: .underline(defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha), + .highlight: .highlight(defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha, experimentalPositioning: experimentalPositioning), + .underline: .underline(defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha, experimentalPositioning: experimentalPositioning), ] } /// Creates a new decoration template for the `highlight` style. - public static func highlight(defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double) -> HTMLDecorationTemplate { - makeTemplate(asHighlight: true, defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha) + public static func highlight(defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double, experimentalPositioning: Bool = false) -> HTMLDecorationTemplate { + makeTemplate(asHighlight: true, defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha, experimentalPositioning: experimentalPositioning) } /// Creates a new decoration template for the `underline` style. - public static func underline(defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double) -> HTMLDecorationTemplate { - makeTemplate(asHighlight: false, defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha) + public static func underline(defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double, experimentalPositioning: Bool = false) -> HTMLDecorationTemplate { + makeTemplate(asHighlight: false, defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha, experimentalPositioning: experimentalPositioning) } /// - Parameter asHighlight: When true, the non active style is of an highlight. Otherwise, it is an underline. - private static func makeTemplate(asHighlight: Bool, defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double) -> HTMLDecorationTemplate { + private static func makeTemplate(asHighlight: Bool, defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double, experimentalPositioning: Bool = false) -> HTMLDecorationTemplate { let className = makeUniqueClassName(key: asHighlight ? "highlight" : "underline") return HTMLDecorationTemplate( layout: .boxes, @@ -94,6 +95,11 @@ public struct HTMLDecorationTemplate { if !asHighlight || isActive { css += "--underline-color: \(tint.cssValue());" } + if experimentalPositioning { + // Experimental positioning: + // Decoration is placed behind the publication's text, to prevent it from affecting text-color. + css += "--decoration-z-index: -1;" + } return "
" }, stylesheet: @@ -104,6 +110,7 @@ public struct HTMLDecorationTemplate { border-radius: \(cornerRadius)px; box-sizing: border-box; border: 0 solid var(--underline-color); + z-index: var(--decoration-z-index); } /* Horizontal (default) */ @@ -121,7 +128,7 @@ public struct HTMLDecorationTemplate { [data-writing-mode="vertical-lr"].\(className), [data-writing-mode="sideways-lr"].\(className) { border-right-width: \(lineWeight)px; - } + } """ ) } diff --git a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift index 7315ea093c..fb414bf066 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift @@ -30,7 +30,8 @@ class EPUBViewController: VisualReaderViewController, httpServer: HTTPServer ) throws { - var templates = HTMLDecorationTemplate.defaultTemplates() + // Create default templates, but make highlights opaque with experimental positioning. + var templates = HTMLDecorationTemplate.defaultTemplates(alpha: 1.0, experimentalPositioning: true) templates[.pageList] = .pageList let resources = FileURL(url: Bundle.main.resourceURL!)! From 8bd799d00a835248a6f5987f70c23c4c30280e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 6 Nov 2025 16:35:56 +0100 Subject: [PATCH 02/55] 3.5.0 (#667) --- CHANGELOG.md | 5 ++++- Cartfile | 2 +- Package.swift | 2 +- README.md | 12 ++++++------ Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec | 6 +++--- Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec | 6 +++--- Support/CocoaPods/ReadiumInternal.podspec | 2 +- Support/CocoaPods/ReadiumLCP.podspec | 8 ++++---- Support/CocoaPods/ReadiumNavigator.podspec | 6 +++--- Support/CocoaPods/ReadiumOPDS.podspec | 6 +++--- Support/CocoaPods/ReadiumShared.podspec | 6 +++--- Support/CocoaPods/ReadiumStreamer.podspec | 6 +++--- TestApp/Sources/Info.plist | 4 ++-- 13 files changed, 37 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04da7bba73..e71310ea47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. -## [Unreleased] + + +## [3.5.0] ### Added @@ -1006,3 +1008,4 @@ progression. Now if no reading progression is set, the `effectiveReadingProgress [3.2.0]: https://github.com/readium/swift-toolkit/compare/3.1.0...3.2.0 [3.3.0]: https://github.com/readium/swift-toolkit/compare/3.2.0...3.3.0 [3.4.0]: https://github.com/readium/swift-toolkit/compare/3.3.0...3.4.0 +[3.5.0]: https://github.com/readium/swift-toolkit/compare/3.4.0...3.5.0 diff --git a/Cartfile b/Cartfile index 6e94b1cff9..cdb0f6bb36 100644 --- a/Cartfile +++ b/Cartfile @@ -3,7 +3,7 @@ github "krzyzanowskim/CryptoSwift" ~> 1.8.0 github "ra1028/DifferenceKit" ~> 1.3.0 github "readium/Fuzi" ~> 4.0.0 github "readium/GCDWebServer" ~> 4.0.0 -github "readium/ZIPFoundation" ~> 3.0.0 +github "readium/ZIPFoundation" ~> 3.0.1 # There's a regression with 2.7.4 in SwiftSoup, because they used iOS 13 APIs without bumping the deployment target. github "scinfu/SwiftSoup" == 2.7.1 github "stephencelis/SQLite.swift" ~> 0.15.0 diff --git a/Package.swift b/Package.swift index c281d2038e..5856660f03 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ let package = Package( .package(url: "https://github.com/ra1028/DifferenceKit.git", from: "1.3.0"), .package(url: "https://github.com/readium/Fuzi.git", from: "4.0.0"), .package(url: "https://github.com/readium/GCDWebServer.git", from: "4.0.0"), - .package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.0"), + .package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.1"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.0"), .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0"), ], diff --git a/README.md b/README.md index e398b4975b..c7e470bc41 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ If you're stuck, find more information at [developer.apple.com](https://develope Add the following to your `Cartfile`: ``` -github "readium/swift-toolkit" ~> 3.4.0 +github "readium/swift-toolkit" ~> 3.5.0 ``` Then, [follow the usual Carthage steps](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) to add the Readium libraries to your project. @@ -143,11 +143,11 @@ Add the following `pod` statements to your `Podfile` for the Readium libraries y source 'https://github.com/readium/podspecs' source 'https://cdn.cocoapods.org/' -pod 'ReadiumShared', '~> 3.4.0' -pod 'ReadiumStreamer', '~> 3.4.0' -pod 'ReadiumNavigator', '~> 3.4.0' -pod 'ReadiumOPDS', '~> 3.4.0' -pod 'ReadiumLCP', '~> 3.4.0' +pod 'ReadiumShared', '~> 3.5.0' +pod 'ReadiumStreamer', '~> 3.5.0' +pod 'ReadiumNavigator', '~> 3.5.0' +pod 'ReadiumOPDS', '~> 3.5.0' +pod 'ReadiumLCP', '~> 3.5.0' ``` Take a look at [CocoaPods's documentation](https://guides.cocoapods.org/using/using-cocoapods.html) for more information. diff --git a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec index c6a38a0445..91611f4820 100644 --- a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec +++ b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterGCDWebServer" - s.version = "3.4.0" + s.version = "3.5.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use GCDWebServer as an HTTP server in Readium" s.homepage = "http://readium.github.io" @@ -14,8 +14,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "13.4" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumShared', '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' + s.dependency 'ReadiumShared', '~> 3.5.0' + s.dependency 'ReadiumInternal', '~> 3.5.0' s.dependency 'ReadiumGCDWebServer', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec index d9d0b00f18..6d68c28e4f 100644 --- a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec +++ b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterLCPSQLite" - s.version = "3.4.0" + s.version = "3.5.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use SQLite.swift for the Readium LCP repositories" s.homepage = "http://readium.github.io" @@ -14,8 +14,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "13.4" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumLCP', '~> 3.4.0' - s.dependency 'ReadiumShared', '~> 3.4.0' + s.dependency 'ReadiumLCP', '~> 3.5.0' + s.dependency 'ReadiumShared', '~> 3.5.0' s.dependency 'SQLite.swift', '~> 0.15.0' end diff --git a/Support/CocoaPods/ReadiumInternal.podspec b/Support/CocoaPods/ReadiumInternal.podspec index e1a028deb9..cce2e9f236 100644 --- a/Support/CocoaPods/ReadiumInternal.podspec +++ b/Support/CocoaPods/ReadiumInternal.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumInternal" - s.version = "3.4.0" + s.version = "3.5.0" s.license = "BSD 3-Clause License" s.summary = "Private utilities used by the Readium modules" s.homepage = "http://readium.github.io" diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index ee3c6a3d79..9811146bb9 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumLCP" - s.version = "3.4.0" + s.version = "3.5.0" s.license = "BSD 3-Clause License" s.summary = "Readium LCP" s.homepage = "http://readium.github.io" @@ -20,8 +20,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "13.4" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2'} - s.dependency 'ReadiumShared' , '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' - s.dependency 'ReadiumZIPFoundation', '~> 3.0.0' + s.dependency 'ReadiumShared' , '~> 3.5.0' + s.dependency 'ReadiumInternal', '~> 3.5.0' + s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' s.dependency 'CryptoSwift', '~> 1.8.0' end diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index 1b704fb9b5..d301bce653 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumNavigator" - s.version = "3.4.0" + s.version = "3.5.0" s.license = "BSD 3-Clause License" s.summary = "Readium Navigator" s.homepage = "http://readium.github.io" @@ -19,8 +19,8 @@ Pod::Spec.new do |s| s.platform = :ios s.ios.deployment_target = "13.4" - s.dependency 'ReadiumShared', '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' + s.dependency 'ReadiumShared', '~> 3.5.0' + s.dependency 'ReadiumInternal', '~> 3.5.0' s.dependency 'DifferenceKit', '~> 1.0' s.dependency 'SwiftSoup', '~> 2.7.0' diff --git a/Support/CocoaPods/ReadiumOPDS.podspec b/Support/CocoaPods/ReadiumOPDS.podspec index 02b45e62ec..7fee3384b5 100644 --- a/Support/CocoaPods/ReadiumOPDS.podspec +++ b/Support/CocoaPods/ReadiumOPDS.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumOPDS" - s.version = "3.4.0" + s.version = "3.5.0" s.license = "BSD 3-Clause License" s.summary = "Readium OPDS" s.homepage = "http://readium.github.io" @@ -14,8 +14,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "13.4" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumShared', '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' + s.dependency 'ReadiumShared', '~> 3.5.0' + s.dependency 'ReadiumInternal', '~> 3.5.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index 61a78e7405..4e82570632 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumShared" - s.version = "3.4.0" + s.version = "3.5.0" s.license = "BSD 3-Clause License" s.summary = "Readium Shared" s.homepage = "http://readium.github.io" @@ -22,7 +22,7 @@ Pod::Spec.new do |s| s.dependency 'Minizip', '~> 1.0.0' s.dependency 'SwiftSoup', '~> 2.7.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' - s.dependency 'ReadiumZIPFoundation', '~> 3.0.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' + s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' + s.dependency 'ReadiumInternal', '~> 3.5.0' end diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index 8c0a623b3e..0b3bf79cef 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumStreamer" - s.version = "3.4.0" + s.version = "3.5.0" s.license = "BSD 3-Clause License" s.summary = "Readium Streamer" s.homepage = "http://readium.github.io" @@ -22,8 +22,8 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.dependency 'ReadiumFuzi', '~> 4.0.0' - s.dependency 'ReadiumShared', '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' + s.dependency 'ReadiumShared', '~> 3.5.0' + s.dependency 'ReadiumInternal', '~> 3.5.0' s.dependency 'CryptoSwift', '~> 1.8.0' end diff --git a/TestApp/Sources/Info.plist b/TestApp/Sources/Info.plist index f2ff56a1b4..eb0d3f5ca9 100644 --- a/TestApp/Sources/Info.plist +++ b/TestApp/Sources/Info.plist @@ -252,9 +252,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 3.4.0 + 3.5.0 CFBundleVersion - 3.4.0 + 3.5.0 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace From 0ec329d59b18a12502888f3d2eab6e7e6e75fbc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 10 Nov 2025 17:56:16 +0100 Subject: [PATCH 03/55] Add `DirectionalNavigationAdapter.onNavigation` (#669) --- CHANGELOG.md | 11 +++- .../DirectionalNavigationAdapter.swift | 51 ++++++++++++++----- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e71310ea47..f90a8ee26a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,16 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. - +## [Unreleased] + +### Added + +#### Navigator + +* Added `DirectionalNavigationAdapter.onNavigation` callback to be notified when a navigation action is triggered. + * This callback is called before executing any navigation action. + * Useful for hiding UI elements when the user navigates, or implementing analytics. + ## [3.5.0] diff --git a/Sources/Navigator/DirectionalNavigationAdapter.swift b/Sources/Navigator/DirectionalNavigationAdapter.swift index bfa1a574c2..3b27ce9a9c 100644 --- a/Sources/Navigator/DirectionalNavigationAdapter.swift +++ b/Sources/Navigator/DirectionalNavigationAdapter.swift @@ -100,7 +100,9 @@ public final class DirectionalNavigationAdapter { private let pointerPolicy: PointerPolicy private let keyboardPolicy: KeyboardPolicy private let animatedTransition: Bool + private let onNavigation: @MainActor () -> Void + @available(*, deprecated, message: "Use `bind(to:)` instead of notifying the event yourself. See the migration guide.") private weak var navigator: VisualNavigator? /// Initializes a new `DirectionalNavigationAdapter`. @@ -110,14 +112,17 @@ public final class DirectionalNavigationAdapter { /// - keyboardPolicy: Policy on page turns using the keyboard. /// - animatedTransition: Indicates whether the page turns should be /// animated. + /// - onNavigation: Callback called when a navigation is triggered. public init( pointerPolicy: PointerPolicy = PointerPolicy(), keyboardPolicy: KeyboardPolicy = KeyboardPolicy(), - animatedTransition: Bool = false + animatedTransition: Bool = false, + onNavigation: @escaping @MainActor () -> Void = {} ) { self.pointerPolicy = pointerPolicy self.keyboardPolicy = keyboardPolicy self.animatedTransition = animatedTransition + self.onNavigation = onNavigation } /// Binds the adapter to the given visual navigator. @@ -162,7 +167,6 @@ public final class DirectionalNavigationAdapter { } let bounds = navigator.view.bounds - let options = NavigatorGoOptions(animated: animatedTransition) if pointerPolicy.edges.contains(.horizontal) { let horizontalEdgeSize = pointerPolicy.horizontalEdgeThresholdPercent @@ -172,9 +176,9 @@ public final class DirectionalNavigationAdapter { let rightRange = (bounds.width - horizontalEdgeSize) ... bounds.width if rightRange.contains(point.x) { - return await navigator.goRight(options: options) + return await goRight(in: navigator) } else if leftRange.contains(point.x) { - return await navigator.goLeft(options: options) + return await goLeft(in: navigator) } } @@ -186,9 +190,9 @@ public final class DirectionalNavigationAdapter { let bottomRange = (bounds.height - verticalEdgeSize) ... bounds.height if bottomRange.contains(point.y) { - return await navigator.goForward(options: options) + return await goForward(in: navigator) } else if topRange.contains(point.y) { - return await navigator.goBackward(options: options) + return await goBackward(in: navigator) } } @@ -200,24 +204,44 @@ public final class DirectionalNavigationAdapter { return false } - let options = NavigatorGoOptions(animated: animatedTransition) - switch event.key { case .arrowUp where keyboardPolicy.handleArrowKeys: - return await navigator.goBackward(options: options) + return await goBackward(in: navigator) case .arrowDown where keyboardPolicy.handleArrowKeys: - return await navigator.goForward(options: options) + return await goForward(in: navigator) case .arrowLeft where keyboardPolicy.handleArrowKeys: - return await navigator.goLeft(options: options) + return await goLeft(in: navigator) case .arrowRight where keyboardPolicy.handleArrowKeys: - return await navigator.goRight(options: options) + return await goRight(in: navigator) case .space where keyboardPolicy.handleSpaceKey: - return await navigator.goForward(options: options) + return await goForward(in: navigator) default: return false } } + @MainActor private func goBackward(in navigator: VisualNavigator) async -> Bool { + await go { await navigator.goBackward(options: $0) } + } + + @MainActor private func goForward(in navigator: VisualNavigator) async -> Bool { + await go { await navigator.goForward(options: $0) } + } + + @MainActor private func goLeft(in navigator: VisualNavigator) async -> Bool { + await go { await navigator.goLeft(options: $0) } + } + + @MainActor private func goRight(in navigator: VisualNavigator) async -> Bool { + await go { await navigator.goRight(options: $0) } + } + + @MainActor private func go(_ action: (NavigatorGoOptions) async -> Bool) async -> Bool { + onNavigation() + let options = NavigatorGoOptions(animated: animatedTransition) + return await action(options) + } + @available(*, deprecated, message: "Use the new initializer without the navigator parameter and call `bind(to:)`. See the migration guide.") public init( navigator: VisualNavigator, @@ -240,6 +264,7 @@ public final class DirectionalNavigationAdapter { ) keyboardPolicy = KeyboardPolicy() self.animatedTransition = animatedTransition + onNavigation = {} } @available(*, deprecated, message: "Use `bind(to:)` instead of notifying the event yourself. See the migration guide.") From 19d03a4ff3de80d22f45a94a3647c93d081a1659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 10 Nov 2025 18:21:37 +0100 Subject: [PATCH 04/55] Add `DragPointerObserver` (#670) --- CHANGELOG.md | 1 + .../Input/Pointer/DragPointerObserver.swift | 138 ++++++++++++++++++ Support/Carthage/.xcodegen | 1 + .../Readium.xcodeproj/project.pbxproj | 4 + 4 files changed, 144 insertions(+) create mode 100644 Sources/Navigator/Input/Pointer/DragPointerObserver.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f90a8ee26a..3f8aa9f523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. Take a look #### Navigator +* Added `DragPointerObserver` to recognize drag gestures with pointer events. * Added `DirectionalNavigationAdapter.onNavigation` callback to be notified when a navigation action is triggered. * This callback is called before executing any navigation action. * Useful for hiding UI elements when the user navigates, or implementing analytics. diff --git a/Sources/Navigator/Input/Pointer/DragPointerObserver.swift b/Sources/Navigator/Input/Pointer/DragPointerObserver.swift new file mode 100644 index 0000000000..300789c827 --- /dev/null +++ b/Sources/Navigator/Input/Pointer/DragPointerObserver.swift @@ -0,0 +1,138 @@ +// +// Copyright 2025 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 + +public extension InputObserving where Self == DragPointerObserver { + static func drag( + onStart: @MainActor @escaping (PointerEvent) -> Bool = { _ in false }, + onMove: @MainActor @escaping (PointerEvent) -> Bool = { _ in false }, + onEnd: @MainActor @escaping (PointerEvent) -> Bool = { _ in false }, + onCancel: @MainActor @escaping (PointerEvent) -> Bool = { _ in false } + ) -> DragPointerObserver { + DragPointerObserver( + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + } +} + +/// Pointer observer recognizing drag gestures. +@MainActor public final class DragPointerObserver: InputObserving { + private let onStart: @MainActor (PointerEvent) -> Bool + private let onMove: @MainActor (PointerEvent) -> Bool + private let onEnd: @MainActor (PointerEvent) -> Bool + private let onCancel: @MainActor (PointerEvent) -> Bool + + public init( + onStart: @MainActor @escaping (PointerEvent) -> Bool, + onMove: @MainActor @escaping (PointerEvent) -> Bool, + onEnd: @MainActor @escaping (PointerEvent) -> Bool, + onCancel: @MainActor @escaping (PointerEvent) -> Bool + ) { + self.onStart = onStart + self.onMove = onMove + self.onEnd = onEnd + self.onCancel = onCancel + } + + private var state: State = .idle + + private enum State { + case idle + case pending(id: AnyHashable, startLocation: CGPoint) + case dragging(id: AnyHashable, lastEvent: PointerEvent) + case failed(activePointers: Set) + } + + private enum Action { + case start(PointerEvent) + case move(PointerEvent) + case end(PointerEvent) + case cancel(PointerEvent) + case none + } + + public func didReceive(_ event: KeyEvent) async -> Bool { + false + } + + public func didReceive(_ event: PointerEvent) async -> Bool { + let (newState, action) = transition(state: state, event: event) + state = newState + + switch action { + case let .start(event): + return onStart(event) + case let .move(event): + return onMove(event) + case let .end(event): + return onEnd(event) + case let .cancel(event): + return onCancel(event) + case .none: + return false + } + } + + private func transition(state: State, event: PointerEvent) -> (State, Action) { + let id = event.pointer.id + + switch (state, event.phase) { + case (.idle, .down): + return (.pending(id: id, startLocation: event.location), .none) + + case let (.pending(pendingID, _), .down) where pendingID != id: + return (.failed(activePointers: [pendingID, id]), .none) + + case let (.pending(pendingID, _), .cancel) where pendingID == id: + return (.idle, .none) + + case let (.pending(pendingID, startLocation), .move) where pendingID == id: + // Check if pointer has moved enough to start dragging. + if abs(startLocation.x - event.location.x) > 1 || abs(startLocation.y - event.location.y) > 1 { + return (.dragging(id: pendingID, lastEvent: event), .start(event)) + } else { + return (.pending(id: pendingID, startLocation: startLocation), .none) + } + + case let (.pending(pendingID, _), .up) where pendingID == id: + // Pointer went up without moving - this is a tap, not a drag. + return (.idle, .none) + + case let (.dragging(draggingID, lastEvent), .down) where draggingID != id: + // Second pointer detected during drag - cancel the drag + return (.failed(activePointers: [draggingID, id]), .cancel(lastEvent)) + + case let (.dragging(draggingID, lastEvent), .cancel) where draggingID == id: + return (.idle, .cancel(lastEvent)) + + case let (.dragging(draggingID, _), .move) where draggingID == id: + return (.dragging(id: draggingID, lastEvent: event), .move(event)) + + case let (.dragging(draggingID, _), .up) where draggingID == id: + return (.idle, .end(event)) + + case var (.failed(activePointers), .down): + activePointers.insert(id) + return (.failed(activePointers: activePointers), .none) + + case var (.failed(activePointers), .up), + var (.failed(activePointers), .cancel): + activePointers.remove(id) + if activePointers.isEmpty { + return (.idle, .none) + } else { + return (.failed(activePointers: activePointers), .none) + } + + default: + return (state, .none) + } + } +} diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 97c84c06da..39fd18900c 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -532,6 +532,7 @@ ../../Sources/Navigator/Input/Key/KeyObserver.swift ../../Sources/Navigator/Input/Pointer ../../Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift +../../Sources/Navigator/Input/Pointer/DragPointerObserver.swift ../../Sources/Navigator/Input/Pointer/PointerEvent.swift ../../Sources/Navigator/Navigator.swift ../../Sources/Navigator/PDF diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index bd1cdf1d49..e83acbe46f 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 0B9AC6EF44DA518E9F37FB49 /* ContentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E809378D79D09192A0AAE1 /* ContentService.swift */; }; 0BFCDAEC82CFF09AFC53A5D0 /* LCPDFTableOfContentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94414130EC3731CD9920F27D /* LCPDFTableOfContentsService.swift */; }; 0C038E3525BB600EF6815EB9 /* ReadiumFuzi.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2828D89EBB52CCA782ED1146 /* ReadiumFuzi.xcframework */; }; + 0D13BEAB1495151C30D87B41 /* DragPointerObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24FF3141286A0CF40643D32D /* DragPointerObserver.swift */; }; 0ECE94F27E005FC454EA9D12 /* DecorableNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626CFFF131E0E840B76428F1 /* DecorableNavigator.swift */; }; 0F1AAB56A6ADEDDE2AD7E41E /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1039900AC78465AD989D7464 /* Content.swift */; }; 1004CE1C72C85CC3702C09C0 /* Asset.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC811653B33761089E270C4A /* Asset.swift */; }; @@ -540,6 +541,7 @@ 21944E1DABB61C2CF2EA89C5 /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; 230985A228FA74F24735D6BB /* LCPRenewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPRenewDelegate.swift; sourceTree = ""; }; 239A56BB0E6DAF17E0A13447 /* CBZNavigatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CBZNavigatorViewController.swift; sourceTree = ""; }; + 24FF3141286A0CF40643D32D /* DragPointerObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragPointerObserver.swift; sourceTree = ""; }; 251275D0DF87F85158A5FEA9 /* Assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Assets; path = ../../Sources/Navigator/EPUB/Assets; sourceTree = SOURCE_ROOT; }; 258351CE21165EDED7F87878 /* URLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocol.swift; sourceTree = ""; }; 2732AFC91AB15FA09C60207A /* Locator+Audio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+Audio.swift"; sourceTree = ""; }; @@ -2050,6 +2052,7 @@ isa = PBXGroup; children = ( 8F485F9F15CF41925D2D3D5C /* ActivatePointerObserver.swift */, + 24FF3141286A0CF40643D32D /* DragPointerObserver.swift */, F76073E8E6DACE7F9D22E0DD /* PointerEvent.swift */, ); path = Pointer; @@ -2391,6 +2394,7 @@ 6BE745329D68EE0533E42D14 /* DiffableDecoration+HTML.swift in Sources */, A2B9CE5A5A7F999B4D849C1F /* DiffableDecoration.swift in Sources */, 8029C2773AF704561B09BA99 /* DirectionalNavigationAdapter.swift in Sources */, + 0D13BEAB1495151C30D87B41 /* DragPointerObserver.swift in Sources */, 2E518C960D386F13E0A5E9B7 /* EPUBFixedSpreadView.swift in Sources */, B912ABB7DE8FC1A7A8EC1D84 /* EPUBNavigatorViewController.swift in Sources */, 9DB9674C11DF356966CBFA79 /* EPUBNavigatorViewModel.swift in Sources */, From 35b5072c2c2bbef0f4af6c25f9bc81ddced65860 Mon Sep 17 00:00:00 2001 From: Shane Friedman Date: Mon, 24 Nov 2025 04:41:29 -0500 Subject: [PATCH 05/55] Allow async `onCreatePublication` callbacks (#673) --- CHANGELOG.md | 6 ++++++ Sources/Shared/Publication/Publication.swift | 6 +++--- Sources/Streamer/PublicationOpener.swift | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8aa9f523..2955e69ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ All notable changes to this project will be documented in this file. Take a look * This callback is called before executing any navigation action. * Useful for hiding UI elements when the user navigates, or implementing analytics. +### Changed + +#### Streamer + +* Support for asynchronous callbacks with `onCreatePublication` (contributed by [@smoores-dev](https://github.com/readium/swift-toolkit/pull/673)). + ## [3.5.0] diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index daf5c44e98..6045c81232 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -169,7 +169,7 @@ public class Publication: Closeable, Loggable { _ manifest: inout Manifest, _ container: inout Container, _ services: inout PublicationServicesBuilder - ) -> Void + ) async -> Void private var manifest: Manifest private var container: Container @@ -185,12 +185,12 @@ public class Publication: Closeable, Loggable { self.servicesBuilder = servicesBuilder } - public mutating func apply(_ transform: Transform?) { + public mutating func apply(_ transform: Transform?) async { guard let transform = transform else { return } - transform(&manifest, &container, &servicesBuilder) + await transform(&manifest, &container, &servicesBuilder) } /// Builds the `Publication` from its parts. diff --git a/Sources/Streamer/PublicationOpener.swift b/Sources/Streamer/PublicationOpener.swift index 2af9d56708..f9db6c90e5 100644 --- a/Sources/Streamer/PublicationOpener.swift +++ b/Sources/Streamer/PublicationOpener.swift @@ -93,7 +93,7 @@ public class PublicationOpener { switch await parser.parse(asset: asset, warnings: warnings) { case var .success(builder): for transform in builderTransforms { - builder.apply(transform) + await builder.apply(transform) } return .success(builder.build()) From bd3b3f0a34e440738ded81ce5e7f4e54ff1cdb20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 28 Nov 2025 18:16:18 +0100 Subject: [PATCH 06/55] Fix crash when attempting to LCP decrypt an unencrypted resource (#674) --- CHANGELOG.md | 6 ++++++ .../LCP/Content Protection/LCPDecryptor.swift | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2955e69ea5..455406384e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ All notable changes to this project will be documented in this file. Take a look * Support for asynchronous callbacks with `onCreatePublication` (contributed by [@smoores-dev](https://github.com/readium/swift-toolkit/pull/673)). +### Fixed + +#### LCP + +* Fixed crash when an EPUB resource is declared as LCP-encrypted in the manifest but contains unencrypted data. + ## [3.5.0] diff --git a/Sources/LCP/Content Protection/LCPDecryptor.swift b/Sources/LCP/Content Protection/LCPDecryptor.swift index 7025ba84a6..79846f525b 100644 --- a/Sources/LCP/Content Protection/LCPDecryptor.swift +++ b/Sources/LCP/Content Protection/LCPDecryptor.swift @@ -9,7 +9,6 @@ import ReadiumInternal import ReadiumShared private let lcpScheme = "http://readium.org/2014/01/lcp" -private let AESBlockSize: UInt64 = 16 // bytes /// Decrypts a resource protected with LCP. final class LCPDecryptor { @@ -117,7 +116,7 @@ final class LCPDecryptor { guard let length = length else { return failure(.requiredEstimatedLength) } - guard length >= 2 * AESBlockSize else { + guard length.isValidAESChunk else { return failure(.invalidCBCData) } @@ -207,6 +206,10 @@ final class LCPDecryptor { private extension LCPLicense { func decryptFully(data: ReadResult, isDeflated: Bool) async -> ReadResult { data.flatMap { + guard UInt64($0.count).isValidAESChunk else { + return .failure(.decoding(LCPDecryptor.Error.invalidCBCData)) + } + do { // Decrypts the resource. guard var data = try self.decipher($0) else { @@ -242,3 +245,15 @@ private extension ReadiumShared.Encryption { algorithm == "http://www.w3.org/2001/04/xmlenc#aes256-cbc" } } + +private let AESBlockSize: UInt64 = 16 // bytes + +private extension UInt64 { + /// Checks if this number is a valid CBC length - i.e. a multiple of AES + /// block size and at least 2 blocks (IV + data). + /// If not, the file is likely not actually encrypted despite being declared + /// as such. + var isValidAESChunk: Bool { + self >= 2 * AESBlockSize && self % AESBlockSize == 0 + } +} From db12e1ed468e166ddc6de11af0d417beb0c2de69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 1 Dec 2025 18:57:42 +0100 Subject: [PATCH 07/55] Fix FXL auto spread settings after rotating the device (#675) --- CHANGELOG.md | 4 +++ .../EPUB/EPUBNavigatorViewController.swift | 34 +++++++++++-------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 455406384e..035f6820ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ All notable changes to this project will be documented in this file. Take a look ### Fixed +#### Navigator + +* Fixed EPUB fixed-layout spread settings not updating after device rotation when the app was in the background. + #### LCP * Fixed crash when an EPUB resource is declared as LCP-encrypted in the manifest but contains unencrypted data. diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 55ffe5b318..aab8d1393f 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -390,6 +390,12 @@ open class EPUBNavigatorViewController: InputObservableViewController, @objc private func didBecomeActive() { isActive = true + // The device may have rotated since the last time the app was active. + // We may need to refresh the spreads in this situation. Unfortunately, + // the `viewWillTransition(to:with:)` API is called before we receive + // the `didBecomeActive` notification, so we cannot rely on it here. + viewModel.viewSizeWillChange(view.bounds.size) + if needsReloadSpreadsOnActive { needsReloadSpreadsOnActive = false reloadSpreads(force: true) @@ -449,10 +455,8 @@ open class EPUBNavigatorViewController: InputObservableViewController, override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) - viewModel.viewSizeWillChange(size) - - coordinator.animate(alongsideTransition: nil) { [weak self] _ in - self?.reloadSpreads(force: false) + if isActive { + viewModel.viewSizeWillChange(size) } } @@ -566,12 +570,20 @@ open class EPUBNavigatorViewController: InputObservableViewController, private func reloadSpreads(force: Bool) { guard state != .initializing, - isViewLoaded, - isActive + isViewLoaded else { return } + guard isActive else { + // If we reload the spreads while the app is in the background, the + // web view will reset to progression 0 instead of the current one. + // We need to wait for the application to return to the foreground + // to maintain the current location. + needsReloadSpreadsOnActive = true + return + } + _reloadSpreads(force: force) } @@ -1230,15 +1242,7 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { } func spreadViewDidTerminate() { - if !isActive { - // If we reload the spreads while the app is in the background, the - // web view will reset to progression 0 instead of the current one. - // We need to wait for the application to return to the foreground - // to maintain the current location. - needsReloadSpreadsOnActive = true - } else { - reloadSpreads(force: true) - } + reloadSpreads(force: true) } } From fae59aa05aa67c6328d47fabc7d9990963ddb11b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 2 Dec 2025 17:55:29 +0100 Subject: [PATCH 08/55] PDF: Fix zoom in paginated spread mode and add swipe gestures (#678) --- CHANGELOG.md | 8 +++ .../PDF/PDFNavigatorViewController.swift | 67 +++++++++++++++---- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 035f6820ed..518fc35adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ All notable changes to this project will be documented in this file. Take a look * Added `DirectionalNavigationAdapter.onNavigation` callback to be notified when a navigation action is triggered. * This callback is called before executing any navigation action. * Useful for hiding UI elements when the user navigates, or implementing analytics. +* Added swipe gesture support for navigating in PDF paginated spread mode. + +### Deprecated + +#### Navigator + +* `PDFNavigatorViewController.scalesDocumentToFit` is now deprecated and non-functional. The navigator always scales the document to fit the viewport. ### Changed @@ -24,6 +31,7 @@ All notable changes to this project will be documented in this file. Take a look #### Navigator * Fixed EPUB fixed-layout spread settings not updating after device rotation when the app was in the background. +* Fixed zoom-to-fit scaling in PDF paginated spread mode when `offsetFirstPage` is enabled. #### LCP diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 93ac53efe7..f2207ea48c 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -57,7 +57,8 @@ open class PDFNavigatorViewController: } /// Whether the pages is always scaled to fit the screen, unless the user zoomed in. - public var scalesDocumentToFit = true + @available(*, unavailable, message: "This API is deprecated") + public var scalesDocumentToFit: Bool { true } public weak var delegate: PDFNavigatorDelegate? public private(set) var pdfView: PDFDocumentView? @@ -76,6 +77,8 @@ open class PDFNavigatorViewController: // Holds a reference to make sure they are not garbage-collected. private var tapGestureController: PDFTapGestureController? private var clickGestureController: PDFTapGestureController? + private var swipeLeftGestureRecognizer: UISwipeGestureRecognizer? + private var swipeRightGestureRecognizer: UISwipeGestureRecognizer? private let server: HTTPServer? private let publicationEndpoint: HTTPServerEndpoint? @@ -184,7 +187,7 @@ open class PDFNavigatorViewController: super.viewWillAppear(animated) // Hack to layout properly the first page when opening the PDF. - if let pdfView = pdfView, scalesDocumentToFit { + if let pdfView = pdfView { pdfView.scaleFactor = pdfView.minScaleFactor if let page = pdfView.currentPage { pdfView.go(to: page.bounds(for: pdfView.displayBox), on: page) @@ -195,14 +198,12 @@ open class PDFNavigatorViewController: override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) - if let pdfView = pdfView, scalesDocumentToFit { - // Makes sure that the PDF is always properly scaled down when rotating the screen, if the user didn't zoom in. + if let pdfView = pdfView { + // Makes sure that the PDF is always properly scaled down when + // rotating the screen, if the user didn't zoom in. let isAtMinScaleFactor = (pdfView.scaleFactor == pdfView.minScaleFactor) coordinator.animate(alongsideTransition: { _ in - self.updateScaleFactors() - if isAtMinScaleFactor { - pdfView.scaleFactor = pdfView.minScaleFactor - } + self.updateScaleFactors(zoomToFit: isAtMinScaleFactor) // Reset the PDF view to update the spread if needed. if self.settings.spread == .auto { @@ -263,11 +264,14 @@ open class PDFNavigatorViewController: target: self, action: #selector(didClick) ) + swipeLeftGestureRecognizer = recognizeSwipe(in: pdfView, direction: .left) + swipeRightGestureRecognizer = recognizeSwipe(in: pdfView, direction: .right) apply(settings: settings, to: pdfView) delegate?.navigator(self, setupPDFView: pdfView) NotificationCenter.default.addObserver(self, selector: #selector(pageDidChange), name: .PDFViewPageChanged, object: pdfView) + NotificationCenter.default.addObserver(self, selector: #selector(visiblePagesDidChange), name: .PDFViewVisiblePagesChanged, object: pdfView) NotificationCenter.default.addObserver(self, selector: #selector(selectionDidChange), name: .PDFViewSelectionChanged, object: pdfView) if let locator = locator { @@ -328,7 +332,7 @@ open class PDFNavigatorViewController: pdfView.displaysRTL = isRTL pdfView.displaysPageBreaks = true - pdfView.autoScales = !scalesDocumentToFit + pdfView.autoScales = false if let scrollView = pdfView.firstScrollView { let showScrollbar = settings.visibleScrollbar @@ -341,6 +345,10 @@ open class PDFNavigatorViewController: } pdfView.backgroundColor = settings.backgroundColor?.uiColor ?? pdfViewDefaultBackgroundColor + + let enableSwipes = !settings.scroll && spread + swipeLeftGestureRecognizer?.isEnabled = enableSwipes + swipeRightGestureRecognizer?.isEnabled = enableSwipes } @objc private func didTap(_ gesture: UITapGestureRecognizer) { @@ -367,6 +375,25 @@ open class PDFNavigatorViewController: delegate?.navigator(self, didTapAt: location) } + private func recognizeSwipe(in view: UIView, direction: UISwipeGestureRecognizer.Direction) -> UISwipeGestureRecognizer { + let recognizer = UISwipeGestureRecognizer(target: self, action: #selector(didSwipe)) + recognizer.direction = direction + recognizer.numberOfTouchesRequired = 1 + view.addGestureRecognizer(recognizer) + return recognizer + } + + @objc private func didSwipe(_ gesture: UISwipeGestureRecognizer) { + switch gesture.direction { + case .left: + Task { await goRight(options: .animated) } + case .right: + Task { await goLeft(options: .animated) } + default: + break + } + } + @objc private func pageDidChange() { guard let locator = currentPosition else { return @@ -374,6 +401,14 @@ open class PDFNavigatorViewController: delegate?.navigator(self, locationDidChange: locator) } + @objc private func visiblePagesDidChange() { + // In paginated mode, we want to refresh the scale factors to properly + // fit the newly visible pages. + if !settings.scroll { + updateScaleFactors(zoomToFit: true) + } + } + @discardableResult private func go(to locator: Locator, isJump: Bool) async -> Bool { let locator = publication.normalizeLocator(locator) @@ -426,7 +461,7 @@ open class PDFNavigatorViewController: currentResourceIndex = index documentHolder.set(document, at: href) pdfView.document = document - updateScaleFactors() + updateScaleFactors(zoomToFit: true) } guard let document = pdfView.document else { @@ -446,12 +481,20 @@ open class PDFNavigatorViewController: return true } - private func updateScaleFactors() { - guard let pdfView = pdfView, scalesDocumentToFit else { + /// Updates the scale factors to match the currently visible pages. + /// + /// - Parameter zoomToFit: When true, the document will be zoomed to fit the + /// visible pages. + private func updateScaleFactors(zoomToFit: Bool) { + guard let pdfView = pdfView else { return } pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit pdfView.maxScaleFactor = 4.0 + + if zoomToFit { + pdfView.scaleFactor = pdfView.minScaleFactor + } } private func pageNumber(for locator: Locator) -> Int? { From e49966e2be29e23c45e1a288aa956efc119db8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 4 Dec 2025 17:18:33 +0100 Subject: [PATCH 09/55] Add PDF `fit` preference (#680) --- CHANGELOG.md | 11 + Sources/Navigator/PDF/PDFDocumentView.swift | 227 +++++++++++++++++- .../PDF/PDFNavigatorViewController.swift | 25 +- .../PDF/Preferences/PDFPreferences.swift | 6 + .../Preferences/PDFPreferencesEditor.swift | 12 + .../PDF/Preferences/PDFSettings.swift | 8 + Sources/Navigator/Preferences/Types.swift | 11 +- .../Common/Preferences/UserPreferences.swift | 6 +- 8 files changed, 291 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 518fc35adb..959d97d764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file. Take a look * This callback is called before executing any navigation action. * Useful for hiding UI elements when the user navigates, or implementing analytics. * Added swipe gesture support for navigating in PDF paginated spread mode. +* Added `fit` preference for PDF documents to control how pages are scaled within the viewport. + * Only effective in scroll mode. Paginated mode always uses page fit due to PDFKit limitations. ### Deprecated @@ -26,6 +28,15 @@ All notable changes to this project will be documented in this file. Take a look * Support for asynchronous callbacks with `onCreatePublication` (contributed by [@smoores-dev](https://github.com/readium/swift-toolkit/pull/673)). +#### Navigator + +* The `Fit` enum has been redesigned to fit the PDF implementation. + * **Breaking change:** Update any code using the old `Fit` enum values. +* The PDF navigator's content inset behavior has changed: + * iPhone: Continues to apply window safe area insets (to account for notch/Dynamic Island). + * iPad/macOS: Now displays edge-to-edge with no automatic safe area insets. + * You can customize this behavior with `VisualNavigatorDelegate.navigatorContentInset(_:)`. + ### Fixed #### Navigator diff --git a/Sources/Navigator/PDF/PDFDocumentView.swift b/Sources/Navigator/PDF/PDFDocumentView.swift index d8de952200..11e9cf70e5 100644 --- a/Sources/Navigator/PDF/PDFDocumentView.swift +++ b/Sources/Navigator/PDF/PDFDocumentView.swift @@ -50,11 +50,31 @@ public final class PDFDocumentView: PDFView { } private func updateContentInset() { - let insets = documentViewDelegate?.pdfDocumentViewContentInset(self) ?? window?.safeAreaInsets ?? .zero + let insets = contentInset firstScrollView?.contentInset.top = insets.top firstScrollView?.contentInset.bottom = insets.bottom } + private var contentInset: UIEdgeInsets { + if let contentInset = documentViewDelegate?.pdfDocumentViewContentInset(self) { + return contentInset + } + + // We apply the window's safe area insets (representing the system + // status bar, but ignoring app bars) on iPhones only because in most + // cases we prefer to display the content edge-to-edge. + // iPhones are a special case because they are the only devices with a + // physical notch (or Dynamic Island) which is included in the window's + // safe area insets. Therefore, we must always take it into account to + // avoid hiding the content. + if UIDevice.current.userInterfaceIdiom == .phone { + return window?.safeAreaInsets ?? .zero + } else { + // Edge-to-edge on macOS and iPadOS. + return .zero + } + } + override public func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { super.canPerformAction(action, withSender: sender) && editingActions.canPerformAction(action) } @@ -70,4 +90,209 @@ public final class PDFDocumentView: PDFView { editingActions.buildMenu(with: builder) super.buildMenu(with: builder) } + + var isPaginated: Bool { + isUsingPageViewController || displayMode == .twoUp || displayMode == .singlePage + } + + var isSpreadEnabled: Bool { + displayMode == .twoUp || displayMode == .twoUpContinuous + } + + /// Returns whether the document is currently zoomed to match the given + /// `fit`. + func isAtScaleFactor(for fit: Fit) -> Bool { + let scaleFactorToFit = scaleFactor(for: fit) + // 1% tolerance for floating point comparison + let tolerance: CGFloat = 0.01 + return abs(scaleFactor - scaleFactorToFit) < tolerance + } + + /// Calculates the appropriate scale factor based on the fit preference. + /// + /// Only used in scroll mode, as the paginated mode doesn't support custom + /// scale factors without visual hiccups when swiping pages. + func scaleFactor(for fit: Fit) -> CGFloat { + // While a `width` fit works in scroll mode, the pagination mode has + // critical limitations when zooming larger than the page fit, so it + // does not support a `width` fit. + // + // - Visual snap: There is no API to pre-set the zoom scale for the next + // page. PDFView resets the scale per page, causing a visible snap + // when swiping. We don’t see the issue with edge taps. + // - Incorrect anchoring: When zooming larger than the page fit, the + // viewport centers vertically instead of showing the top. The API to + // fix this works in scroll mode but is ignored in paginated mode. + // + // So we only support a `page` fit in paginated mode. + if isPaginated { + return scaleFactorForSizeToFitVisiblePages + } + + switch fit { + case .auto, .width: + // Use PDFKit's default auto-fit behavior + return scaleFactorForSizeToFit + case .page: + return scaleFactorForLargestPage + } + } + + /// Calculates the scale factor to fit the visible pages (by area) to the + /// viewport. + private var scaleFactorForSizeToFitVisiblePages: CGFloat { + // The native `scaleFactorForSizeToFit` is incorrect when displaying + // paginated spreads, so we need to use a custom implementation. + if !isPaginated || !isSpreadEnabled { + scaleFactorForSizeToFit + } else { + calculateScale( + for: spreadSize(for: visiblePages), + viewSize: bounds.size, + insets: contentInset + ) + } + } + + /// Calculates the scale factor to fit the largest page or spread (by area) + /// to the viewport. + private var scaleFactorForLargestPage: CGFloat { + guard let document = document else { + return 1.0 + } + + // Check cache before expensive calculation + let viewSize = bounds.size + let insets = contentInset + if + let cached = cachedScaleFactorForLargestPage, + cached.document == ObjectIdentifier(document), + cached.viewSize == viewSize, + cached.contentInset == insets, + cached.spread == isSpreadEnabled, + cached.displaysAsBook == displaysAsBook + { + return cached.scaleFactor + } + + var maxSize: CGSize = .zero + var maxArea: CGFloat = 0 + + if !isSpreadEnabled { + // No spreads: find largest individual page + for pageIndex in 0 ..< document.pageCount { + guard let page = document.page(at: pageIndex) else { continue } + let pageSize = page.bounds(for: displayBox).size + let area = pageSize.width * pageSize.height + + if area > maxArea { + maxArea = area + maxSize = pageSize + } + } + } else { + // Spreads enabled: find largest spread + let pageCount = document.pageCount + + if displaysAsBook, pageCount > 0 { + // First page displayed alone - check its size + if let firstPage = document.page(at: 0) { + let firstSize = firstPage.bounds(for: displayBox).size + let firstArea = firstSize.width * firstSize.height + if firstArea > maxArea { + maxArea = firstArea + maxSize = firstSize + } + } + } + + // Check spreads (pairs of pages) + let startIndex = displaysAsBook ? 1 : 0 + for pageIndex in stride(from: startIndex, to: pageCount, by: 2) { + let leftIndex = pageIndex + let rightIndex = pageIndex + 1 + + guard let leftPage = document.page(at: leftIndex) else { continue } + + if rightIndex < pageCount, let rightPage = document.page(at: rightIndex) { + // Two-page spread + let currentSpreadSize = spreadSize(for: [leftPage, rightPage]) + let spreadArea = currentSpreadSize.width * currentSpreadSize.height + + if spreadArea > maxArea { + maxArea = spreadArea + maxSize = currentSpreadSize + } + } else { + // Last page alone (odd page count) + let leftSize = leftPage.bounds(for: displayBox).size + let singleArea = leftSize.width * leftSize.height + if singleArea > maxArea { + maxArea = singleArea + maxSize = leftSize + } + } + } + } + + let scale = calculateScale( + for: maxSize, + viewSize: viewSize, + insets: insets + ) + + cachedScaleFactorForLargestPage = ( + document: ObjectIdentifier(document), + scaleFactor: scale, + viewSize: viewSize, + contentInset: insets, + spread: isSpreadEnabled, + displaysAsBook: displaysAsBook + ) + return scale + } + + /// Cache for expensive largest page scale calculation. + private var cachedScaleFactorForLargestPage: ( + document: ObjectIdentifier, + scaleFactor: CGFloat, + viewSize: CGSize, + contentInset: UIEdgeInsets, + spread: Bool, + displaysAsBook: Bool + )? + + /// Calculates the combined size of pages laid out side-by-side horizontally. + private func spreadSize(for pages: [PDFPage]) -> CGSize { + var size = CGSize.zero + for page in pages { + let pageBounds = page.bounds(for: displayBox) + size.height = max(size.height, pageBounds.height) + size.width += pageBounds.width + } + return size + } + + /// Calculates the scale factor needed to fit the given content size within + /// the available viewport, accounting for content insets. + private func calculateScale( + for contentSize: CGSize, + viewSize: CGSize, + insets: UIEdgeInsets + ) -> CGFloat { + guard contentSize.width > 0, contentSize.height > 0 else { + return 1.0 + } + + let availableSize = CGSize( + width: viewSize.width - insets.left - insets.right, + height: viewSize.height - insets.top - insets.bottom + ) + + let widthScale = availableSize.width / contentSize.width + let heightScale = availableSize.height / contentSize.height + + // Use the smaller scale to ensure both dimensions fit + return min(widthScale, heightScale) + } } diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index f2207ea48c..527c54999a 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -199,11 +199,12 @@ open class PDFNavigatorViewController: super.viewWillTransition(to: size, with: coordinator) if let pdfView = pdfView { - // Makes sure that the PDF is always properly scaled down when - // rotating the screen, if the user didn't zoom in. - let isAtMinScaleFactor = (pdfView.scaleFactor == pdfView.minScaleFactor) + // Makes sure that the PDF is always properly scaled when rotating + // the screen, if the user didn't set a custom zoom. + let isAtScaleFactor = pdfView.isAtScaleFactor(for: settings.fit) + coordinator.animate(alongsideTransition: { _ in - self.updateScaleFactors(zoomToFit: isAtMinScaleFactor) + self.updateScaleFactors(zoomToFit: isAtScaleFactor) // Reset the PDF view to update the spread if needed. if self.settings.spread == .auto { @@ -403,7 +404,8 @@ open class PDFNavigatorViewController: @objc private func visiblePagesDidChange() { // In paginated mode, we want to refresh the scale factors to properly - // fit the newly visible pages. + // fit the newly visible pages. This is especially important for + // paginated spreads. if !settings.scroll { updateScaleFactors(zoomToFit: true) } @@ -489,11 +491,20 @@ open class PDFNavigatorViewController: guard let pdfView = pdfView else { return } - pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit + + let scaleFactorToFit = pdfView.scaleFactor(for: settings.fit) + + if settings.scroll { + // Allow zooming out to 25% in scroll mode. + pdfView.minScaleFactor = 0.25 + } else { + pdfView.minScaleFactor = scaleFactorToFit + } + pdfView.maxScaleFactor = 4.0 if zoomToFit { - pdfView.scaleFactor = pdfView.minScaleFactor + pdfView.scaleFactor = scaleFactorToFit } } diff --git a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift index 8c32120247..5a61ea2e66 100644 --- a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift +++ b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift @@ -14,6 +14,9 @@ public struct PDFPreferences: ConfigurablePreferences { /// Background color behind the document pages. public var backgroundColor: Color? + /// Method for fitting the pages within the viewport. + public var fit: Fit? + /// Indicates if the first page should be displayed in its own spread. public var offsetFirstPage: Bool? @@ -41,6 +44,7 @@ public struct PDFPreferences: ConfigurablePreferences { public init( backgroundColor: Color? = nil, + fit: Fit? = nil, offsetFirstPage: Bool? = nil, pageSpacing: Double? = nil, readingProgression: ReadingProgression? = nil, @@ -51,6 +55,7 @@ public struct PDFPreferences: ConfigurablePreferences { ) { precondition(pageSpacing == nil || pageSpacing! >= 0) self.backgroundColor = backgroundColor + self.fit = fit self.offsetFirstPage = offsetFirstPage self.pageSpacing = pageSpacing self.readingProgression = readingProgression @@ -63,6 +68,7 @@ public struct PDFPreferences: ConfigurablePreferences { public func merging(_ other: PDFPreferences) -> PDFPreferences { PDFPreferences( backgroundColor: other.backgroundColor ?? backgroundColor, + fit: other.fit ?? fit, offsetFirstPage: other.offsetFirstPage ?? offsetFirstPage, pageSpacing: other.pageSpacing ?? pageSpacing, readingProgression: other.readingProgression ?? readingProgression, diff --git a/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift b/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift index 28d1f9e256..71a15de0af 100644 --- a/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift +++ b/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift @@ -32,6 +32,18 @@ public final class PDFPreferencesEditor: StatefulPreferencesEditor = + enumPreference( + preference: \.fit, + setting: \.fit, + defaultEffectiveValue: defaults.fit ?? .auto, + isEffective: { $0.settings.scroll }, + supportedValues: [.auto, .page, .width] + ) + /// Indicates if the first page should be displayed in its own spread. /// /// Only effective when `spread` is not off. diff --git a/Sources/Navigator/PDF/Preferences/PDFSettings.swift b/Sources/Navigator/PDF/Preferences/PDFSettings.swift index 82ac91463a..c370f8cd38 100644 --- a/Sources/Navigator/PDF/Preferences/PDFSettings.swift +++ b/Sources/Navigator/PDF/Preferences/PDFSettings.swift @@ -13,6 +13,7 @@ import ReadiumShared /// See `PDFPreferences` public struct PDFSettings: ConfigurableSettings { public let backgroundColor: Color? + public let fit: Fit public let offsetFirstPage: Bool public let pageSpacing: Double public let readingProgression: ReadingProgression @@ -25,6 +26,10 @@ public struct PDFSettings: ConfigurableSettings { backgroundColor = preferences.backgroundColor ?? defaults.backgroundColor + fit = preferences.fit + ?? defaults.fit + ?? .auto + offsetFirstPage = preferences.offsetFirstPage ?? defaults.offsetFirstPage ?? false @@ -64,6 +69,7 @@ public struct PDFSettings: ConfigurableSettings { /// See `PDFPreferences`. public struct PDFDefaults { public var backgroundColor: Color? + public var fit: Fit? public var offsetFirstPage: Bool? public var pageSpacing: Double? public var readingProgression: ReadingProgression? @@ -74,6 +80,7 @@ public struct PDFDefaults { public init( backgroundColor: Color? = nil, + fit: Fit? = nil, offsetFirstPage: Bool? = nil, pageSpacing: Double? = nil, readingProgression: ReadingProgression? = nil, @@ -83,6 +90,7 @@ public struct PDFDefaults { visibleScrollbar: Bool? = nil ) { self.backgroundColor = backgroundColor + self.fit = fit self.offsetFirstPage = offsetFirstPage self.pageSpacing = pageSpacing self.readingProgression = readingProgression diff --git a/Sources/Navigator/Preferences/Types.swift b/Sources/Navigator/Preferences/Types.swift index 18d6e1493f..ef8a923e87 100644 --- a/Sources/Navigator/Preferences/Types.swift +++ b/Sources/Navigator/Preferences/Types.swift @@ -58,12 +58,15 @@ extension ReadiumShared.ReadingProgression { } } -/// Method for constraining a resource inside the viewport. +/// Method for fitting the content within the viewport. public enum Fit: String, Codable, Hashable { - case cover - case contain + /// Use the best fitting strategy depending on the current settings and + /// content. + case auto + /// The content is scaled to fit both dimensions within the viewport. + case page + /// The content is scaled to fit the viewport width. case width - case height } /// Reader theme for reflowable documents. diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index 759b30341e..214f88e298 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -79,6 +79,7 @@ struct UserPreferences< case let editor as PDFPreferencesEditor: fixedLayoutUserPreferences( commit: commit, + fit: editor.fit, offsetFirstPage: editor.offsetFirstPage, pageSpacing: editor.pageSpacing, readingProgression: editor.readingProgression, @@ -272,10 +273,9 @@ struct UserPreferences< commit: commit, formatValue: { v in switch v { - case .cover: return "Cover" - case .contain: return "Contain" + case .auto: return "Auto" + case .page: return "Page" case .width: return "Width" - case .height: return "Height" } } ) From c1fd5b548f98617029ea0ea3b2ddf8d3e9b6803a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 5 Dec 2025 12:53:34 +0100 Subject: [PATCH 10/55] Add FXL EPUB `fit` preference (#681) --- CHANGELOG.md | 6 +- .../scripts/readium-fixed-wrapper-one.js | 2 +- .../scripts/readium-fixed-wrapper-two.js | 2 +- .../Navigator/EPUB/EPUBFixedSpreadView.swift | 4 +- .../EPUB/EPUBNavigatorViewController.swift | 15 +++- .../EPUB/EPUBNavigatorViewModel.swift | 1 + .../EPUB/Preferences/EPUBPreferences.swift | 10 +++ .../Preferences/EPUBPreferencesEditor.swift | 12 +++ .../EPUB/Preferences/EPUBSettings.swift | 9 ++ .../Navigator/EPUB/Scripts/src/fixed-page.js | 82 +++++++++++++++++-- .../Scripts/src/index-fixed-wrapper-one.js | 8 +- .../Scripts/src/index-fixed-wrapper-two.js | 58 +++++++------ .../Common/Preferences/UserPreferences.swift | 1 + 13 files changed, 170 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 959d97d764..97f3c743c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ All notable changes to this project will be documented in this file. Take a look * This callback is called before executing any navigation action. * Useful for hiding UI elements when the user navigates, or implementing analytics. * Added swipe gesture support for navigating in PDF paginated spread mode. -* Added `fit` preference for PDF documents to control how pages are scaled within the viewport. - * Only effective in scroll mode. Paginated mode always uses page fit due to PDFKit limitations. +* Added `fit` preference for fixed-layout publications (PDF and FXL EPUB) to control how pages are scaled within the viewport. + * In the PDF navigator, it is only effective in scroll mode. Paginated mode always uses `page` fit due to PDFKit limitations. ### Deprecated @@ -32,7 +32,7 @@ All notable changes to this project will be documented in this file. Take a look * The `Fit` enum has been redesigned to fit the PDF implementation. * **Breaking change:** Update any code using the old `Fit` enum values. -* The PDF navigator's content inset behavior has changed: +* The fixed-layout navigators (PDF and FXL EPUB)'s content inset behavior has changed: * iPhone: Continues to apply window safe area insets (to account for notch/Dynamic Island). * iPad/macOS: Now displays edge-to-edge with no automatic safe area insets. * You can customize this behavior with `VisualNavigatorDelegate.navigatorContentInset(_:)`. diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js index a1f95ec215..fbfbd2519e 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();var e=function(t){var e=null,n=null,i=null,o=document.getElementById("page");o.addEventListener("load",(function(){var t=o.contentWindow.document.querySelector("meta[name=viewport]");if(t){for(var n,i=/(\w+) *= *([^\s,]+)/g,l={};n=i.exec(t.content);)l[n[1]]=n[2];var a=Number.parseFloat(l.width),s=Number.parseFloat(l.height);a&&s&&(e={width:a,height:s},r())}}));var l=o.closest(".viewport");function r(){if(e&&n&&i){o.style.width=e.width+"px",o.style.height=e.height+"px",o.style.marginTop=i.top-i.bottom+"px";var t=n.width/e.width,l=n.height/e.height,r=Math.min(t,l);document.querySelector("meta[name=viewport]").content="initial-scale="+r+", minimum-scale="+r}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,o.addEventListener("load",(function i(){o.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,o.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),o.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,e=null,o.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return o.contentWindow.eval(t)},setViewport:function(t,e){n=t,i=e,r()},show:function(){l.style.display="block"},hide:function(){l.style.display="none"}}}();t.g.spread={load:function(t){0!==t.length&&e.load(t[0],(function(){webkit.messageHandlers.spreadLoaded.postMessage({})}))},eval:function(t,n){var i;if("#"===t||""===t||(null===(i=e.link)||void 0===i?void 0:i.href)===t)return e.eval(n)},setViewport:function(t,n){e.setViewport(t,n)}}})(); +(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};var i=function(t,i){var o=null,l=null,a=null,r=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,u=document.getElementById("page");u.addEventListener("load",(function(){var t=u.contentWindow.document.querySelector("meta[name=viewport]");if(t){for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var l=Number.parseFloat(i.width),a=Number.parseFloat(i.height);l&&a&&(o={width:l,height:a},d())}}));var c=u.closest(".viewport");function d(){if(o&&l&&a){u.style.width=o.width+"px",u.style.height=o.height+"px";var t,i=l.width/o.width,c=l.height/o.height;t=r===n.WIDTH?i:Math.min(i,c);var d=o.height*t,h=s===e.SINGLE||s===e.SPREAD_CENTER;if(r===n.WIDTH&&d>l.height)u.style.top=a.top+"px",u.style.transform=h?"translateX(-50%)":"none";else{var f=a.top-a.bottom;u.style.top="calc(50% + "+f+"px)",u.style.transform=h?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,u.addEventListener("load",(function i(){u.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,u.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),u.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,o=null,u.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return u.contentWindow.eval(t)},setViewport:function(t,e,i){l=t,a=e,Object.values(n).includes(i)&&(r=i),d()},show:function(){c.style.display="block"},hide:function(){c.style.display="none"}}}(0,e.SINGLE);t.g.spread={load:function(t){0!==t.length&&i.load(t[0],(function(){webkit.messageHandlers.spreadLoaded.postMessage({})}))},eval:function(t,e){var n;if("#"===t||""===t||(null===(n=i.link)||void 0===n?void 0:n.href)===t)return i.eval(e)},setViewport:function(t,e,n){i.setViewport(t,e,n)}}})(); //# sourceMappingURL=readium-fixed-wrapper-one.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js index 8ee445d82e..db8191ee78 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var t={};function e(t){var e=null,n=null,i=null,o=document.getElementById(t);o.addEventListener("load",(function(){var t=o.contentWindow.document.querySelector("meta[name=viewport]");if(t){for(var n,i=/(\w+) *= *([^\s,]+)/g,r={};n=i.exec(t.content);)r[n[1]]=n[2];var a=Number.parseFloat(r.width),s=Number.parseFloat(r.height);a&&s&&(e={width:a,height:s},l())}}));var r=o.closest(".viewport");function l(){if(e&&n&&i){o.style.width=e.width+"px",o.style.height=e.height+"px",o.style.marginTop=i.top-i.bottom+"px";var t=n.width/e.width,r=n.height/e.height,l=Math.min(t,r);document.querySelector("meta[name=viewport]").content="initial-scale="+l+", minimum-scale="+l}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,o.addEventListener("load",(function i(){o.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,o.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),o.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,e=null,o.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return o.contentWindow.eval(t)},setViewport:function(t,e){n=t,i=e,l()},show:function(){r.style.display="block"},hide:function(){r.style.display="none"}}}t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();var n={left:e("page-left"),right:e("page-right"),center:e("page-center")};function i(t){for(const e in n)t(n[e])}t.g.spread={load:function(t){function e(){n.left.isLoading||n.right.isLoading||n.center.isLoading||webkit.messageHandlers.spreadLoaded.postMessage({})}i((function(t){t.reset(),t.hide()}));for(const i in t){const o=t[i],r=n[o.page];r&&(r.show(),r.load(o,e))}},eval:function(t,e){if("#"===t||""===t)i((function(t){t.eval(e)}));else{var o=function(t){for(const o in n){var e,i=n[o];if((null===(e=i.link)||void 0===e?void 0:e.href)===t)return i}return null}(t);if(o)return o.eval(e)}},setViewport:function(t,e){t.width/=2,n.left.setViewport(t,{top:e.top,right:0,bottom:e.bottom,left:e.left}),n.right.setViewport(t,{top:e.top,right:e.right,bottom:e.bottom,left:0}),n.center.setViewport(t,{top:e.top,right:0,bottom:e.bottom,left:0})}}})(); +(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};function i(t,i){var o=null,r=null,l=null,a=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,c=document.getElementById(t);c.addEventListener("load",(function(){var t=c.contentWindow.document.querySelector("meta[name=viewport]");if(t){for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var r=Number.parseFloat(i.width),l=Number.parseFloat(i.height);r&&l&&(o={width:r,height:l},d())}}));var u=c.closest(".viewport");function d(){if(o&&r&&l){c.style.width=o.width+"px",c.style.height=o.height+"px";var t,i=r.width/o.width,u=r.height/o.height;t=a===n.WIDTH?i:Math.min(i,u);var d=o.height*t,f=s===e.SINGLE||s===e.SPREAD_CENTER;if(a===n.WIDTH&&d>r.height)c.style.top=l.top+"px",c.style.transform=f?"translateX(-50%)":"none";else{var h=l.top-l.bottom;c.style.top="calc(50% + "+h+"px)",c.style.transform=f?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,c.addEventListener("load",(function i(){c.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,c.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),c.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,o=null,c.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return c.contentWindow.eval(t)},setViewport:function(t,e,i){r=t,l=e,Object.values(n).includes(i)&&(a=i),d()},show:function(){u.style.display="block"},hide:function(){u.style.display="none"}}}var o={left:i("page-left",e.SPREAD_LEFT),right:i("page-right",e.SPREAD_RIGHT),center:i("page-center",e.SPREAD_CENTER)};function r(t){for(const e in o)t(o[e])}t.g.spread={load:function(t){function e(){o.left.isLoading||o.right.isLoading||o.center.isLoading||webkit.messageHandlers.spreadLoaded.postMessage({})}r((function(t){t.reset(),t.hide()}));for(const n in t){const i=t[n],r=o[i.page];r&&(r.show(),r.load(i,e))}},eval:function(t,e){if("#"===t||""===t)r((function(t){t.eval(e)}));else{var n=function(t){for(const i in o){var e,n=o[i];if((null===(e=n.link)||void 0===e?void 0:e.href)===t)return n}return null}(t);if(n)return n.eval(e)}},setViewport:function(t,e,n){t.width/=2,o.left.setViewport(t,{top:e.top,right:0,bottom:e.bottom,left:e.left},n),o.right.setViewport(t,{top:e.top,right:e.right,bottom:e.bottom,left:0},n),o.center.setViewport(t,{top:e.top,right:0,bottom:e.bottom,left:0},n)}}})(); //# sourceMappingURL=readium-fixed-wrapper-two.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift index 6b529f4b3c..a3a5cc5e77 100644 --- a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift @@ -88,11 +88,13 @@ final class EPUBFixedSpreadView: EPUBSpreadView { insets.right = horizontalInsets let viewportSize = bounds.inset(by: insets).size + let fitString = viewModel.settings.fit.rawValue webView.evaluateJavaScript(""" spread.setViewport( {'width': \(Int(viewportSize.width)), 'height': \(Int(viewportSize.height))}, - {'top': \(Int(insets.top)), 'left': \(Int(insets.left)), 'bottom': \(Int(insets.bottom)), 'right': \(Int(insets.right))} + {'top': \(Int(insets.top)), 'left': \(Int(insets.left)), 'bottom': \(Int(insets.bottom)), 'right': \(Int(insets.right))}, + '\(fitString)' ); """) } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index aab8d1393f..6fa0056dd4 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -1043,10 +1043,23 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { // the application's bars. var insets = view.window?.safeAreaInsets ?? .zero - if publication.metadata.layout != .fixed { + switch publication.metadata.layout ?? .reflowable { + 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 + // iPhone. + if UIDevice.current.userInterfaceIdiom != .phone { + insets = .zero + } + + case .reflowable: let configInset = config.contentInset(for: view.traitCollection.verticalSizeClass) insets.top = max(insets.top, configInset.top) insets.bottom = max(insets.bottom, configInset.bottom) + + case .scrolled: + // Not supported with EPUB. + break } return insets diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 6e93fecf8b..67b89d0f28 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -220,6 +220,7 @@ final class EPUBNavigatorViewModel: Loggable { || oldSettings.verticalText != newSettings.verticalText || oldSettings.scroll != newSettings.scroll || oldSettings.spread != newSettings.spread + || oldSettings.fit != newSettings.fit // We don't commit the CSS changes if we invalidate the pagination, as // the resources will be reloaded anyway. diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index ec9289a09b..8eb31beb8a 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift @@ -18,6 +18,13 @@ public struct EPUBPreferences: ConfigurablePreferences { /// spread). public var columnCount: ColumnCount? + /// Method for fitting the content of a fixed-layout publication within the + /// viewport. + /// + /// - `auto` or `page`: Fit entire page within viewport (default). + /// - `width`: Fit page width, allow vertical scrolling if needed. + public var fit: Fit? + /// Default typeface for the text. public var fontFamily: FontFamily? @@ -97,6 +104,7 @@ public struct EPUBPreferences: ConfigurablePreferences { public init( backgroundColor: Color? = nil, columnCount: ColumnCount? = nil, + fit: Fit? = nil, fontFamily: FontFamily? = nil, fontSize: Double? = nil, fontWeight: Double? = nil, @@ -123,6 +131,7 @@ public struct EPUBPreferences: ConfigurablePreferences { ) { self.backgroundColor = backgroundColor self.columnCount = columnCount + self.fit = fit self.fontFamily = fontFamily self.fontSize = fontSize.map { max($0, 0) } self.fontWeight = fontWeight?.clamped(to: 0.0 ... 2.5) @@ -152,6 +161,7 @@ public struct EPUBPreferences: ConfigurablePreferences { EPUBPreferences( backgroundColor: other.backgroundColor ?? backgroundColor, columnCount: other.columnCount ?? columnCount, + fit: other.fit ?? fit, fontFamily: other.fontFamily ?? fontFamily, fontSize: other.fontSize ?? fontSize, fontWeight: other.fontWeight ?? fontWeight, diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift index 98843dc8f3..4cfab00091 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift @@ -70,6 +70,18 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = + enumPreference( + preference: \.fit, + setting: \.fit, + defaultEffectiveValue: defaults.fit ?? .auto, + isEffective: { [layout] _ in layout == .fixed }, + supportedValues: [.auto, .page, .width] + ) + /// Default typeface for the text. /// /// Only effective with reflowable publications. diff --git a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift index 7e545edbf8..f1a7f48b98 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift @@ -13,6 +13,7 @@ import ReadiumShared public struct EPUBSettings: ConfigurableSettings { public var backgroundColor: Color? public var columnCount: ColumnCount + public var fit: Fit public var fontFamily: FontFamily? public var fontSize: Double public var fontWeight: Double? @@ -46,6 +47,7 @@ public struct EPUBSettings: ConfigurableSettings { public init( backgroundColor: Color?, columnCount: ColumnCount, + fit: Fit, fontFamily: FontFamily?, fontSize: Double, fontWeight: Double?, @@ -72,6 +74,7 @@ public struct EPUBSettings: ConfigurableSettings { ) { self.backgroundColor = backgroundColor self.columnCount = columnCount + self.fit = fit self.fontFamily = fontFamily self.fontSize = fontSize self.fontWeight = fontWeight @@ -139,6 +142,9 @@ public struct EPUBSettings: ConfigurableSettings { columnCount: preferences.columnCount ?? defaults.columnCount ?? .auto, + fit: preferences.fit + ?? defaults.fit + ?? .auto, fontFamily: preferences.fontFamily, fontSize: preferences.fontSize ?? defaults.fontSize @@ -196,6 +202,7 @@ public struct EPUBSettings: ConfigurableSettings { /// See `EPUBPreferences`. public struct EPUBDefaults { public var columnCount: ColumnCount? + public var fit: Fit? public var fontSize: Double? public var fontWeight: Double? public var hyphens: Bool? @@ -218,6 +225,7 @@ public struct EPUBDefaults { public init( columnCount: ColumnCount? = nil, + fit: Fit? = nil, fontSize: Double? = nil, fontWeight: Double? = nil, hyphens: Bool? = nil, @@ -239,6 +247,7 @@ public struct EPUBDefaults { wordSpacing: Double? = nil ) { self.columnCount = columnCount + self.fit = fit self.fontSize = fontSize self.fontWeight = fontWeight self.hyphens = hyphens diff --git a/Sources/Navigator/EPUB/Scripts/src/fixed-page.js b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js index f5bc1c12e1..91121e5899 100644 --- a/Sources/Navigator/EPUB/Scripts/src/fixed-page.js +++ b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js @@ -4,14 +4,37 @@ // available in the top-level LICENSE file of the project. // +// Page layout types. +export const PageType = { + SINGLE: "single", + SPREAD_LEFT: "spread-left", + SPREAD_RIGHT: "spread-right", + SPREAD_CENTER: "spread-center", +}; + +// Fit modes for scaling content. +export const Fit = { + AUTO: "auto", + PAGE: "page", + WIDTH: "width", +}; + // Manages a fixed layout resource embedded in an iframe. -export function FixedPage(iframeId) { +// @param iframeId - ID of the iframe element +// @param pageType - Type of page layout from PageType enum +export function FixedPage(iframeId, pageType) { // Fixed dimensions for the page, extracted from the viewport meta tag. var _pageSize = null; // Available viewport size to fill with the resource. var _viewportSize = null; // Margins that should not overlap the content. var _safeAreaInsets = null; + // Fit mode for scaling the page. + var _fit = Fit.AUTO; + // Type of page layout (determines centering behavior). + var _pageType = Object.values(PageType).includes(pageType) + ? pageType + : PageType.SINGLE; // iFrame containing the page. var _iframe = document.getElementById(iframeId); @@ -42,7 +65,7 @@ export function FixedPage(iframeId) { } } - // Layouts the page iframe to center its content and scale it to fill the available viewport. + // Layouts the page iframe and scale it according to the current fit mode. function layoutPage() { if (!_pageSize || !_viewportSize || !_safeAreaInsets) { return; @@ -50,13 +73,57 @@ export function FixedPage(iframeId) { _iframe.style.width = _pageSize.width + "px"; _iframe.style.height = _pageSize.height + "px"; - _iframe.style.marginTop = - _safeAreaInsets.top - _safeAreaInsets.bottom + "px"; // Calculates the zoom scale required to fit the content to the viewport. var widthRatio = _viewportSize.width / _pageSize.width; var heightRatio = _viewportSize.height / _pageSize.height; - var scale = Math.min(widthRatio, heightRatio); + var scale; + + switch (_fit) { + case Fit.WIDTH: + // Fit to width only. + scale = widthRatio; + break; + // Auto is equivalent to page in paginated mode, we don't have a scroll mode for FXL. + case Fit.AUTO: + case Fit.PAGE: + default: + // Fit both dimensions. + scale = Math.min(widthRatio, heightRatio); + break; + } + + // Calculate the scaled height of the content + var scaledHeight = _pageSize.height * scale; + + // Determine the appropriate transform based on page type. + // Single page and center page in spread need horizontal centering. + // Left/right pages in spread don't need horizontal transform. + var needsHorizontalCenter = + _pageType === PageType.SINGLE || _pageType === PageType.SPREAD_CENTER; + + // For width fit, if content overflows vertically, align to top + // For page fit, center the content vertically + if (_fit === Fit.WIDTH && scaledHeight > _viewportSize.height) { + // Content overflows: align to top with safe area inset + // Override the CSS centering + _iframe.style.top = _safeAreaInsets.top + "px"; + if (needsHorizontalCenter) { + _iframe.style.transform = "translateX(-50%)"; + } else { + _iframe.style.transform = "none"; + } + } else { + // Content fits or is page fit: center vertically + // Keep the CSS centering but adjust for safe area insets + var verticalOffset = _safeAreaInsets.top - _safeAreaInsets.bottom; + _iframe.style.top = "calc(50% + " + verticalOffset + "px)"; + if (needsHorizontalCenter) { + _iframe.style.transform = "translate(-50%, -50%)"; + } else { + _iframe.style.transform = "translateY(-50%)"; + } + } // Sets the viewport of the wrapper page (this page) to scale the iframe. var viewport = document.querySelector("meta[name=viewport]"); @@ -123,9 +190,12 @@ export function FixedPage(iframeId) { }, // Updates the available viewport to display the resource. - setViewport: function (viewportSize, safeAreaInsets) { + setViewport: function (viewportSize, safeAreaInsets, fit) { _viewportSize = viewportSize; _safeAreaInsets = safeAreaInsets; + if (Object.values(Fit).includes(fit)) { + _fit = fit; + } layoutPage(); }, diff --git a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-one.js b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-one.js index ea0224dcb8..f1f6f43061 100644 --- a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-one.js +++ b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-one.js @@ -6,9 +6,9 @@ // Script used for the single spread wrapper HTML page for fixed layout resources. -import { FixedPage } from "./fixed-page"; +import { FixedPage, PageType } from "./fixed-page"; -var page = FixedPage("page"); +var page = FixedPage("page", PageType.SINGLE); // Public API called from Swift. global.spread = { @@ -30,7 +30,7 @@ global.spread = { }, // Updates the available viewport to display the resources. - setViewport: function (viewportSize, safeAreaInsets) { - page.setViewport(viewportSize, safeAreaInsets); + setViewport: function (viewportSize, safeAreaInsets, fit) { + page.setViewport(viewportSize, safeAreaInsets, fit); }, }; diff --git a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js index a2848eec90..3c31f660a1 100644 --- a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js +++ b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js @@ -6,12 +6,12 @@ // Script used for the single spread wrapper HTML page for fixed layout resources. -import { FixedPage } from "./fixed-page"; +import { FixedPage, PageType } from "./fixed-page"; var pages = { - left: FixedPage("page-left"), - right: FixedPage("page-right"), - center: FixedPage("page-center"), + left: FixedPage("page-left", PageType.SPREAD_LEFT), + right: FixedPage("page-right", PageType.SPREAD_RIGHT), + center: FixedPage("page-center", PageType.SPREAD_CENTER), }; function forEachPage(callback) { @@ -76,28 +76,40 @@ global.spread = { }, // Updates the available viewport to display the resources. - setViewport: function (viewportSize, safeAreaInsets) { + setViewport: function (viewportSize, safeAreaInsets, fit) { viewportSize.width /= 2; - pages.left.setViewport(viewportSize, { - top: safeAreaInsets.top, - right: 0, - bottom: safeAreaInsets.bottom, - left: safeAreaInsets.left, - }); + pages.left.setViewport( + viewportSize, + { + top: safeAreaInsets.top, + right: 0, + bottom: safeAreaInsets.bottom, + left: safeAreaInsets.left, + }, + fit + ); - pages.right.setViewport(viewportSize, { - top: safeAreaInsets.top, - right: safeAreaInsets.right, - bottom: safeAreaInsets.bottom, - left: 0, - }); + pages.right.setViewport( + viewportSize, + { + top: safeAreaInsets.top, + right: safeAreaInsets.right, + bottom: safeAreaInsets.bottom, + left: 0, + }, + fit + ); - pages.center.setViewport(viewportSize, { - top: safeAreaInsets.top, - right: 0, - bottom: safeAreaInsets.bottom, - left: 0, - }); + pages.center.setViewport( + viewportSize, + { + top: safeAreaInsets.top, + right: 0, + bottom: safeAreaInsets.bottom, + left: 0, + }, + fit + ); }, }; diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index 214f88e298..381b215dc3 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -123,6 +123,7 @@ struct UserPreferences< fixedLayoutUserPreferences( commit: commit, backgroundColor: editor.backgroundColor, + fit: editor.fit, language: editor.language, readingProgression: editor.readingProgression, spread: editor.spread From b224f4dc515e19723d7eedc0b2c3eedc8ad56b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 9 Dec 2025 23:05:26 +0100 Subject: [PATCH 11/55] Add navigator UI tests (#683) --- .github/workflows/checks.yml | 17 +++ Makefile | 9 +- Package.swift | 21 +++- .../EPUB/EPUBReflowableSpreadView.swift | 14 ++- Sources/Navigator/EPUB/EPUBSpreadView.swift | 21 +++- .../Navigator/Toolkit/PaginationView.swift | 12 ++ Tests/NavigatorTests/UITests/.gitignore | 1 + .../NavigatorTestHost/AccessibilityID.swift | 21 ++++ .../UITests/NavigatorTestHost/Container.swift | 80 ++++++++++++++ .../NavigatorTestHost/FixtureList.swift | 103 ++++++++++++++++++ .../Fixtures/PublicationFixture.swift | 26 +++++ .../UITests/NavigatorTestHost/Info.plist | 39 +++++++ .../UITests/NavigatorTestHost/ListRow.swift | 43 ++++++++ .../NavigatorTestHost/MemoryTracker.swift | 67 ++++++++++++ .../NavigatorTestHostApp.swift | 16 +++ .../NavigatorTestHost/ReaderView.swift | 72 ++++++++++++ .../ReaderViewController.swift | 48 ++++++++ .../UITests/NavigatorUITests/Info.plist | 22 ++++ .../NavigatorUITests/MemoryLeakTests.swift | 47 ++++++++ .../NavigatorUITests/XCUIApplication.swift | 65 +++++++++++ .../NavigatorUITests/XCUIElement.swift | 43 ++++++++ Tests/NavigatorTests/UITests/README.md | 20 ++++ Tests/NavigatorTests/UITests/project.yml | 51 +++++++++ .../Publications/childrens-literature.epub} | Bin .../Publications}/daisy.lcpdf | Bin .../Publications}/daisy.pdf | Bin Tests/Publications/TestPublications.swift | 29 +++++ .../Resource/BufferingResourceTests.swift | 3 +- .../Resource/TailCachingResourceTests.swift | 3 +- 29 files changed, 874 insertions(+), 19 deletions(-) create mode 100644 Tests/NavigatorTests/UITests/.gitignore create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist create mode 100644 Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift create mode 100644 Tests/NavigatorTests/UITests/README.md create mode 100644 Tests/NavigatorTests/UITests/project.yml rename Tests/{SharedTests/Fixtures/Fetcher/epub.epub => Publications/Publications/childrens-literature.epub} (100%) rename Tests/{LCPTests/Fixtures => Publications/Publications}/daisy.lcpdf (100%) rename Tests/{LCPTests/Fixtures => Publications/Publications}/daisy.pdf (100%) create mode 100644 Tests/Publications/TestPublications.swift diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fee815bcd0..906c850990 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -40,6 +40,23 @@ jobs: set -eo pipefail xcodebuild test-without-building -scheme "$scheme" -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi + navigator-ui-tests: + name: Navigator UI Tests + runs-on: macos-14 + if: ${{ !github.event.pull_request.draft }} + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Install dependencies + run: | + brew update + brew install xcodegen + - name: Test + run: | + set -eo pipefail + make navigator-ui-tests-project + xcodebuild test -project Tests/NavigatorTests/UITests/NavigatorUITests.xcodeproj -scheme NavigatorTestHost -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi + lint: name: Lint runs-on: macos-14 diff --git a/Makefile b/Makefile index 3f66b6af4a..4cdeb32af6 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,10 @@ carthage-project: rm -rf $(SCRIPTS_PATH)/node_modules/ xcodegen -s Support/Carthage/project.yml --use-cache --cache-path Support/Carthage/.xcodegen +.PHONY: navigator-ui-tests-project +navigator-ui-tests-project: + xcodegen -s Tests/NavigatorTests/UITests/project.yml + .PHONY: scripts scripts: @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1) @@ -32,11 +36,6 @@ update-scripts: @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1) pnpm install --dir "$(SCRIPTS_PATH)" -.PHONY: test -test: - # To limit to a particular test suite: -only-testing:ReadiumSharedTests - xcodebuild test -scheme "Readium-Package" -destination "platform=iOS Simulator,name=iPhone 15" | xcbeautify -q - .PHONY: lint-format lint-format: swift run --package-path BuildTools swiftformat --lint . diff --git a/Package.swift b/Package.swift index 5856660f03..8ef42cf5d9 100644 --- a/Package.swift +++ b/Package.swift @@ -53,7 +53,10 @@ let package = Package( ), .testTarget( name: "ReadiumSharedTests", - dependencies: ["ReadiumShared"], + dependencies: [ + "ReadiumShared", + "TestPublications", + ], path: "Tests/SharedTests", resources: [ .copy("Fixtures"), @@ -101,7 +104,10 @@ let package = Package( .testTarget( name: "ReadiumNavigatorTests", dependencies: ["ReadiumNavigator"], - path: "Tests/NavigatorTests" + path: "Tests/NavigatorTests", + exclude: [ + "UITests", + ] ), .target( @@ -140,7 +146,7 @@ let package = Package( // dependencies: ["ReadiumLCP"], // path: "Tests/LCPTests", // resources: [ - // .copy("Fixtures"), + // .copy("../Fixtures"), // ] // ), @@ -171,5 +177,14 @@ let package = Package( dependencies: ["ReadiumInternal"], path: "Tests/InternalTests" ), + + // Shared test publications used across multiple test targets. + .target( + name: "TestPublications", + path: "Tests/Publications", + resources: [ + .copy("Publications"), + ] + ), ] ) diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 24eda8d15a..55772bbad2 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -33,6 +33,16 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { ) } + override func clear() { + super.clear() + + // Clean up go to continuations. + for continuation in goToContinuations { + continuation.resume() + } + goToContinuations.removeAll() + } + override func setupWebView() { super.setupWebView() @@ -193,7 +203,6 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { // Location to scroll to in the resource once the page is loaded. private var pendingLocation: PageLocation = .start - @MainActor override func go(to location: PageLocation) async { guard isSpreadLoaded else { // Delays moving to the location until the document is loaded. @@ -215,14 +224,12 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { didCompleteGoTo() } - @MainActor private func waitGoToCompletion() async { await withCheckedContinuation { continuation in goToContinuations.append(continuation) } } - @MainActor private func didCompleteGoTo() { for cont in goToContinuations { cont.resume() @@ -230,7 +237,6 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { goToContinuations.removeAll() } - @MainActor private var goToContinuations: [CheckedContinuation] = [] @discardableResult diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 27a52c2b95..b540d1fb67 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -95,6 +95,13 @@ class EPUBSpreadView: UIView, Loggable, PageView { deinit { NotificationCenter.default.removeObserver(self) + clear() + } + + /// Called when the spread view is removed from the view hierarchy, to + /// clear pending operations and retain cycles. + func clear() { + // Disable JS messages to break WKUserContentController reference. disableJSMessages() } @@ -126,14 +133,18 @@ class EPUBSpreadView: UIView, Loggable, PageView { webView.scrollView } + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + + if newSuperview == nil { + clear() + } + } + override func didMoveToSuperview() { super.didMoveToSuperview() - if superview == nil { - disableJSMessages() - // Fixing an iOS 9 bug by explicitly clearing scrollView.delegate before deinitialization - scrollView.delegate = nil - } else { + if superview != nil { enableJSMessages() scrollView.delegate = self } diff --git a/Sources/Navigator/Toolkit/PaginationView.swift b/Sources/Navigator/Toolkit/PaginationView.swift index 5035417cee..b12d4dbb83 100644 --- a/Sources/Navigator/Toolkit/PaginationView.swift +++ b/Sources/Navigator/Toolkit/PaginationView.swift @@ -150,6 +150,18 @@ final class PaginationView: UIView, Loggable { scrollView.contentOffset.x = xOffsetForIndex(currentIndex) } + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + + if newSuperview == nil { + // Remove all spread views to break retain cycles + for (_, view) in loadedViews { + view.removeFromSuperview() + } + loadedViews.removeAll() + } + } + override func didMoveToWindow() { super.didMoveToWindow() diff --git a/Tests/NavigatorTests/UITests/.gitignore b/Tests/NavigatorTests/UITests/.gitignore new file mode 100644 index 0000000000..4640ebbac8 --- /dev/null +++ b/Tests/NavigatorTests/UITests/.gitignore @@ -0,0 +1 @@ +*.xcodeproj diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift new file mode 100644 index 0000000000..f82f4fe46f --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift @@ -0,0 +1,21 @@ +// +// Copyright 2025 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 SwiftUI + +enum AccessibilityID: String { + case open + case close + case allMemoryDeallocated + case isNavigatorReady +} + +extension View { + func accessibilityIdentifier(_ id: AccessibilityID) -> ModifiedContent { + accessibilityIdentifier(id.rawValue) + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift new file mode 100644 index 0000000000..6b9d43931b --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift @@ -0,0 +1,80 @@ +// +// Copyright 2025 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 ReadiumAdapterGCDWebServer +import ReadiumNavigator +import ReadiumShared +import ReadiumStreamer +import UIKit + +/// Shared Readium infrastructure for testing. +@MainActor class Container { + static let shared = Container() + + let memoryTracker = MemoryTracker() + let httpClient: HTTPClient + let httpServer: HTTPServer + let assetRetriever: AssetRetriever + let publicationOpener: PublicationOpener + + init() { + httpClient = DefaultHTTPClient() + assetRetriever = AssetRetriever(httpClient: httpClient) + httpServer = GCDHTTPServer(assetRetriever: assetRetriever) + + publicationOpener = PublicationOpener( + parser: DefaultPublicationParser( + httpClient: httpClient, + assetRetriever: assetRetriever, + pdfFactory: DefaultPDFDocumentFactory() + ), + contentProtections: [] + ) + } + + func publication(at url: FileURL) async throws -> Publication { + let asset = try await assetRetriever.retrieve(url: url).get() + let publication = try await publicationOpener.open( + asset: asset, + allowUserInteraction: false, + sender: nil + ).get() + + memoryTracker.track(publication) + return publication + } + + func navigator(for publication: Publication) throws -> VisualNavigator & UIViewController { + if publication.conforms(to: .epub) { + return try epubNavigator(for: publication) + } else if publication.conforms(to: .pdf) { + return try pdfNavigator(for: publication) + } else { + fatalError("Publication not supported") + } + } + + func epubNavigator(for publication: Publication) throws -> EPUBNavigatorViewController { + let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: nil, + config: EPUBNavigatorViewController.Configuration(), + httpServer: httpServer + ) + memoryTracker.track(navigator) + return navigator + } + + func pdfNavigator(for publication: Publication) throws -> PDFNavigatorViewController { + let navigator = try PDFNavigatorViewController( + publication: publication, + initialLocation: nil, + httpServer: httpServer + ) + memoryTracker.track(navigator) + return navigator + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift new file mode 100644 index 0000000000..20d81f62d6 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift @@ -0,0 +1,103 @@ +// +// Copyright 2025 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 +import SwiftUI + +/// Provides a simple UI for opening publication fixtures and display memory +/// status for UI test verification. +struct FixtureList: View { + @ObservedObject private var memoryTracker: MemoryTracker + @StateObject private var viewModel = FixtureListViewModel() + + init() { + memoryTracker = Container.shared.memoryTracker + } + + var body: some View { + List { + Section { + fixture(.childrensLiteratureEPUB) + fixture(.daisyPDF) + } + + Section { + Toggle(isOn: $memoryTracker.allDeallocated) { + Text("All memory is deallocated") + } + .accessibilityIdentifier(.allMemoryDeallocated) + } + .disabled(true) + } + .fullScreenCover(item: $viewModel.readerViewModel) { viewModel in + ReaderView(viewModel: viewModel) + } + } + + private func fixture(_ fixture: PublicationFixture) -> some View { + ListRow(action: { viewModel.open(fixture) }) { + VStack(alignment: .leading) { + Text(fixture.filename) + .font(.headline) + + Text(fixture.description) + .font(.caption) + } + + Spacer() + + Image(systemName: "chevron.right") + } + .accessibilityIdentifier(fixture.accessibilityIdentifier) + } +} + +@MainActor +class FixtureListViewModel: ObservableObject { + @Published var readerViewModel: ReaderViewModel? + + private var openTask: Task? + + func open(_ fixture: PublicationFixture) { + openTask?.cancel() + openTask = Task { try! await open(fixture) } + } + + private func open(_ fixture: PublicationFixture) async throws { + let components = fixture.filename.split(separator: ".", maxSplits: 1) + .map { String($0) } + + guard + components.count == 2, + let epubURL = Bundle.main.url( + forResource: components[0], + withExtension: components[1], + subdirectory: "Publications" + ) + else { + throw FixtureError.notFound(fixture) + } + + let fileURL = FileURL(url: epubURL)! + + let container = Container.shared + let publication = try await container.publication(at: fileURL) + let navigator = try container.navigator(for: publication) + + readerViewModel = ReaderViewModel(navigator: navigator) + } +} + +enum FixtureError: LocalizedError { + case notFound(PublicationFixture) + + var errorDescription: String? { + switch self { + case let .notFound(fixture): + return "Test fixture \(fixture.filename) not found in bundle" + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift new file mode 100644 index 0000000000..7ca33ee7d5 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift @@ -0,0 +1,26 @@ +// +// Copyright 2025 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 + +struct PublicationFixture { + let filename: String + let description: String + + var accessibilityIdentifier: String { + "publication://\(filename)" + } + + static let childrensLiteratureEPUB: PublicationFixture = .init( + filename: "childrens-literature.epub", + description: "Basic reflowable EPUB with a page-list." + ) + + static let daisyPDF: PublicationFixture = .init( + filename: "daisy.pdf", + description: "Basic PDF document." + ) +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist b/Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist new file mode 100644 index 0000000000..ee46fcec7e --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift new file mode 100644 index 0000000000..a04fa0c53b --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift @@ -0,0 +1,43 @@ +// +// Copyright 2025 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 SwiftUI + +struct ListRow: View { + private let action: (@MainActor () async -> Void)? + private let content: () -> Content + + @State private var isActionRunning = false + + init( + action: (() async -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.action = action + self.content = content + } + + var body: some View { + HStack { + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.interaction, .rect) + .onTapGesture(perform: activate) + .disabled(isActionRunning) + } + + private func activate() { + guard let action, !isActionRunning else { + return + } + isActionRunning = true + Task { @MainActor in + await action() + isActionRunning = false + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift new file mode 100644 index 0000000000..f5bbdb8022 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift @@ -0,0 +1,67 @@ +// +// Copyright 2025 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 ReadiumNavigator + +/// Tracks object instances to detect memory leaks in UI tests. +@MainActor class MemoryTracker: ObservableObject { + @Published var allDeallocated: Bool = true + + class Ref { + private weak var object: AnyObject? + + var isDeallocated: Bool { + object == nil + } + + init(_ object: AnyObject) { + self.object = object + } + } + + private var refs: [Ref] = [] + private var pollingTask: Task? + + /// Records a weak reference to track. + @discardableResult + func track(_ object: T) -> Ref { + let ref = Ref(object) + refs.append(ref) + startPollingIfNeeded() + return ref + } + + private func startPollingIfNeeded() { + guard pollingTask == nil else { return } + + pollingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(seconds: 0.5) + pollAllocations() + } + } + } + + private func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } + + private func pollAllocations() { + refs.removeAll { $0.isDeallocated } + let deallocated = refs.isEmpty + + if allDeallocated != deallocated { + allDeallocated = deallocated + } + + // Stop polling when no objects are being tracked + if refs.isEmpty { + stopPolling() + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift new file mode 100644 index 0000000000..75ad431087 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift @@ -0,0 +1,16 @@ +// +// Copyright 2025 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 SwiftUI + +@main +struct NavigatorTestHostApp: App { + var body: some Scene { + WindowGroup { + FixtureList() + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift new file mode 100644 index 0000000000..a180aa756c --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift @@ -0,0 +1,72 @@ +// +// Copyright 2025 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 ReadiumNavigator +import ReadiumShared +import SwiftUI + +/// SwiftUI wrapper for the `ReaderViewController`. +struct ReaderView: View { + @ObservedObject var viewModel: ReaderViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + ReaderViewControllerWrapper(navigator: viewModel.navigator) + // State information checked in UI tests, not meant to be + // visible. + .background( + List { + Toggle(isOn: $viewModel.isReady) {} + .accessibilityIdentifier(.isNavigatorReady) + } + ) + .ignoresSafeArea(.all) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + .accessibilityIdentifier(.close) + } + } + } + } +} + +@MainActor final class ReaderViewModel: ObservableObject, Identifiable { + nonisolated var id: ObjectIdentifier { ObjectIdentifier(self) } + + let navigator: VisualNavigator & UIViewController + + @Published var isReady: Bool = false + + init(navigator: VisualNavigator & UIViewController) { + self.navigator = navigator + + if let epubNavigator = navigator as? EPUBNavigatorViewController { + epubNavigator.delegate = self + } else if let pdfNavigator = navigator as? PDFNavigatorViewController { + pdfNavigator.delegate = self + } + } +} + +// MARK: - NavigatorDelegate + +extension ReaderViewModel: NavigatorDelegate { + func navigator(_ navigator: Navigator, presentError error: NavigatorError) {} + + func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + if !isReady { + isReady = true + } + } +} + +extension ReaderViewModel: EPUBNavigatorDelegate {} +extension ReaderViewModel: PDFNavigatorDelegate {} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift new file mode 100644 index 0000000000..c65cc77b5c --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift @@ -0,0 +1,48 @@ +// +// Copyright 2025 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 ReadiumNavigator +import SwiftUI +import UIKit + +class ReaderViewController: UIViewController { + private let navigator: VisualNavigator & UIViewController + + init(navigator: VisualNavigator & UIViewController) { + self.navigator = navigator + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init?(coder: NSCoder) not implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Add navigator as child view controller + addChild(navigator) + navigator.view.frame = view.bounds + navigator.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(navigator.view) + navigator.didMove(toParent: self) + } +} + +struct ReaderViewControllerWrapper: UIViewControllerRepresentable { + let navigator: VisualNavigator & UIViewController + + init(navigator: VisualNavigator & UIViewController) { + self.navigator = navigator + } + + func makeUIViewController(context: Context) -> ReaderViewController { + ReaderViewController(navigator: navigator) + } + + func updateUIViewController(_ uiViewController: ReaderViewController, context: Context) {} +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist b/Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist new file mode 100644 index 0000000000..64d65ca495 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift new file mode 100644 index 0000000000..88283538a4 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift @@ -0,0 +1,47 @@ +// +// Copyright 2025 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 XCTest + +/// These tests verify that navigator instances are properly deallocated when +/// dismissed. +/// +/// The host app maintains a weak reference to the navigator. If the navigator +/// is properly deallocated after dismissal, the weak reference becomes nil. +/// If it remains non-nil, a retain cycle or memory leak exists. +final class MemoryLeakTests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + func testEPUBNavigatorDeallocatesAfterClosing() throws { + app + .open(.childrensLiteratureEPUB, waitUntilReady: true) + .close(assertMemoryDeallocated: true) + } + + func testEPUBNavigatorDeallocatesAfterClosingBeforeReady() throws { + app + .open(.childrensLiteratureEPUB, waitUntilReady: false) + .close(assertMemoryDeallocated: true) + } + + func testPDFNavigatorDeallocatesAfterClosing() throws { + app + .open(.daisyPDF, waitUntilReady: true) + .close(assertMemoryDeallocated: true) + } + + func testPDFNavigatorDeallocatesAfterClosingBeforeReady() throws { + app + .open(.daisyPDF, waitUntilReady: false) + .close(assertMemoryDeallocated: true) + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift new file mode 100644 index 0000000000..6165e84291 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift @@ -0,0 +1,65 @@ +// +// Copyright 2025 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 XCTest + +extension XCUIApplication { + /// Opens a publication fixture. + @discardableResult + func open(_ fixture: PublicationFixture, waitUntilReady: Bool = true) -> ReaderUI { + staticTexts[fixture.accessibilityIdentifier].firstMatch.tap() + + let reader = ReaderUI(app: self) + + if waitUntilReady { + // Give the navigator time to fully load content. + reader.assertReady() + } + + return reader + } + + /// Checks that some memory is allocated in the app. + @discardableResult + func assertSomeMemoryAllocated() -> Self { + switches[.allMemoryDeallocated].assertIs(false) + return self + } + + /// Checks that all the tracked memory is deallocated in the app. + /// + /// A timeout is used to make sure the memory is cleared. + @discardableResult + func assertAllMemoryDeallocated() -> Self { + switches[.allMemoryDeallocated].assertIs(true, waitForTimeout: 30) + return self + } +} + +struct ReaderUI { + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + /// Activates the Close button. + @discardableResult + func close(assertMemoryDeallocated: Bool = true) -> XCUIApplication { + app.buttons[.close].tap() + if assertMemoryDeallocated { + app.assertAllMemoryDeallocated() + } + return app + } + + /// Waits for the navigator to be ready. + @discardableResult + func assertReady(timeout: TimeInterval = 30) -> Self { + app.switches[.isNavigatorReady].assertIs(true, waitForTimeout: timeout) + return self + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift new file mode 100644 index 0000000000..95bd6f9eac --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift @@ -0,0 +1,43 @@ +// +// Copyright 2025 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 XCTest + +extension XCUIElementQuery { + subscript(id: AccessibilityID) -> XCUIElement { + self[id.rawValue] + } +} + +extension XCUIElement { + var stringValue: String? { + value as? String + } + + func assertIsOn() { + assertIs(true) + } + + func assertIsOff() { + assertIs(false) + } + + func assertIs(_ on: Bool, waitForTimeout timeout: TimeInterval? = nil) { + let expectedValue = on ? "1" : "0" + let message = "Expected to be \(on ? "on" : "off")" + + if let timeout = timeout { + XCTAssertTrue(wait(toBe: on, timeout: timeout), message) + } else { + XCTAssertEqual(stringValue, expectedValue, message) + } + } + + func wait(toBe on: Bool, timeout: TimeInterval) -> Bool { + let expectedValue = on ? "1" : "0" + return wait(for: \.stringValue, toEqual: expectedValue, timeout: timeout) + } +} diff --git a/Tests/NavigatorTests/UITests/README.md b/Tests/NavigatorTests/UITests/README.md new file mode 100644 index 0000000000..e89749c262 --- /dev/null +++ b/Tests/NavigatorTests/UITests/README.md @@ -0,0 +1,20 @@ +# Navigator UI Tests + +This test host app provides a controlled environment for running UI tests against Readium Navigators in a real app context with full WebKit and SwiftUI lifecycle. It's designed to be simple and maintainable, avoiding the complexity of the main TestApp. + +## Generate Xcode Project + +```bash +cd Tests/NavigatorTests/UITests +xcodegen generate +``` + +This creates `NavigatorUITests.xcodeproj` from `project.yml`. + +## Running Tests from Xcode + +1. Open `NavigatorUITests.xcodeproj` +2. Select the `NavigatorTestHost` scheme +3. Choose a simulator (iPhone or iPad) +4. Run tests: Cmd+U or Product > Test + diff --git a/Tests/NavigatorTests/UITests/project.yml b/Tests/NavigatorTests/UITests/project.yml new file mode 100644 index 0000000000..19891c7596 --- /dev/null +++ b/Tests/NavigatorTests/UITests/project.yml @@ -0,0 +1,51 @@ +name: NavigatorUITests +options: + bundleIdPrefix: org.readium.test +packages: + Readium: + path: ../../.. +schemes: + NavigatorTestHost: + build: + targets: + NavigatorTestHost: all + test: + targets: + - NavigatorUITests +targets: + NavigatorTestHost: + type: application + platform: iOS + deploymentTarget: 15.0 + sources: + - path: NavigatorTestHost + - path: ../../Publications/Publications + type: folder + buildPhase: resources + dependencies: + - package: Readium + product: ReadiumShared + - package: Readium + product: ReadiumStreamer + - package: Readium + product: ReadiumNavigator + - package: Readium + product: ReadiumAdapterGCDWebServer + settings: + INFOPLIST_FILE: NavigatorTestHost/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: org.readium.test.NavigatorTestHost + + NavigatorUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: 15.0 + sources: + - NavigatorUITests + - NavigatorTestHost/AccessibilityID.swift + - NavigatorTestHost/Fixtures + dependencies: + - target: NavigatorTestHost + settings: + INFOPLIST_FILE: NavigatorUITests/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: org.readium.test.NavigatorUITests + TEST_TARGET_NAME: NavigatorTestHost diff --git a/Tests/SharedTests/Fixtures/Fetcher/epub.epub b/Tests/Publications/Publications/childrens-literature.epub similarity index 100% rename from Tests/SharedTests/Fixtures/Fetcher/epub.epub rename to Tests/Publications/Publications/childrens-literature.epub diff --git a/Tests/LCPTests/Fixtures/daisy.lcpdf b/Tests/Publications/Publications/daisy.lcpdf similarity index 100% rename from Tests/LCPTests/Fixtures/daisy.lcpdf rename to Tests/Publications/Publications/daisy.lcpdf diff --git a/Tests/LCPTests/Fixtures/daisy.pdf b/Tests/Publications/Publications/daisy.pdf similarity index 100% rename from Tests/LCPTests/Fixtures/daisy.pdf rename to Tests/Publications/Publications/daisy.pdf diff --git a/Tests/Publications/TestPublications.swift b/Tests/Publications/TestPublications.swift new file mode 100644 index 0000000000..d17564c8cf --- /dev/null +++ b/Tests/Publications/TestPublications.swift @@ -0,0 +1,29 @@ +// +// Copyright 2025 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 + +/// Provides access to shared test publication files. +public enum TestPublications { + /// Returns the resource bundle containing shared test publications. + public static let bundle = Bundle.module + + /// Returns a URL for the specified publication file. + /// + /// - Parameter filename: The filename with extension (e.g., "childrens-literature.epub"). + /// - Returns: A URL pointing to the publication file. + public static func url(for filename: String) -> URL { + let components = filename.split(separator: ".", maxSplits: 1) + let name = String(components[0]) + let ext = components.count > 1 ? String(components[1]) : nil + + guard let url = bundle.url(forResource: name, withExtension: ext, subdirectory: "Publications") else { + fatalError("Test publication '\(filename)' not found in TestPublications bundle") + } + + return url + } +} diff --git a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift index 6bd82269fe..4b8812de7d 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift @@ -5,6 +5,7 @@ // @testable import ReadiumShared +import TestPublications import XCTest class BufferingResourceTests: XCTestCase { @@ -99,7 +100,7 @@ class BufferingResourceTests: XCTestCase { } } - private let file = Fixtures(path: "Fetcher").url(for: "epub.epub") + private let file = FileURL(url: TestPublications.url(for: "childrens-literature.epub"))! private lazy var data = try! Data(contentsOf: file.url) private lazy var resource = FileResource(file: file) diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift index 8dbe5b7a4e..cf3745baed 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift @@ -5,6 +5,7 @@ // @testable import ReadiumShared +import TestPublications import XCTest class TailCachingResourceTests: XCTestCase { @@ -50,7 +51,7 @@ class TailCachingResourceTests: XCTestCase { } } - private let file = Fixtures(path: "Fetcher").url(for: "epub.epub") + private let file = FileURL(url: TestPublications.url(for: "childrens-literature.epub"))! private lazy var data = try! Data(contentsOf: file.url) private lazy var resource = FileResource(file: file) From e6b7798fc2581dabd7a5d6ef8a73254a0d773f4b Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Wed, 17 Dec 2025 07:15:45 -0600 Subject: [PATCH 12/55] Rewrite OPDS catalogs to use SwiftUI (#671) --- .../OPDS/OPDSCatalogs/OPDSCatalog.swift | 2 +- .../OPDS/OPDSCatalogs/OPDSCatalogRow.swift | 5 - .../OPDS/OPDSCatalogs/OPDSCatalogsView.swift | 38 +- .../OPDS/OPDSFacets/Feed+preview.swift | 222 ------- .../OPDS/OPDSFacets/OPDSFacetLink.swift | 39 -- .../OPDS/OPDSFacets/OPDSFacetList.swift | 51 -- TestApp/Sources/OPDS/OPDSFactory.swift | 10 - .../OPDS/OPDSFeeds/OPDSFacetView.swift | 50 ++ .../Sources/OPDS/OPDSFeeds/OPDSFeedView.swift | 278 ++++++++ .../OPDS/OPDSFeeds/OPDSFeedViewModel.swift | 149 +++++ .../Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift | 47 ++ .../OPDS/OPDSFeeds/OPDSNavigationRow.swift | 40 ++ .../OPDSFeeds/OPDSPublicationInfoView.swift | 19 + .../OPDSFeeds/OPDSPublicationItemView.swift | 57 ++ .../OPDS/OPDSGroupCollectionViewCell.swift | 12 - .../Sources/OPDS/OPDSGroupTableViewCell.swift | 176 ----- TestApp/Sources/OPDS/OPDSModule.swift | 34 +- .../OPDS/OPDSNavigationTableViewCell.swift | 23 - .../OPDS/OPDSPublicationTableViewCell.swift | 129 ---- .../OPDS/OPDSRootTableViewController.swift | 603 ------------------ .../Sources/Reader/Common/TTS/TTSView.swift | 2 +- 21 files changed, 684 insertions(+), 1302 deletions(-) delete mode 100644 TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift delete mode 100644 TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift delete mode 100644 TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift create mode 100644 TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift create mode 100644 TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift create mode 100644 TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift create mode 100644 TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift create mode 100644 TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift create mode 100644 TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift create mode 100644 TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift delete mode 100644 TestApp/Sources/OPDS/OPDSGroupCollectionViewCell.swift delete mode 100644 TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift delete mode 100644 TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift delete mode 100644 TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift delete mode 100644 TestApp/Sources/OPDS/OPDSRootTableViewController.swift diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift index 3cca9cf6cd..e3aecc3ab5 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift @@ -6,7 +6,7 @@ import Foundation -struct OPDSCatalog: Identifiable, Equatable { +struct OPDSCatalog: Identifiable, Equatable, Hashable { let id: String var title: String var url: URL diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift index fa27bb2516..029b496a03 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift @@ -14,11 +14,6 @@ struct OPDSCatalogRow: View { Image(systemName: "books.vertical.fill") .foregroundColor(.accentColor) Text(title) - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.gray) } } } diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift index df4c247a09..5c7dd8ce5d 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift @@ -9,30 +9,32 @@ import SwiftUI struct OPDSCatalogsView: View { @State private var viewModel: OPDSCatalogsViewModel - init(viewModel: OPDSCatalogsViewModel) { + private var delegate: OPDSModuleDelegate? + + init(viewModel: OPDSCatalogsViewModel, delegate: OPDSModuleDelegate?) { self.viewModel = viewModel + self.delegate = delegate } var body: some View { List(viewModel.catalogs) { catalog in - OPDSCatalogRow(title: catalog.title) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.onCatalogTap(id: catalog.id) + NavigationLink(value: catalog) { + OPDSCatalogRow(title: catalog.title) + } + .contentShape(Rectangle()) + .swipeActions(allowsFullSwipe: false) { + Button(role: .destructive) { + viewModel.onDeleteCatalogTap(id: catalog.id) + } label: { + Label("Delete", systemImage: "trash") } - .swipeActions(allowsFullSwipe: false) { - Button(role: .destructive) { - viewModel.onDeleteCatalogTap(id: catalog.id) - } label: { - Label("Delete", systemImage: "trash") - } - Button { - viewModel.onEditCatalogTap(id: catalog.id) - } label: { - Label("Edit", systemImage: "pencil") - } + Button { + viewModel.onEditCatalogTap(id: catalog.id) + } label: { + Label("Edit", systemImage: "pencil") } + } } .listStyle(.plain) .onAppear { @@ -56,5 +58,7 @@ struct OPDSCatalogsView: View { } #Preview { - OPDSCatalogsView(viewModel: OPDSCatalogsViewModel()) + NavigationStack { + OPDSCatalogsView(viewModel: OPDSCatalogsViewModel(), delegate: nil) + } } diff --git a/TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift b/TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift deleted file mode 100644 index 6938b7bfba..0000000000 --- a/TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// Copyright 2025 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 ReadiumOPDS -import ReadiumShared - -extension Feed { - static var preview: Feed { - try! OPDS2Parser.parse( - jsonData: .preview, - url: URL(string: "http://opds-spec.org/opds.json")!, - response: URLResponse() - ).feed! - } -} - -private extension Data { - static var preview: Data { - let jsonString = """ - { - "@context": "http://opds-spec.org/opds.json", - "metadata": { - "title": "Example Library", - "modified": "2024-11-05T12:00:00Z", - "numberOfItems": 5000, - "itemsPerPage": 30 - }, - "links": [ - { - "rel": "self", - "href": "/opds", - "type": "application/opds+json" - } - ], - "facets": [ - { - "metadata": { - "title": "Genre" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=fiction", - "title": "Fiction", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1250 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=mystery", - "title": "Mystery & Detective", - "type": "application/opds+json", - "properties": { - "numberOfItems": 850 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=scifi", - "title": "Science Fiction", - "type": "application/opds+json", - "properties": { - "numberOfItems": 725 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=non-fiction", - "title": "Non-Fiction", - "type": "application/opds+json", - "properties": { - "numberOfItems": 2175 - } - } - ] - }, - { - "metadata": { - "title": "Language" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?language=en", - "title": "English", - "type": "application/opds+json", - "properties": { - "numberOfItems": 3000 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?language=es", - "title": "Spanish", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1000 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?language=ru", - "title": "Russian", - "type": "application/opds+json", - "properties": { - "numberOfItems": 800 - } - } - ] - }, - { - "metadata": { - "title": "Availability" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?availability=free", - "title": "Free", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1500 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?availability=subscription", - "title": "Subscription", - "type": "application/opds+json", - "properties": { - "numberOfItems": 2500 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?availability=buy", - "title": "Purchase Required", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1000 - } - } - ] - }, - { - "metadata": { - "title": "Reading Age" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?age=children", - "title": "Children (0-11)", - "type": "application/opds+json", - "properties": { - "numberOfItems": 800 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?age=teen", - "title": "Teen (12-18)", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1200 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?age=adult", - "title": "Adult (18+)", - "type": "application/opds+json", - "properties": { - "numberOfItems": 3000 - } - } - ] - } - ], - "publications": [ - { - "metadata": { - "title": "Sample Book", - "identifier": "urn:uuid:6409a00b-7bf2-405e-826c-3fdff0fd0734", - "modified": "2024-11-05T12:00:00Z", - "language": ["en"], - "published": "2024", - "author": [ - { - "name": "Sample Author" - } - ], - "subject": [ - { - "name": "Fiction", - "code": "fiction" - } - ] - }, - "links": [ - { - "rel": "http://opds-spec.org/acquisition", - "href": "/books/sample.epub", - "type": "application/epub+zip" - } - ] - } - ] - } - """ - guard let data = jsonString.data(using: .utf8) else { - return Data() - } - return data - } -} diff --git a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift b/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift deleted file mode 100644 index a0e0a9d969..0000000000 --- a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Copyright 2025 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 -import SwiftUI - -struct OPDSFacetLink: View { - let link: ReadiumShared.Link - - var body: some View { - HStack { - if let title = link.title { - Text(title) - .foregroundStyle(Color.primary) - } - - Spacer() - - if let count = link.properties.numberOfItems { - Text("\(count)") - .foregroundStyle(Color.secondary) - .font(.subheadline) - } - - Image(systemName: "chevron.right") - } - .font(.body) - } -} - -#Preview { - OPDSFacetLink( - link: Feed.preview.facets[0].links[0] - ) - .padding() -} diff --git a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift b/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift deleted file mode 100644 index ee73d979db..0000000000 --- a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Copyright 2025 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 -import SwiftUI - -struct OPDSFacetList: View { - @Environment(\.dismiss) private var dismiss - - let feed: Feed - let onLinkSelected: (ReadiumShared.Link) -> Void - - var body: some View { - NavigationView { - facets - .toolbar { cancelButton } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Facets") - } - } - - private var facets: some View { - List(feed.facets, id: \.metadata.title) { facet in - Section(facet.metadata.title) { - ForEach(facet.links, id: \.href) { link in - OPDSFacetLink(link: link) - .contentShape(Rectangle()) - .onTapGesture { - onLinkSelected(link) - dismiss() - } - } - } - } - } - - private var cancelButton: some ToolbarContent { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { dismiss() } - } - } -} - -#Preview { - OPDSFacetList(feed: .preview) { link in - print("Tap on link \(link.href)") - } -} diff --git a/TestApp/Sources/OPDS/OPDSFactory.swift b/TestApp/Sources/OPDS/OPDSFactory.swift index e10e41b3bb..89bba69bdd 100644 --- a/TestApp/Sources/OPDS/OPDSFactory.swift +++ b/TestApp/Sources/OPDS/OPDSFactory.swift @@ -17,16 +17,6 @@ final class OPDSFactory { private let storyboard = UIStoryboard(name: "OPDS", bundle: nil) } -extension OPDSFactory: OPDSRootTableViewControllerFactory { - func make(feedURL: URL, indexPath: IndexPath?) -> OPDSRootTableViewController { - let controller = storyboard.instantiateViewController(withIdentifier: "OPDSRootTableViewController") as! OPDSRootTableViewController - controller.factory = self - controller.originalFeedURL = feedURL - controller.originalFeedIndexPath = nil - return controller - } -} - extension OPDSFactory: OPDSPublicationInfoViewControllerFactory { func make(publication: Publication) -> OPDSPublicationInfoViewController { let controller = storyboard.instantiateViewController(withIdentifier: "OPDSPublicationInfoViewController") as! OPDSPublicationInfoViewController diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift new file mode 100644 index 0000000000..83424e4163 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift @@ -0,0 +1,50 @@ +// +// Copyright 2025 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 +import SwiftUI + +struct OPDSFacetView: View { + let facets: [Facet] + + /// This closure is called when a facet link is tapped. + /// The parent view (OPDSFeedView) will handle the navigation. + let onLinkTapped: (ReadiumShared.Link) -> Void + + /// The dismiss action provided by the environment. + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + ForEach(facets, id: \.metadata.title) { facet in + Section(header: Text(facet.metadata.title)) { + ForEach(facet.links, id: \.href) { link in + Button { + // When tapped, dismiss this sheet + // and tell the parent to navigate. + dismiss() + onLinkTapped(link) + } label: { + OPDSNavigationRow(link: link) + .foregroundColor(.primary) + } + } + } + } + } + .listStyle(.grouped) + .navigationTitle(NSLocalizedString("filter_button", comment: "Filter the OPDS feed")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(NSLocalizedString("ok_button", comment: "Alert button")) { + dismiss() + } + } + } + } + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift new file mode 100644 index 0000000000..1f1f799699 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift @@ -0,0 +1,278 @@ +// +// Copyright 2025 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 +import SwiftUI + +struct OPDSFeedView: View { + @StateObject private var viewModel: OPDSFeedViewModel + + private var delegate: OPDSModuleDelegate? + + @State private var facetNavigationURL: URL? + + struct NavigablePublication: Identifiable, Hashable { + let id: String + let publication: ReadiumShared.Publication + + init(publication: ReadiumShared.Publication, index: Int) { + self.publication = publication + id = "\(publication.manifest.hashValue)-\(index)" + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: NavigablePublication, rhs: NavigablePublication) -> Bool { + lhs.id == rhs.id + } + } + + /// Converts publications to NavigablePublications with unique IDs. + /// Each publication gets an ID in the format: hash-index + private func makeNavigablePublications(_ publications: [ReadiumShared.Publication]) -> [NavigablePublication] { + publications.enumerated().map { index, publication in + NavigablePublication(publication: publication, index: index) + } + } + + init(feedURL: URL, delegate: OPDSModuleDelegate?) { + _viewModel = StateObject(wrappedValue: OPDSFeedViewModel(feedURL: feedURL, delegate: delegate)) + self.delegate = delegate + } + + var body: some View { + mainContent + .navigationTitle(viewModel.feed?.metadata.title ?? "Loading...") + .navigationBarTitleDisplayMode(.inline) // Keeps title small + .onAppear { + if viewModel.feed == nil { + viewModel.parseFeed() + } + } + .toolbar { + buildToolbar() + } + .sheet(isPresented: $viewModel.isShowingFacets) { + buildFacetView() + } + .navigationDestination( + isPresented: Binding( + get: { facetNavigationURL != nil }, + set: { if !$0 { facetNavigationURL = nil } } + ) + ) { + facetDestinationView() + } + } + + @ViewBuilder + private var mainContent: some View { + Group { + // If the feed is only publications, show a grid. + if viewModel.isPublicationOnly { + buildPublicationOnlyView(viewModel.publications) + } else { + // Otherwise, show a list view. + buildListView() + } + } + } + + @ViewBuilder + private func facetDestinationView() -> some View { + if let url = facetNavigationURL { + OPDSFeedView(feedURL: url, delegate: delegate) + } else { + EmptyView() + } + } + + // MARK: - Toolbar & Sheet Builders + + @ToolbarContentBuilder + private func buildToolbar() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + if !(viewModel.feed?.facets.isEmpty ?? true) { + Button { + viewModel.isShowingFacets = true + } label: { + Text(NSLocalizedString("filter_button", comment: "Filter the OPDS feed")) + } + } + } + } + + @ViewBuilder + private func buildFacetView() -> some View { + OPDSFacetView(facets: viewModel.feed?.facets ?? []) { link in + if let url = URL(string: link.href) { + facetNavigationURL = url + } + } + } + + // MARK: - List View Builders + + @ViewBuilder + private func buildListView() -> some View { + ScrollView { + LazyVStack(spacing: 0) { + if viewModel.feed != nil { + if !viewModel.navigation.isEmpty { + buildNavigationSection(viewModel.navigation) + } + + if !viewModel.groups.isEmpty { + buildGroupsSection(viewModel.groups) + } + + if let group = viewModel.rootPublicationsGroup { + buildGroupsSection([group]) + } + + if !viewModel.hasContent { + buildNoneView() + .padding() + } + + } else if viewModel.error != nil { + Text("Failed to load feed. Please try again.") + .padding() + } else { + ProgressView() + .padding() + } + } + } + } + + @ViewBuilder + private func buildNoneView() -> some View { + if let error = viewModel.error { + Text("Failed to load feed: \(error.localizedDescription)") + } else { + Text("No content in this feed.") + } + } + + // MARK: - Publication Grid Builder + + @ViewBuilder + private func buildPublicationOnlyView(_ publications: [ReadiumShared.Publication]) -> some View { + let columns = [ + GridItem(.adaptive(minimum: 140), spacing: 16), + ] + let navPublications = makeNavigablePublications(publications) + + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(navPublications) { navPublication in + NavigationLink(value: navPublication) { + OPDSPublicationItemView(publication: navPublication.publication) + } + .buttonStyle(.plain) + .onAppear { + if navPublication == navPublications.last { + viewModel.loadNextPage() + } + } + } + } + .padding() + + if viewModel.isLoadingNextPage { + ProgressView() + .padding() + } + } + } + + // MARK: - Section Builders + + @ViewBuilder + private func buildNavigationSection(_ navigation: [ReadiumShared.Link]) -> some View { + HStack { + Text(NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds")) + .font(.title3.bold()) + .textCase(nil) + Spacer() + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 8) + + Divider() + .padding(.horizontal) + + buildNavigationList(navigation, isRootList: true) + } + + @ViewBuilder + private func buildGroupsSection(_ groups: [ReadiumShared.Group]) -> some View { + ForEach(Array(groups.enumerated()), id: \.element.metadata.title) { _, group in + HStack { + Text(group.metadata.title) + .font(.title3.bold()) + .textCase(nil) + + Spacer() + + if let moreLink = group.links.first, let url = URL(string: moreLink.href) { + NavigationLink(value: url) { + Text(NSLocalizedString("opds_more_button", comment: "Button to expand a feed gallery")) + .font(.title3.bold()) + .foregroundColor(.secondary) + } + } + } + .padding(.horizontal) + .padding(.top, 32) + .padding(.bottom, 8) + + if !group.publications.isEmpty { + let navPublications = makeNavigablePublications(group.publications) + + OPDSGroupRow( + group: group, + publications: navPublications, + isLoading: viewModel.isLoadingNextPage, + onLastItemAppeared: { + viewModel.loadNextPage() + } + ) + } else if !group.navigation.isEmpty { + Divider() + .padding(.horizontal) + + buildNavigationList(group.navigation, isRootList: false) + } + } + } + + @ViewBuilder + private func buildNavigationList(_ navigation: [ReadiumShared.Link], isRootList: Bool) -> some View { + ForEach(navigation.indices, id: \.self) { index in + let link = navigation[index] + + if let url = URL(string: link.href) { + NavigationLink(value: url) { + OPDSNavigationRow(link: link) + .padding(.horizontal) + } + .buttonStyle(.plain) + if isRootList { + Divider() + .padding(.horizontal) + } else { + Divider() + .padding(.leading) + } + } + } + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift new file mode 100644 index 0000000000..725862b877 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift @@ -0,0 +1,149 @@ +// +// Copyright 2025 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 Combine +import Foundation +import ReadiumOPDS +import ReadiumShared + +@MainActor +class OPDSFeedViewModel: ObservableObject { + let feedURL: URL + + @Published var feed: Feed? + @Published var error: Error? + @Published var isShowingFacets = false + + /// Tracks if a pagination request is in progress. + @Published var isLoadingNextPage = false + + weak var delegate: OPDSModuleDelegate? + + /// Stores the URL for the next page of results. + private var nextPageURL: URL? + + init(feedURL: URL, delegate: OPDSModuleDelegate?) { + self.feedURL = feedURL + self.delegate = delegate + } + + /// Fetches and parses the initial OPDS feed. + func parseFeed() { + feed = nil + error = nil + nextPageURL = nil // Reset next page URL + + OPDSParser.parseURL(url: feedURL) { [weak self] data, error in + DispatchQueue.main.async { + guard let self = self else { return } + + if let data = data, let feed = data.feed { + self.feed = feed + // Find and store the next page URL + self.nextPageURL = self.findNextPageURL(feed: feed) + } else if let error = error { + self.error = error + print("Failed to parse feed: \(error)") + } else { + self.error = OPDSError.invalidURL(self.feedURL.absoluteString) + } + } + } + } + + /// Fetches and parses the next page of the feed. + func loadNextPage() { + // Don't load if already loading or if there's no next page + guard !isLoadingNextPage, let url = nextPageURL else { + return + } + + isLoadingNextPage = true + + OPDSParser.parseURL(url: url) { [weak self] data, error in + DispatchQueue.main.async { + guard let self = self else { return } + + if let data = data, let newFeed = data.feed { + // Append new publications to the existing feed + self.feed?.publications.append(contentsOf: newFeed.publications) + // Find the *next* next page URL + self.nextPageURL = self.findNextPageURL(feed: newFeed) + } else if let error = error { + print("Failed to load next page: \(error)") + } + + self.isLoadingNextPage = false + } + } + } + + /// Finds the "next" link in the feed's links. + private func findNextPageURL(feed: Feed) -> URL? { + guard let href = feed.links.firstWithRel(.next)?.href else { + return nil + } + return URL(string: href) + } + + // MARK: - View-Ready Computed Properties + + /// Provides the navigation links, or an empty array. + var navigation: [ReadiumShared.Link] { + feed?.navigation ?? [] + } + + /// Provides the feed groups, or an empty array. + var groups: [ReadiumShared.Group] { + feed?.groups ?? [] + } + + /// Provides the publications, or an empty array. + var publications: [ReadiumShared.Publication] { + feed?.publications ?? [] + } + + /// True if the feed contains only publications and no navigation or groups. + /// The View uses this to decide whether to show a grid or a list. + var isPublicationOnly: Bool { + guard let feed = feed else { return false } + return !feed.publications.isEmpty + && feed.navigation.isEmpty + && feed.groups.isEmpty + } + + /// True if the feed contains any content at all. + var hasContent: Bool { + guard let feed = feed else { return false } + return !feed.navigation.isEmpty + || !feed.groups.isEmpty + || !feed.publications.isEmpty + } + + /// Creates a group for publications at the feed's root. + /// This allows the View to render them as just another group in the list. + var rootPublicationsGroup: ReadiumShared.Group? { + guard let feed = feed, !feed.publications.isEmpty else { + return nil + } + + if isPublicationOnly { + return nil + } + + let title: String + if feed.groups.isEmpty { + title = NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds") + } else { + title = feed.metadata.title + } + + // Create the group and assign publications + let pubGroup = ReadiumShared.Group(title: title) + pubGroup.publications = feed.publications + return pubGroup + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift new file mode 100644 index 0000000000..fa8daaff54 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift @@ -0,0 +1,47 @@ +// +// Copyright 2025 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 +import SwiftUI + +struct OPDSGroupRow: View { + let group: ReadiumShared.Group + + typealias NavigablePublication = OPDSFeedView.NavigablePublication + let publications: [NavigablePublication] + + let isLoading: Bool + let onLastItemAppeared: () -> Void + + private let rowHeight: CGFloat = 230 + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach(publications) { navPublication in + NavigationLink(value: navPublication) { + OPDSPublicationItemView(publication: navPublication.publication) + } + .buttonStyle(.plain) + .onAppear { + if navPublication == publications.last { + onLastItemAppeared() + } + } + } + + if isLoading { + ZStack { + ProgressView() + } + .frame(width: 140, height: rowHeight) + } + } + .padding(.horizontal) + } + .frame(height: rowHeight) + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift new file mode 100644 index 0000000000..3a22b81fea --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift @@ -0,0 +1,40 @@ +// +// Copyright 2025 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 +import SwiftUI + +/// A view for a single navigation link in an OPDS feed. +struct OPDSNavigationRow: View { + let link: ReadiumShared.Link + + var body: some View { + rowContent + } + + @ViewBuilder + private var rowContent: some View { + HStack { + Text(link.title ?? "Untitled") + .font(.body) + .padding(.vertical, 12) + .lineLimit(1) + + Spacer() + + if let count = link.properties.numberOfItems { + Text("\(count)") + .font(.body) + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.bold)) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + } + .contentShape(Rectangle()) + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift new file mode 100644 index 0000000000..7dac50ce96 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift @@ -0,0 +1,19 @@ +// +// Copyright 2025 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 +import SwiftUI + +/// A SwiftUI wrapper for the UIKit OPDSPublicationInfoViewController. +struct OPDSPublicationInfoView: UIViewControllerRepresentable { + let publication: Publication + + func makeUIViewController(context: Context) -> OPDSPublicationInfoViewController { + OPDSFactory.shared.make(publication: publication) + } + + func updateUIViewController(_ uiViewController: OPDSPublicationInfoViewController, context: Context) {} +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift new file mode 100644 index 0000000000..c993953579 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift @@ -0,0 +1,57 @@ +// +// Copyright 2025 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 +import SwiftUI + +struct OPDSPublicationItemView: View { + let publication: Publication + + private let coverHeight: CGFloat = 200 + private let coverWidth: CGFloat = 140 + + private var imageURL: URL? { + let primaryURL = publication.coverLink?.url(relativeTo: publication.baseURL).httpURL?.url + + let fallbackURL = publication.images.first?.url(relativeTo: publication.baseURL).httpURL?.url + + return primaryURL ?? fallbackURL + } + + var body: some View { + VStack(alignment: .leading) { + AsyncImage(url: imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color.gray.opacity(0.3) + .overlay(Image(systemName: "book.closed")) + } + .frame(width: coverWidth, height: coverHeight) + .clipped() + + Text(publication.metadata.title ?? "") + .font(.caption) + .lineLimit(2) + + Text(publication.metadata.authors.map(\.name).joined(separator: ", ")) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + .frame(width: coverWidth) + } +} + +private extension Publication { + /// Finds the first link with `cover` or thumbnail relations. + var coverLink: ReadiumShared.Link? { + links.firstWithRel(.cover) + ?? links.firstWithRel("http://opds-ps.org/image") + ?? links.firstWithRel("http://opds-ps.org/image/thumbnail") + } +} diff --git a/TestApp/Sources/OPDS/OPDSGroupCollectionViewCell.swift b/TestApp/Sources/OPDS/OPDSGroupCollectionViewCell.swift deleted file mode 100644 index c5e9e189ad..0000000000 --- a/TestApp/Sources/OPDS/OPDSGroupCollectionViewCell.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Copyright 2025 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 UIKit - -class OPDSGroupCollectionViewCell: UICollectionViewCell { - @IBOutlet var navigationTitleLabel: UILabel! - @IBOutlet var navigationCountLabel: UILabel! -} diff --git a/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift b/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift deleted file mode 100644 index 99cc77d2e4..0000000000 --- a/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// Copyright 2025 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 Kingfisher -import ReadiumShared -import UIKit - -class OPDSGroupTableViewCell: UITableViewCell { - var group: Group? - weak var opdsRootTableViewController: OPDSRootTableViewController? - weak var collectionView: UICollectionView? - - var browsingState: FeedBrowsingState = .None - - static let iPadLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 4, .landscape: 5] - static let iPhoneLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 3, .landscape: 4] - - lazy var layoutNumberPerRow: [UIUserInterfaceIdiom: [ScreenOrientation: Int]] = [ - .pad: OPDSGroupTableViewCell.iPadLayoutNumberPerRow, - .phone: OPDSGroupTableViewCell.iPhoneLayoutNumberPerRow, - ] - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } - - override func prepareForReuse() { - super.prepareForReuse() - collectionView?.setContentOffset(.zero, animated: false) - collectionView?.reloadData() - } - - override func layoutSubviews() { - super.layoutSubviews() - collectionView?.collectionViewLayout.invalidateLayout() - } -} - -extension OPDSGroupTableViewCell: UICollectionViewDataSource { - // MARK: - Collection view data source - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - var count = 0 - - if let group = group { - if group.publications.count > 0 { - count = group.publications.count - browsingState = .Publication - } else if group.navigation.count > 0 { - count = group.navigation.count - browsingState = .Navigation - } - } - - return count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - self.collectionView = collectionView - - if browsingState == .Publication { - collectionView.register(UINib(nibName: "PublicationCollectionViewCell", bundle: nil), - forCellWithReuseIdentifier: "publicationCollectionViewCell") - - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "publicationCollectionViewCell", - for: indexPath) as! PublicationCollectionViewCell - - cell.isAccessibilityElement = true - cell.accessibilityHint = NSLocalizedString("opds_show_detail_view_a11y_hint", comment: "Accessibility hint for OPDS publication cell") - - if let publication = group?.publications[indexPath.row] { - cell.accessibilityLabel = publication.metadata.title - - let titleTextView = OPDSPlaceholderListView( - frame: cell.frame, - title: publication.metadata.title, - author: publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - ) - - let coverURL: URL? = publication.linkWithRel(.cover)?.url(relativeTo: publication.baseURL).url - ?? publication.images.first.flatMap { URL(string: $0.href) } - - if let coverURL = coverURL { - cell.coverImageView.kf.setImage( - with: coverURL, - placeholder: titleTextView, - options: [.transition(ImageTransition.fade(0.5))], - progressBlock: nil - ) { _ in } - } - - cell.titleLabel.text = publication.metadata.title - cell.authorLabel.text = publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - } - - return cell - - } else { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "opdsNavigationCollectionViewCell", - for: indexPath) as! OPDSGroupCollectionViewCell - - if let navigation = group?.navigation[indexPath.row] { - cell.accessibilityLabel = navigation.title - - cell.navigationTitleLabel.text = navigation.title - if let count = navigation.properties.numberOfItems { - cell.navigationCountLabel.text = "\(count)" - } else { - cell.navigationCountLabel.text = "" - } - } - - return cell - } - } -} - -extension OPDSGroupTableViewCell: UICollectionViewDelegateFlowLayout { - // MARK: - Collection view delegate - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath) -> CGSize - { - if browsingState == .Publication { - let idiom = { () -> UIUserInterfaceIdiom in - let tempIdion = UIDevice.current.userInterfaceIdiom - return (tempIdion != .pad) ? .phone : .pad // ignnore carplay and others - }() - - guard let deviceLayoutNumberPerRow = layoutNumberPerRow[idiom] else { return CGSize(width: 0, height: 0) } - guard let numberPerRow = deviceLayoutNumberPerRow[.current] else { return CGSize(width: 0, height: 0) } - - let minimumSpacing: CGFloat = 5.0 - let labelHeight: CGFloat = 50.0 - let coverRatio: CGFloat = 1.5 - - let itemWidth = (collectionView.frame.width / CGFloat(numberPerRow)) - (CGFloat(minimumSpacing) * CGFloat(numberPerRow)) - minimumSpacing - let itemHeight = (itemWidth * coverRatio) + labelHeight - - return CGSize(width: itemWidth, height: itemHeight) - - } else { - return CGSize(width: 200, height: 50) - } - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - if browsingState == .Publication { - if let publication = group?.publications[indexPath.row] { - let opdsPublicationInfoViewController: OPDSPublicationInfoViewController = OPDSFactory.shared.make(publication: publication) - opdsRootTableViewController?.navigationController?.pushViewController(opdsPublicationInfoViewController, animated: true) - } - - } else { - if let href = group?.navigation[indexPath.row].href, let url = URL(string: href) { - let newOPDSRootTableViewController: OPDSRootTableViewController = OPDSFactory.shared.make(feedURL: url, indexPath: nil) - opdsRootTableViewController?.navigationController?.pushViewController(newOPDSRootTableViewController, animated: true) - } - } - } -} diff --git a/TestApp/Sources/OPDS/OPDSModule.swift b/TestApp/Sources/OPDS/OPDSModule.swift index d1d61049db..74b248d0f0 100644 --- a/TestApp/Sources/OPDS/OPDSModule.swift +++ b/TestApp/Sources/OPDS/OPDSModule.swift @@ -46,21 +46,29 @@ final class OPDSModule: OPDSModuleAPI { private(set) lazy var rootViewController: UINavigationController = { let viewModel = OPDSCatalogsViewModel() - let catalogViewController = UIHostingController( - rootView: OPDSCatalogsView(viewModel: viewModel) - ) + let rootView = NavigationStack { + OPDSCatalogsView(viewModel: viewModel, delegate: self.delegate) + .navigationDestination(for: OPDSCatalog.self) { catalog in + OPDSFeedView( + feedURL: catalog.url, + delegate: self.delegate + ) + } + .navigationDestination(for: URL.self) { url in + OPDSFeedView(feedURL: url, delegate: self.delegate) + } + .navigationDestination(for: OPDSFeedView.NavigablePublication.self) { navPublication in + OPDSPublicationInfoView(publication: navPublication.publication) + } + } - let navigationController = UINavigationController( - rootViewController: catalogViewController - ) + let catalogViewController = UIHostingController(rootView: rootView) - viewModel.openCatalog = { [weak navigationController] url, indexPath in - let viewController = OPDSFactory.shared.make( - feedURL: url, - indexPath: indexPath - ) - navigationController?.pushViewController(viewController, animated: true) - } + let navigationController = UINavigationController(rootViewController: catalogViewController) + + navigationController.isNavigationBarHidden = true + + viewModel.openCatalog = nil return navigationController }() diff --git a/TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift b/TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift deleted file mode 100644 index c5aa72ee42..0000000000 --- a/TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright 2025 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 UIKit - -class OPDSNavigationTableViewCell: UITableViewCell { - @IBOutlet var title: UILabel! - @IBOutlet var count: UILabel! - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } -} diff --git a/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift b/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift deleted file mode 100644 index 18452a7bb0..0000000000 --- a/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// Copyright 2025 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 Kingfisher -import ReadiumShared -import UIKit - -class OPDSPublicationTableViewCell: UITableViewCell { - @IBOutlet var collectionView: UICollectionView! - - var feed: Feed? - weak var opdsRootTableViewController: OPDSRootTableViewController? - - static let iPadLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 4, .landscape: 5] - static let iPhoneLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 3, .landscape: 4] - - lazy var layoutNumberPerRow: [UIUserInterfaceIdiom: [ScreenOrientation: Int]] = [ - .pad: OPDSPublicationTableViewCell.iPadLayoutNumberPerRow, - .phone: OPDSPublicationTableViewCell.iPhoneLayoutNumberPerRow, - ] - - override func awakeFromNib() { - super.awakeFromNib() - collectionView.register(UINib(nibName: "PublicationCollectionViewCell", bundle: nil), forCellWithReuseIdentifier: "publicationCollectionViewCell") - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } - - override func layoutSubviews() { - super.layoutSubviews() - collectionView.collectionViewLayout.invalidateLayout() - } -} - -extension OPDSPublicationTableViewCell: UICollectionViewDataSource { - // MARK: - Collection view data source - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - feed?.publications.count ?? 0 - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "publicationCollectionViewCell", - for: indexPath) as! PublicationCollectionViewCell - - cell.isAccessibilityElement = true - cell.accessibilityHint = NSLocalizedString("opds_show_detail_view_a11y_hint", comment: "Accessibility hint for OPDS publication cell") - - if let publications = feed?.publications, let publication = feed?.publications[indexPath.row] { - cell.accessibilityLabel = publication.metadata.title - - let titleTextView = OPDSPlaceholderListView( - frame: cell.frame, - title: publication.metadata.title, - author: publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - ) - - let coverURL: URL? = publication.linkWithRel(.cover)?.url(relativeTo: publication.baseURL).url - ?? publication.images.first.flatMap { URL(string: $0.href) } - - if let coverURL = coverURL { - cell.coverImageView.kf.setImage( - with: coverURL, - placeholder: titleTextView, - options: [.transition(ImageTransition.fade(0.5))], - progressBlock: nil - ) { _ in } - } else { - cell.coverImageView.addSubview(titleTextView) - } - - cell.titleLabel.text = publication.metadata.title - cell.authorLabel.text = publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - - if indexPath.row == publications.count - 3 { - opdsRootTableViewController?.loadNextPage(completionHandler: { feed in - self.feed = feed - collectionView.reloadData() - }) - } - } - - return cell - } -} - -extension OPDSPublicationTableViewCell: UICollectionViewDelegateFlowLayout { - // MARK: - Collection view delegate - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath) -> CGSize - { - let idiom = { () -> UIUserInterfaceIdiom in - let tempIdion = UIDevice.current.userInterfaceIdiom - return (tempIdion != .pad) ? .phone : .pad // ignnore carplay and others - }() - - guard let deviceLayoutNumberPerRow = layoutNumberPerRow[idiom] else { return CGSize(width: 0, height: 0) } - guard let numberPerRow = deviceLayoutNumberPerRow[.current] else { return CGSize(width: 0, height: 0) } - - let minimumSpacing: CGFloat = 5.0 - let labelHeight: CGFloat = 50.0 - let coverRatio: CGFloat = 1.5 - - let itemWidth = (collectionView.frame.width / CGFloat(numberPerRow)) - (CGFloat(minimumSpacing) * CGFloat(numberPerRow)) - minimumSpacing - let itemHeight = (itemWidth * coverRatio) + labelHeight - - return CGSize(width: itemWidth, height: itemHeight) - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - if let publication = feed?.publications[indexPath.row] { - let opdsPublicationInfoViewController: OPDSPublicationInfoViewController = OPDSFactory.shared.make(publication: publication) - opdsRootTableViewController?.navigationController?.pushViewController(opdsPublicationInfoViewController, animated: true) - } - } -} diff --git a/TestApp/Sources/OPDS/OPDSRootTableViewController.swift b/TestApp/Sources/OPDS/OPDSRootTableViewController.swift deleted file mode 100644 index a56bc3acf6..0000000000 --- a/TestApp/Sources/OPDS/OPDSRootTableViewController.swift +++ /dev/null @@ -1,603 +0,0 @@ -// -// Copyright 2025 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 ReadiumOPDS -import ReadiumShared -import SwiftUI -import UIKit - -enum FeedBrowsingState { - case Navigation - case Publication - case MixedGroup - case MixedNavigationPublication - case MixedNavigationGroup - case MixedNavigationGroupPublication - case None -} - -protocol OPDSRootTableViewControllerFactory { - func make(feedURL: URL, indexPath: IndexPath?) -> OPDSRootTableViewController -} - -class OPDSRootTableViewController: UITableViewController { - typealias Factory = - OPDSRootTableViewControllerFactory - - var factory: Factory! - var originalFeedURL: URL? - - var nextPageURL: URL? - var originalFeedIndexPath: IndexPath? - var mustEditFeed = false - - var parseData: ParseData? - var feed: Feed? - var publication: Publication? - - var browsingState: FeedBrowsingState = .None - - static let iPadLayoutHeightForRow: [ScreenOrientation: CGFloat] = [.portrait: 330, .landscape: 340] - static let iPhoneLayoutHeightForRow: [ScreenOrientation: CGFloat] = [.portrait: 230, .landscape: 280] - - lazy var layoutHeightForRow: [UIUserInterfaceIdiom: [ScreenOrientation: CGFloat]] = [ - .pad: OPDSRootTableViewController.iPadLayoutHeightForRow, - .phone: OPDSRootTableViewController.iPhoneLayoutHeightForRow, - ] - - override func viewDidLoad() { - super.viewDidLoad() - navigationController?.delegate = self - - parseFeed() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - tableView.reloadData() - } - - // MARK: - OPDS feed parsing - - func parseFeed() { - if let url = originalFeedURL { - OPDSParser.parseURL(url: url) { data, _ in - DispatchQueue.main.async { - if let data = data { - self.parseData = data - } - self.finishFeedInitialization() - } - } - } - } - - func finishFeedInitialization() { - if let feed = parseData?.feed { - self.feed = feed - - navigationItem.title = feed.metadata.title - nextPageURL = findNextPageURL(feed: feed) - - if feed.facets.count > 0 { - let filterButton = UIBarButtonItem( - title: NSLocalizedString("filter_button", comment: "Filter the OPDS feed"), - style: UIBarButtonItem.Style.plain, - target: self, - action: #selector(OPDSRootTableViewController.filterMenuClicked) - ) - navigationItem.rightBarButtonItem = filterButton - } - - // Check feed compozition. Then, browsingState will be used to build the UI. - if feed.navigation.count > 0, feed.groups.count == 0, feed.publications.count == 0 { - browsingState = .Navigation - } else if feed.publications.count > 0, feed.groups.count == 0, feed.navigation.count == 0 { - browsingState = .Publication - tableView.separatorStyle = .none - tableView.isScrollEnabled = false - } else if feed.groups.count > 0, feed.publications.count == 0, feed.navigation.count == 0 { - browsingState = .MixedGroup - } else if feed.navigation.count > 0, feed.groups.count == 0, feed.publications.count > 0 { - browsingState = .MixedNavigationPublication - } else if feed.navigation.count > 0, feed.groups.count > 0, feed.publications.count == 0 { - browsingState = .MixedNavigationGroup - } else if feed.navigation.count > 0, feed.groups.count > 0, feed.publications.count > 0 { - browsingState = .MixedNavigationGroupPublication - } else { - browsingState = .None - } - - } else { - tableView.backgroundView = UIView(frame: UIScreen.main.bounds) - tableView.separatorStyle = .none - - let frame = CGRect(x: 0, y: tableView.backgroundView!.bounds.height / 2, width: tableView.backgroundView!.bounds.width, height: 20) - - let messageLabel = UILabel(frame: frame) - messageLabel.textColor = UIColor.darkGray - messageLabel.textAlignment = .center - messageLabel.text = NSLocalizedString("opds_failure_message", comment: "Error message when the feed couldn't be loaded") - - let editButton = UIButton(type: .system) - editButton.frame = frame - editButton.setTitle(NSLocalizedString("opds_edit_button", comment: "Button to edit the OPDS catalog"), for: .normal) - editButton.addTarget(self, action: #selector(editButtonClicked), for: .touchUpInside) - editButton.isHidden = originalFeedIndexPath == nil ? true : false - - let stackView = UIStackView(arrangedSubviews: [messageLabel, editButton]) - stackView.axis = .vertical - stackView.distribution = .equalSpacing - let spacing: CGFloat = 15 - stackView.spacing = spacing - - tableView.backgroundView?.addSubview(stackView) - - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.widthAnchor.constraint(equalTo: tableView.backgroundView!.widthAnchor).isActive = true - stackView.heightAnchor.constraint(equalToConstant: messageLabel.frame.height + editButton.frame.height + spacing).isActive = true - stackView.centerYAnchor.constraint(equalTo: tableView.backgroundView!.centerYAnchor).isActive = true - } - - DispatchQueue.main.async { - self.tableView.reloadData() - } - } - - @objc func editButtonClicked(_ sender: UIBarButtonItem) { - mustEditFeed = true - navigationController?.popViewController(animated: true) - } - - func findNextPageURL(feed: Feed) -> URL? { - guard let href = feed.links.firstWithRel(.next)?.href else { - return nil - } - return URL(string: href) - } - - public func loadNextPage(completionHandler: @escaping (Feed?) -> Void) { - if let nextPageURL = nextPageURL { - OPDSParser.parseURL(url: nextPageURL) { data, _ in - DispatchQueue.main.async { - guard let newFeed = data?.feed else { - return - } - - self.nextPageURL = self.findNextPageURL(feed: newFeed) - self.feed?.publications.append(contentsOf: newFeed.publications) - completionHandler(self.feed) - } - } - } - } - - // MARK: - Facets - - @objc func filterMenuClicked(_ sender: UIBarButtonItem) { - guard let feed = feed else { - return - } - - let facetViewController = UIHostingController(rootView: OPDSFacetList( - feed: feed, - onLinkSelected: { [weak self] link in - self?.pushOpdsRootViewController(href: link.href) - } - )) - - facetViewController.modalPresentationStyle = UIModalPresentationStyle.popover - - present(facetViewController, animated: true, completion: nil) - - if let popoverPresentationController = facetViewController.popoverPresentationController { - popoverPresentationController.barButtonItem = sender - } - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - var numberOfSections = 0 - - switch browsingState { - case .Navigation, .Publication: - numberOfSections = 1 - - case .MixedGroup: - numberOfSections = feed!.groups.count - - case .MixedNavigationPublication: - numberOfSections = 2 - - case .MixedNavigationGroup: - // 1 section for the nav + groups count for the next sections - numberOfSections = 1 + feed!.groups.count - - case .MixedNavigationGroupPublication: - numberOfSections = 1 + feed!.groups.count + 1 - - default: - numberOfSections = 0 - } - - return numberOfSections - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - var numberOfRowsInSection = 0 - - switch browsingState { - case .Navigation: - numberOfRowsInSection = feed!.navigation.count - - case .Publication: - numberOfRowsInSection = 1 - - case .MixedGroup: - if feed!.groups[section].navigation.count > 0 { - numberOfRowsInSection = feed!.groups[section].navigation.count - } else { - numberOfRowsInSection = 1 - } - - case .MixedNavigationPublication: - if section == 0 { - numberOfRowsInSection = feed!.navigation.count - } - if section == 1 { - numberOfRowsInSection = 1 - } - - case .MixedNavigationGroup: - // Nav - if section == 0 { - numberOfRowsInSection = feed!.navigation.count - } - // Groups - if section >= 1, section <= feed!.groups.count { - if feed!.groups[section - 1].navigation.count > 0 { - // Nav inside a group - numberOfRowsInSection = feed!.groups[section - 1].navigation.count - } else { - // No nav inside a group - numberOfRowsInSection = 1 - } - } - - case .MixedNavigationGroupPublication: - if section == 0 { - numberOfRowsInSection = feed!.navigation.count - } - if section >= 1, section <= feed!.groups.count { - if feed!.groups[section - 1].navigation.count > 0 { - numberOfRowsInSection = feed!.groups[section - 1].navigation.count - } else { - numberOfRowsInSection = 1 - } - } - if section == (feed!.groups.count + 1) { - numberOfRowsInSection = 1 - } - - default: - numberOfRowsInSection = 0 - } - - return numberOfRowsInSection - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - var heightForRowAt: CGFloat = 0.0 - - switch browsingState { - case .Publication: - heightForRowAt = tableView.bounds.height - - case .MixedGroup: - if feed!.groups[indexPath.section].navigation.count > 0 { - heightForRowAt = 44 - } else { - heightForRowAt = calculateRowHeightForGroup(feed!.groups[indexPath.section]) - } - - case .MixedNavigationPublication: - if indexPath.section == 0 { - heightForRowAt = 44 - } else { - heightForRowAt = tableView.bounds.height / 2 - } - - case .MixedNavigationGroup: - // Nav - if indexPath.section == 0 { - heightForRowAt = 44 - // Group - } else { - // Nav inside a group - if feed!.groups[indexPath.section - 1].navigation.count > 0 { - heightForRowAt = 44 - } else { - // No nav inside a group - heightForRowAt = calculateRowHeightForGroup(feed!.groups[indexPath.section - 1]) - } - } - - case .MixedNavigationGroupPublication: - if indexPath.section == 0 { - heightForRowAt = 44 - } else if indexPath.section >= 1, indexPath.section <= feed!.groups.count { - if feed!.groups[indexPath.section - 1].navigation.count > 0 { - heightForRowAt = 44 - } else { - heightForRowAt = calculateRowHeightForGroup(feed!.groups[indexPath.section - 1]) - } - } else { - let group = ReadiumShared.Group(title: feed!.metadata.title) - group.publications = feed!.publications - heightForRowAt = calculateRowHeightForGroup(group) - } - - default: - heightForRowAt = 44 - } - - return heightForRowAt - } - - fileprivate func calculateRowHeightForGroup(_ group: ReadiumShared.Group) -> CGFloat { - if group.navigation.count > 0 { - return tableView.bounds.height / 2 - - } else { - let idiom = { () -> UIUserInterfaceIdiom in - let tempIdion = UIDevice.current.userInterfaceIdiom - return (tempIdion != .pad) ? .phone : .pad // ignnore carplay and others - }() - - guard let deviceLayoutHeightForRow = layoutHeightForRow[idiom] else { return 44 } - guard let heightForRow = deviceLayoutHeightForRow[.current] else { return 44 } - - return heightForRow - } - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - var title: String? - - switch browsingState { - case .MixedGroup: - if section >= 0, section <= feed!.groups.count { - title = feed!.groups[section].metadata.title - } - - case .MixedNavigationGroup: - // Nav - if section == 0 { - title = NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds") - } - // Groups - if section >= 1, section <= feed!.groups.count { - title = feed!.groups[section - 1].metadata.title - } - - case .MixedNavigationGroupPublication: - if section == 0 { - title = NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds") - } - if section >= 1, section <= feed!.groups.count { - title = feed!.groups[section - 1].metadata.title - } - if section > feed!.groups.count { - title = feed!.metadata.title - } - - default: - title = nil - } - - return title - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - var cell: UITableViewCell? - - switch browsingState { - case .Navigation: - cell = buildNavigationCell(tableView: tableView, indexPath: indexPath) - - case .Publication: - cell = buildPublicationCell(tableView: tableView, indexPath: indexPath) - - case .MixedGroup: - cell = buildGroupCell(tableView: tableView, indexPath: indexPath) - - case .MixedNavigationPublication: - if indexPath.section == 0 { - cell = buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - cell = buildPublicationCell(tableView: tableView, indexPath: indexPath) - } - - case .MixedNavigationGroup, .MixedNavigationGroupPublication: - if indexPath.section == 0 { - // Nav - cell = buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - // Groups - cell = buildGroupCell(tableView: tableView, indexPath: indexPath) - } - - default: - cell = nil - } - - return cell! - } - - func buildNavigationCell(tableView: UITableView, indexPath: IndexPath) -> OPDSNavigationTableViewCell { - let castedCell = tableView.dequeueReusableCell(withIdentifier: "opdsNavigationCell", for: indexPath) as! OPDSNavigationTableViewCell - - var currentNavigation: [ReadiumShared.Link]? - - if let navigation = feed?.navigation, navigation.count > 0 { - currentNavigation = navigation - } else { - if let navigation = feed?.groups[indexPath.section].navigation, navigation.count > 0 { - currentNavigation = navigation - } - } - - if let currentNavigation = currentNavigation { - castedCell.title.text = currentNavigation[indexPath.row].title - if let count = currentNavigation[indexPath.row].properties.numberOfItems { - castedCell.count.text = "\(count)" - } else { - castedCell.count.text = "" - } - } - - return castedCell - } - - func buildPublicationCell(tableView: UITableView, indexPath: IndexPath) -> OPDSPublicationTableViewCell { - let castedCell = tableView.dequeueReusableCell(withIdentifier: "opdsPublicationCell", for: indexPath) as! OPDSPublicationTableViewCell - castedCell.feed = feed - castedCell.opdsRootTableViewController = self - return castedCell - } - - func buildGroupCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { - if browsingState != .MixedGroup { - if indexPath.section > feed!.groups.count { - let group = ReadiumShared.Group(title: feed!.metadata.title) - group.publications = feed!.publications - return preparedGroupCell(group: group, indexPath: indexPath, offset: 0) - } else { - if feed!.groups[indexPath.section - 1].navigation.count > 0 { - return buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - return preparedGroupCell(group: nil, indexPath: indexPath, offset: 1) - } - } - } else { - if feed!.groups[indexPath.section].navigation.count > 0 { - return buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - return preparedGroupCell(group: nil, indexPath: indexPath, offset: 0) - } - } - } - - fileprivate func preparedGroupCell(group: ReadiumShared.Group?, indexPath: IndexPath, offset: Int) -> OPDSGroupTableViewCell { - let castedCell = tableView.dequeueReusableCell(withIdentifier: "opdsGroupCell", for: indexPath) as! OPDSGroupTableViewCell - castedCell.group = group != nil ? group : feed?.groups[indexPath.section - offset] - castedCell.opdsRootTableViewController = self - return castedCell - } - - // MARK: - Table view delegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - switch browsingState { - case .Navigation, .MixedNavigationPublication, .MixedNavigationGroup, .MixedNavigationGroupPublication: - var link: ReadiumShared.Link? - if indexPath.section == 0 { - link = feed!.navigation[indexPath.row] - } else if indexPath.section >= 1, indexPath.section <= feed!.groups.count, feed!.groups[indexPath.section - 1].navigation.count > 0 { - link = feed!.groups[indexPath.section - 1].navigation[indexPath.row] - } - - if let link = link { - pushOpdsRootViewController(href: link.href) - } - - default: - break - } - } - - override func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - let header = view as! UITableViewHeaderFooterView - header.isAccessibilityElement = false - - header.textLabel?.font = UIFont.boldSystemFont(ofSize: 13) - header.textLabel?.accessibilityHint = NSLocalizedString("opds_feed_header_a11y_hint", comment: "Accessibility hint feed section header") - - var offset: Int - - if browsingState != .MixedGroup { - offset = section - 1 - } else { - offset = section - } - - if let feed = feed { - if let moreButton = view.subviews.last as? OPDSMoreButton { - if offset >= 0, offset < feed.groups.count { - moreButton.offset = offset - } else { - view.subviews.last?.removeFromSuperview() - } - return - } - - if offset >= 0, offset < feed.groups.count { - let links = feed.groups[offset].links - if links.count > 0 { - let buttonWidth: CGFloat = 70 - let moreButton = OPDSMoreButton(type: .system) - moreButton.frame = CGRect(x: header.frame.width - buttonWidth, y: 0, width: buttonWidth, height: header.frame.height) - - moreButton.setTitle(NSLocalizedString("opds_more_button", comment: "Button to expand a feed gallery"), for: .normal) - moreButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 11) - moreButton.setTitleColor(UIColor.darkGray, for: .normal) - - moreButton.offset = offset - moreButton.addTarget(self, action: #selector(moreAction), for: .touchUpInside) - - moreButton.isAccessibilityElement = true - moreButton.accessibilityLabel = NSLocalizedString("opds_more_button_a11y_label", comment: "Button to expand a feed gallery") - - view.addSubview(moreButton) - - moreButton.translatesAutoresizingMaskIntoConstraints = false - moreButton.widthAnchor.constraint(equalToConstant: buttonWidth).isActive = true - moreButton.heightAnchor.constraint(equalToConstant: header.frame.height).isActive = true - moreButton.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - } - } - } - } - - // MARK: - Target action - - @objc func moreAction(sender: UIButton!) { - if let moreButton = sender as? OPDSMoreButton { - if let href = feed?.groups[moreButton.offset!].links[0].href { - pushOpdsRootViewController(href: href) - } - } - } -} - -// MARK: - UINavigationController delegate and tooling - -extension OPDSRootTableViewController: UINavigationControllerDelegate { - fileprivate func pushOpdsRootViewController(href: String) { - guard let url = URL(string: href) else { - return - } - - let viewController: OPDSRootTableViewController = factory.make(feedURL: url, indexPath: nil) - navigationController?.pushViewController(viewController, animated: true) - } -} - -// MARK: - Sublass of UIButton - -class OPDSMoreButton: UIButton { - var offset: Int? -} diff --git a/TestApp/Sources/Reader/Common/TTS/TTSView.swift b/TestApp/Sources/Reader/Common/TTS/TTSView.swift index 9fc69e142d..a8b8a385c0 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSView.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSView.swift @@ -125,7 +125,7 @@ private extension Optional where Wrapped == TTSVoice { guard case let .some(voice) = self else { return "Default" } - var desc = voice.name ?? "Voice" + var desc = voice.name if let region = voice.language.localizedRegion() { desc += " (\(region))" } From 5634c871368177694fec224a492a2191da3f67e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 17 Dec 2025 14:45:18 +0100 Subject: [PATCH 13/55] 3.6.0 (#685) --- CHANGELOG.md | 5 ++++- README.md | 12 ++++++------ Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec | 6 +++--- Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec | 6 +++--- Support/CocoaPods/ReadiumInternal.podspec | 2 +- Support/CocoaPods/ReadiumLCP.podspec | 6 +++--- Support/CocoaPods/ReadiumNavigator.podspec | 6 +++--- Support/CocoaPods/ReadiumOPDS.podspec | 6 +++--- Support/CocoaPods/ReadiumShared.podspec | 4 ++-- Support/CocoaPods/ReadiumStreamer.podspec | 6 +++--- TestApp/Sources/Info.plist | 4 ++-- 11 files changed, 33 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f3c743c4..c8a5d0b0ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. -## [Unreleased] + + +## [3.6.0] ### Added @@ -1054,3 +1056,4 @@ progression. Now if no reading progression is set, the `effectiveReadingProgress [3.3.0]: https://github.com/readium/swift-toolkit/compare/3.2.0...3.3.0 [3.4.0]: https://github.com/readium/swift-toolkit/compare/3.3.0...3.4.0 [3.5.0]: https://github.com/readium/swift-toolkit/compare/3.4.0...3.5.0 +[3.6.0]: https://github.com/readium/swift-toolkit/compare/3.5.0...3.6.0 diff --git a/README.md b/README.md index c7e470bc41..542e42cc31 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ If you're stuck, find more information at [developer.apple.com](https://develope Add the following to your `Cartfile`: ``` -github "readium/swift-toolkit" ~> 3.5.0 +github "readium/swift-toolkit" ~> 3.6.0 ``` Then, [follow the usual Carthage steps](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) to add the Readium libraries to your project. @@ -143,11 +143,11 @@ Add the following `pod` statements to your `Podfile` for the Readium libraries y source 'https://github.com/readium/podspecs' source 'https://cdn.cocoapods.org/' -pod 'ReadiumShared', '~> 3.5.0' -pod 'ReadiumStreamer', '~> 3.5.0' -pod 'ReadiumNavigator', '~> 3.5.0' -pod 'ReadiumOPDS', '~> 3.5.0' -pod 'ReadiumLCP', '~> 3.5.0' +pod 'ReadiumShared', '~> 3.6.0' +pod 'ReadiumStreamer', '~> 3.6.0' +pod 'ReadiumNavigator', '~> 3.6.0' +pod 'ReadiumOPDS', '~> 3.6.0' +pod 'ReadiumLCP', '~> 3.6.0' ``` Take a look at [CocoaPods's documentation](https://guides.cocoapods.org/using/using-cocoapods.html) for more information. diff --git a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec index 91611f4820..504c41a6c0 100644 --- a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec +++ b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterGCDWebServer" - s.version = "3.5.0" + s.version = "3.6.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use GCDWebServer as an HTTP server in Readium" s.homepage = "http://readium.github.io" @@ -14,8 +14,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "13.4" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumShared', '~> 3.5.0' - s.dependency 'ReadiumInternal', '~> 3.5.0' + s.dependency 'ReadiumShared', '~> 3.6.0' + s.dependency 'ReadiumInternal', '~> 3.6.0' s.dependency 'ReadiumGCDWebServer', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec index 6d68c28e4f..8b02c8a46b 100644 --- a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec +++ b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterLCPSQLite" - s.version = "3.5.0" + s.version = "3.6.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use SQLite.swift for the Readium LCP repositories" s.homepage = "http://readium.github.io" @@ -14,8 +14,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "13.4" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumLCP', '~> 3.5.0' - s.dependency 'ReadiumShared', '~> 3.5.0' + s.dependency 'ReadiumLCP', '~> 3.6.0' + s.dependency 'ReadiumShared', '~> 3.6.0' s.dependency 'SQLite.swift', '~> 0.15.0' end diff --git a/Support/CocoaPods/ReadiumInternal.podspec b/Support/CocoaPods/ReadiumInternal.podspec index cce2e9f236..076b09ec34 100644 --- a/Support/CocoaPods/ReadiumInternal.podspec +++ b/Support/CocoaPods/ReadiumInternal.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumInternal" - s.version = "3.5.0" + s.version = "3.6.0" s.license = "BSD 3-Clause License" s.summary = "Private utilities used by the Readium modules" s.homepage = "http://readium.github.io" diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index 9811146bb9..b85c6ad2e8 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumLCP" - s.version = "3.5.0" + s.version = "3.6.0" s.license = "BSD 3-Clause License" s.summary = "Readium LCP" s.homepage = "http://readium.github.io" @@ -20,8 +20,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "13.4" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2'} - s.dependency 'ReadiumShared' , '~> 3.5.0' - s.dependency 'ReadiumInternal', '~> 3.5.0' + s.dependency 'ReadiumShared' , '~> 3.6.0' + s.dependency 'ReadiumInternal', '~> 3.6.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' s.dependency 'CryptoSwift', '~> 1.8.0' end diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index d301bce653..7a0ce1a3d4 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumNavigator" - s.version = "3.5.0" + s.version = "3.6.0" s.license = "BSD 3-Clause License" s.summary = "Readium Navigator" s.homepage = "http://readium.github.io" @@ -19,8 +19,8 @@ Pod::Spec.new do |s| s.platform = :ios s.ios.deployment_target = "13.4" - s.dependency 'ReadiumShared', '~> 3.5.0' - s.dependency 'ReadiumInternal', '~> 3.5.0' + s.dependency 'ReadiumShared', '~> 3.6.0' + s.dependency 'ReadiumInternal', '~> 3.6.0' s.dependency 'DifferenceKit', '~> 1.0' s.dependency 'SwiftSoup', '~> 2.7.0' diff --git a/Support/CocoaPods/ReadiumOPDS.podspec b/Support/CocoaPods/ReadiumOPDS.podspec index 7fee3384b5..b90994b03e 100644 --- a/Support/CocoaPods/ReadiumOPDS.podspec +++ b/Support/CocoaPods/ReadiumOPDS.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumOPDS" - s.version = "3.5.0" + s.version = "3.6.0" s.license = "BSD 3-Clause License" s.summary = "Readium OPDS" s.homepage = "http://readium.github.io" @@ -14,8 +14,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "13.4" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumShared', '~> 3.5.0' - s.dependency 'ReadiumInternal', '~> 3.5.0' + s.dependency 'ReadiumShared', '~> 3.6.0' + s.dependency 'ReadiumInternal', '~> 3.6.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index 4e82570632..614acf9cea 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumShared" - s.version = "3.5.0" + s.version = "3.6.0" s.license = "BSD 3-Clause License" s.summary = "Readium Shared" s.homepage = "http://readium.github.io" @@ -23,6 +23,6 @@ Pod::Spec.new do |s| s.dependency 'SwiftSoup', '~> 2.7.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' - s.dependency 'ReadiumInternal', '~> 3.5.0' + s.dependency 'ReadiumInternal', '~> 3.6.0' end diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index 0b3bf79cef..614658c963 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumStreamer" - s.version = "3.5.0" + s.version = "3.6.0" s.license = "BSD 3-Clause License" s.summary = "Readium Streamer" s.homepage = "http://readium.github.io" @@ -22,8 +22,8 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.dependency 'ReadiumFuzi', '~> 4.0.0' - s.dependency 'ReadiumShared', '~> 3.5.0' - s.dependency 'ReadiumInternal', '~> 3.5.0' + s.dependency 'ReadiumShared', '~> 3.6.0' + s.dependency 'ReadiumInternal', '~> 3.6.0' s.dependency 'CryptoSwift', '~> 1.8.0' end diff --git a/TestApp/Sources/Info.plist b/TestApp/Sources/Info.plist index eb0d3f5ca9..3b9725df93 100644 --- a/TestApp/Sources/Info.plist +++ b/TestApp/Sources/Info.plist @@ -252,9 +252,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 3.5.0 + 3.6.0 CFBundleVersion - 3.5.0 + 3.6.0 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace From e1b85c0906fc3bbf0ad6632d25a45cd11e25fc57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 7 Jan 2026 15:26:49 +0100 Subject: [PATCH 14/55] Update file headers for 2026 (#689) --- .github/workflows/checks.yml | 14 +++++++------- BuildTools/Empty.swift | 2 +- Sources/Adapters/GCDWebServer/GCDHTTPServer.swift | 2 +- .../Adapters/GCDWebServer/ResourceResponse.swift | 2 +- Sources/Adapters/LCPSQLite/Database.swift | 2 +- .../LCPSQLite/SQLiteLCPLicenseRepository.swift | 2 +- .../LCPSQLite/SQLiteLCPPassphraseRepository.swift | 2 +- Sources/Internal/Extensions/Array.swift | 2 +- Sources/Internal/Extensions/Collection.swift | 2 +- Sources/Internal/Extensions/Comparable.swift | 2 +- Sources/Internal/Extensions/Data.swift | 2 +- Sources/Internal/Extensions/Date+ISO8601.swift | 2 +- Sources/Internal/Extensions/Double.swift | 2 +- .../Internal/Extensions/NSRegularExpression.swift | 2 +- Sources/Internal/Extensions/Number.swift | 2 +- Sources/Internal/Extensions/Optional.swift | 2 +- Sources/Internal/Extensions/Range.swift | 2 +- Sources/Internal/Extensions/Result.swift | 2 +- Sources/Internal/Extensions/Sequence.swift | 2 +- Sources/Internal/Extensions/String.swift | 2 +- Sources/Internal/Extensions/Task.swift | 2 +- Sources/Internal/Extensions/UInt64.swift | 2 +- Sources/Internal/Extensions/URL.swift | 2 +- Sources/Internal/JSON.swift | 2 +- Sources/Internal/Measure.swift | 2 +- Sources/Internal/UTI.swift | 2 +- .../LCP/Authentications/LCPAuthenticating.swift | 2 +- Sources/LCP/Authentications/LCPDialog.swift | 2 +- .../Authentications/LCPDialogAuthentication.swift | 2 +- .../Authentications/LCPDialogViewController.swift | 2 +- .../LCPObservableAuthentication.swift | 2 +- .../LCPPassphraseAuthentication.swift | 2 +- .../LCP/Content Protection/EncryptionParser.swift | 2 +- .../Content Protection/LCPContentProtection.swift | 2 +- Sources/LCP/Content Protection/LCPDecryptor.swift | 2 +- Sources/LCP/LCPAcquiredPublication.swift | 2 +- Sources/LCP/LCPClient.swift | 2 +- Sources/LCP/LCPError.swift | 2 +- Sources/LCP/LCPLicense.swift | 2 +- Sources/LCP/LCPLicenseRepository.swift | 2 +- Sources/LCP/LCPPassphraseRepository.swift | 2 +- Sources/LCP/LCPProgress.swift | 2 +- Sources/LCP/LCPRenewDelegate.swift | 2 +- Sources/LCP/LCPService.swift | 2 +- .../Container/ContainerLicenseContainer.swift | 2 +- .../LCP/License/Container/LicenseContainer.swift | 2 +- .../Container/ResourceLicenseContainer.swift | 2 +- Sources/LCP/License/LCPError+wrap.swift | 2 +- Sources/LCP/License/License.swift | 2 +- Sources/LCP/License/LicenseValidation.swift | 2 +- .../License/Model/Components/LCP/ContentKey.swift | 2 +- .../License/Model/Components/LCP/Encryption.swift | 2 +- .../LCP/License/Model/Components/LCP/Rights.swift | 2 +- .../License/Model/Components/LCP/Signature.swift | 2 +- .../LCP/License/Model/Components/LCP/User.swift | 2 +- .../LCP/License/Model/Components/LCP/UserKey.swift | 2 +- .../LCP/License/Model/Components/LSD/Event.swift | 2 +- .../Model/Components/LSD/PotentialRights.swift | 2 +- Sources/LCP/License/Model/Components/Link.swift | 2 +- Sources/LCP/License/Model/Components/Links.swift | 2 +- Sources/LCP/License/Model/LicenseDocument.swift | 2 +- Sources/LCP/License/Model/StatusDocument.swift | 2 +- Sources/LCP/Services/CRLService.swift | 2 +- Sources/LCP/Services/DeviceService.swift | 2 +- Sources/LCP/Services/LicensesService.swift | 2 +- Sources/LCP/Services/PassphrasesService.swift | 2 +- Sources/LCP/Toolkit/Bundle.swift | 2 +- .../LCP/Toolkit/ReadiumLCPLocalizedString.swift | 2 +- Sources/LCP/Toolkit/Streamable.swift | 2 +- Sources/Navigator/Audiobook/AudioNavigator.swift | 2 +- .../Audiobook/Preferences/AudioPreferences.swift | 2 +- .../Preferences/AudioPreferencesEditor.swift | 2 +- .../Audiobook/Preferences/AudioSettings.swift | 2 +- .../Audiobook/PublicationMediaLoader.swift | 2 +- .../Navigator/CBZ/CBZNavigatorViewController.swift | 2 +- Sources/Navigator/CBZ/ImageViewController.swift | 2 +- .../Navigator/Decorator/DecorableNavigator.swift | 2 +- .../Navigator/Decorator/DiffableDecoration.swift | 2 +- .../Navigator/DirectionalNavigationAdapter.swift | 2 +- Sources/Navigator/EPUB/CSS/CSSLayout.swift | 2 +- Sources/Navigator/EPUB/CSS/CSSProperties.swift | 2 +- .../EPUB/CSS/HTMLFontFamilyDeclaration.swift | 2 +- Sources/Navigator/EPUB/CSS/ReadiumCSS.swift | 2 +- .../Navigator/EPUB/DiffableDecoration+HTML.swift | 2 +- Sources/Navigator/EPUB/EPUBFixedSpreadView.swift | 2 +- .../EPUB/EPUBNavigatorViewController.swift | 2 +- .../Navigator/EPUB/EPUBNavigatorViewModel.swift | 2 +- .../Navigator/EPUB/EPUBReflowableSpreadView.swift | 2 +- Sources/Navigator/EPUB/EPUBSpread.swift | 2 +- Sources/Navigator/EPUB/EPUBSpreadView.swift | 2 +- .../Navigator/EPUB/HTMLDecorationTemplate.swift | 2 +- .../EPUB/Preferences/EPUBPreferences+Legacy.swift | 2 +- .../EPUB/Preferences/EPUBPreferences.swift | 2 +- .../EPUB/Preferences/EPUBPreferencesEditor.swift | 2 +- .../Navigator/EPUB/Preferences/EPUBSettings.swift | 2 +- Sources/Navigator/EditingAction.swift | 2 +- .../Navigator/Input/CompositeInputObserver.swift | 2 +- .../Navigator/Input/InputObservable+Legacy.swift | 2 +- Sources/Navigator/Input/InputObservable.swift | 2 +- .../Input/InputObservableViewController.swift | 2 +- Sources/Navigator/Input/InputObserving.swift | 2 +- .../InputObservingGestureRecognizerAdapter.swift | 2 +- Sources/Navigator/Input/Key/Key.swift | 2 +- Sources/Navigator/Input/Key/KeyEvent.swift | 2 +- Sources/Navigator/Input/Key/KeyModifiers.swift | 2 +- Sources/Navigator/Input/Key/KeyObserver.swift | 2 +- .../Input/Pointer/ActivatePointerObserver.swift | 2 +- .../Input/Pointer/DragPointerObserver.swift | 2 +- Sources/Navigator/Input/Pointer/PointerEvent.swift | 2 +- Sources/Navigator/Navigator.swift | 2 +- Sources/Navigator/PDF/PDFDocumentHolder.swift | 2 +- Sources/Navigator/PDF/PDFDocumentView.swift | 2 +- .../Navigator/PDF/PDFNavigatorViewController.swift | 2 +- .../Navigator/PDF/PDFTapGestureController.swift | 2 +- .../Navigator/PDF/Preferences/PDFPreferences.swift | 2 +- .../PDF/Preferences/PDFPreferencesEditor.swift | 2 +- .../Navigator/PDF/Preferences/PDFSettings.swift | 2 +- Sources/Navigator/Preferences/Configurable.swift | 2 +- .../Navigator/Preferences/MappedPreference.swift | 2 +- Sources/Navigator/Preferences/Preference.swift | 2 +- .../Navigator/Preferences/PreferencesEditor.swift | 2 +- .../Preferences/ProgressionStrategy.swift | 2 +- .../Navigator/Preferences/ProxyPreference.swift | 2 +- Sources/Navigator/Preferences/Types.swift | 2 +- Sources/Navigator/ReadingOrder.swift | 2 +- Sources/Navigator/SelectableNavigator.swift | 2 +- Sources/Navigator/TTS/AVTTSEngine.swift | 2 +- .../TTS/PublicationSpeechSynthesizer.swift | 2 +- Sources/Navigator/TTS/TTSEngine.swift | 2 +- Sources/Navigator/TTS/TTSVoice.swift | 2 +- Sources/Navigator/Toolkit/CompletionList.swift | 2 +- Sources/Navigator/Toolkit/CursorList.swift | 2 +- Sources/Navigator/Toolkit/Extensions/Bundle.swift | 2 +- Sources/Navigator/Toolkit/Extensions/CGRect.swift | 2 +- .../Navigator/Toolkit/Extensions/Language.swift | 2 +- Sources/Navigator/Toolkit/Extensions/Range.swift | 2 +- Sources/Navigator/Toolkit/Extensions/UIColor.swift | 2 +- Sources/Navigator/Toolkit/Extensions/UIView.swift | 2 +- .../Navigator/Toolkit/Extensions/WKWebView.swift | 2 +- Sources/Navigator/Toolkit/HTMLInjection.swift | 2 +- Sources/Navigator/Toolkit/PaginationView.swift | 2 +- .../Toolkit/ReadiumNavigatorLocalizedString.swift | 2 +- Sources/Navigator/Toolkit/TargetAction.swift | 2 +- Sources/Navigator/Toolkit/WebView.swift | 2 +- Sources/Navigator/VisualNavigator.swift | 2 +- Sources/OPDS/OPDS1Parser.swift | 2 +- Sources/OPDS/OPDS2Parser.swift | 2 +- Sources/OPDS/OPDSParser.swift | 2 +- Sources/OPDS/ParseData.swift | 2 +- Sources/OPDS/URLHelper.swift | 2 +- Sources/OPDS/XMLNamespace.swift | 2 +- Sources/Shared/Logger/Loggable.swift | 2 +- Sources/Shared/Logger/Logger.swift | 2 +- Sources/Shared/Logger/LoggerStub.swift | 2 +- Sources/Shared/OPDS/Facet.swift | 2 +- Sources/Shared/OPDS/Feed.swift | 2 +- Sources/Shared/OPDS/Group.swift | 2 +- Sources/Shared/OPDS/OPDSAcquisition.swift | 2 +- Sources/Shared/OPDS/OPDSAvailability.swift | 2 +- Sources/Shared/OPDS/OPDSCopies.swift | 2 +- Sources/Shared/OPDS/OPDSHolds.swift | 2 +- Sources/Shared/OPDS/OPDSPrice.swift | 2 +- Sources/Shared/OPDS/OpdsMetadata.swift | 2 +- .../Publication/Accessibility/Accessibility.swift | 2 +- .../AccessibilityDisplayString+Generated.swift | 2 +- .../AccessibilityMetadataDisplayGuide.swift | 2 +- Sources/Shared/Publication/Contributor.swift | 2 +- .../Extensions/Archive/Properties+Archive.swift | 2 +- .../Extensions/Audio/Locator+Audio.swift | 2 +- .../Publication/Extensions/EPUB/EPUBLayout.swift | 2 +- .../Extensions/EPUB/Properties+EPUB.swift | 2 +- .../Extensions/EPUB/Publication+EPUB.swift | 2 +- .../Extensions/Encryption/Encryption.swift | 2 +- .../Encryption/Properties+Encryption.swift | 2 +- .../Publication/Extensions/HTML/DOMRange.swift | 2 +- .../Publication/Extensions/HTML/Locator+HTML.swift | 2 +- .../Extensions/OPDS/Properties+OPDS.swift | 2 +- .../Extensions/OPDS/Publication+OPDS.swift | 2 +- .../Presentation/Metadata+Presentation.swift | 2 +- .../Extensions/Presentation/Presentation.swift | 2 +- .../Presentation/Properties+Presentation.swift | 2 +- Sources/Shared/Publication/HREFNormalizer.swift | 2 +- Sources/Shared/Publication/Layout.swift | 2 +- Sources/Shared/Publication/Link.swift | 2 +- Sources/Shared/Publication/LinkRelation.swift | 2 +- Sources/Shared/Publication/LocalizedString.swift | 2 +- Sources/Shared/Publication/Locator.swift | 2 +- Sources/Shared/Publication/Manifest.swift | 2 +- .../Shared/Publication/ManifestTransformer.swift | 2 +- .../Media Overlays/MediaOverlayNode.swift | 2 +- .../Publication/Media Overlays/MediaOverlays.swift | 2 +- Sources/Shared/Publication/Metadata.swift | 2 +- Sources/Shared/Publication/Properties.swift | 2 +- .../Publication/Protection/ContentProtection.swift | 2 +- .../Protection/FallbackContentProtection.swift | 2 +- Sources/Shared/Publication/Publication.swift | 2 +- .../Shared/Publication/PublicationCollection.swift | 2 +- .../Shared/Publication/ReadingProgression.swift | 2 +- .../ContentProtectionService.swift | 2 +- .../Services/Content Protection/UserRights.swift | 2 +- .../Publication/Services/Content/Content.swift | 2 +- .../Services/Content/ContentService.swift | 2 +- .../Services/Content/ContentTokenizer.swift | 2 +- .../Iterators/HTMLResourceContentIterator.swift | 2 +- .../Iterators/PublicationContentIterator.swift | 2 +- .../Publication/Services/Cover/CoverService.swift | 2 +- .../Services/Cover/GeneratedCoverService.swift | 2 +- .../Services/Locator/DefaultLocatorService.swift | 2 +- .../Services/Locator/LocatorService.swift | 2 +- .../Positions/InMemoryPositionsService.swift | 2 +- .../Positions/PerResourcePositionsService.swift | 2 +- .../Services/Positions/PositionsService.swift | 2 +- .../Publication/Services/PublicationService.swift | 2 +- .../Services/PublicationServicesBuilder.swift | 2 +- .../Services/Search/SearchService.swift | 2 +- .../Services/Search/StringSearchService.swift | 2 +- .../Table Of Contents/TableOfContentsService.swift | 2 +- Sources/Shared/Publication/Subject.swift | 2 +- Sources/Shared/Publication/TDM.swift | 2 +- Sources/Shared/Toolkit/Archive/ArchiveOpener.swift | 2 +- .../Shared/Toolkit/Archive/ArchiveProperties.swift | 2 +- .../Toolkit/Archive/CompositeArchiveOpener.swift | 2 +- .../Toolkit/Archive/DefaultArchiveOpener.swift | 2 +- Sources/Shared/Toolkit/Atomic.swift | 2 +- Sources/Shared/Toolkit/Cancellable.swift | 2 +- Sources/Shared/Toolkit/Closeable.swift | 2 +- Sources/Shared/Toolkit/ControlFlow.swift | 2 +- Sources/Shared/Toolkit/Data/Asset/Asset.swift | 2 +- .../Shared/Toolkit/Data/Asset/AssetRetriever.swift | 2 +- .../Shared/Toolkit/Data/Container/Container.swift | 2 +- .../Data/Container/SingleResourceContainer.swift | 2 +- .../Data/Container/TransformingContainer.swift | 2 +- Sources/Shared/Toolkit/Data/ReadError.swift | 2 +- .../Toolkit/Data/Resource/BorrowedResource.swift | 2 +- .../Toolkit/Data/Resource/BufferingResource.swift | 2 +- .../Toolkit/Data/Resource/CachingResource.swift | 2 +- .../Toolkit/Data/Resource/DataResource.swift | 2 +- .../Toolkit/Data/Resource/FailureResource.swift | 2 +- .../Shared/Toolkit/Data/Resource/Resource.swift | 2 +- .../Data/Resource/ResourceContentExtractor.swift | 2 +- .../Toolkit/Data/Resource/ResourceFactory.swift | 2 +- .../Toolkit/Data/Resource/ResourceProperties.swift | 2 +- .../Data/Resource/TailCachingResource.swift | 2 +- .../Data/Resource/TransformingResource.swift | 2 +- Sources/Shared/Toolkit/Data/Streamable.swift | 2 +- Sources/Shared/Toolkit/DebugError.swift | 2 +- Sources/Shared/Toolkit/DocumentTypes.swift | 2 +- Sources/Shared/Toolkit/Either.swift | 2 +- Sources/Shared/Toolkit/Extensions/Bundle.swift | 2 +- Sources/Shared/Toolkit/Extensions/Optional.swift | 2 +- Sources/Shared/Toolkit/Extensions/Range.swift | 2 +- Sources/Shared/Toolkit/Extensions/String.swift | 2 +- .../Shared/Toolkit/Extensions/StringEncoding.swift | 2 +- Sources/Shared/Toolkit/Extensions/UIImage.swift | 2 +- .../Shared/Toolkit/File/DirectoryContainer.swift | 2 +- Sources/Shared/Toolkit/File/FileContainer.swift | 2 +- Sources/Shared/Toolkit/File/FileResource.swift | 2 +- .../Shared/Toolkit/File/FileResourceFactory.swift | 2 +- Sources/Shared/Toolkit/File/FileSystemError.swift | 2 +- Sources/Shared/Toolkit/FileExtension.swift | 2 +- Sources/Shared/Toolkit/Format/Format.swift | 2 +- Sources/Shared/Toolkit/Format/FormatSniffer.swift | 2 +- .../Shared/Toolkit/Format/FormatSnifferBlob.swift | 2 +- Sources/Shared/Toolkit/Format/MediaType.swift | 2 +- .../Format/Sniffers/AudioFormatSniffer.swift | 2 +- .../Format/Sniffers/AudiobookFormatSniffer.swift | 2 +- .../Format/Sniffers/BitmapFormatSniffer.swift | 2 +- .../Format/Sniffers/ComicFormatSniffer.swift | 2 +- .../Format/Sniffers/CompositeFormatSniffer.swift | 2 +- .../Format/Sniffers/DefaultFormatSniffer.swift | 2 +- .../Format/Sniffers/EPUBFormatSniffer.swift | 2 +- .../Format/Sniffers/HTMLFormatSniffer.swift | 2 +- .../Format/Sniffers/JSONFormatSniffer.swift | 2 +- .../Format/Sniffers/LCPLicenseFormatSniffer.swift | 2 +- .../Format/Sniffers/LanguageFormatSniffer.swift | 2 +- .../Format/Sniffers/OPDSFormatSniffer.swift | 2 +- .../Toolkit/Format/Sniffers/PDFFormatSniffer.swift | 2 +- .../Toolkit/Format/Sniffers/RARFormatSniffer.swift | 2 +- .../Toolkit/Format/Sniffers/RPFFormatSniffer.swift | 2 +- .../Format/Sniffers/RWPMFormatSniffer.swift | 2 +- .../Toolkit/Format/Sniffers/XMLFormatSniffer.swift | 2 +- .../Toolkit/Format/Sniffers/ZIPFormatSniffer.swift | 2 +- .../Shared/Toolkit/HTTP/DefaultHTTPClient.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPClient.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPContainer.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPError.swift | 2 +- .../Shared/Toolkit/HTTP/HTTPProblemDetails.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPRequest.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPResource.swift | 2 +- .../Shared/Toolkit/HTTP/HTTPResourceFactory.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPServer.swift | 2 +- Sources/Shared/Toolkit/JSON.swift | 2 +- Sources/Shared/Toolkit/Language.swift | 2 +- Sources/Shared/Toolkit/Logging/WarningLogger.swift | 2 +- Sources/Shared/Toolkit/Media/AudioSession.swift | 2 +- Sources/Shared/Toolkit/Media/NowPlayingInfo.swift | 2 +- Sources/Shared/Toolkit/Observable.swift | 2 +- Sources/Shared/Toolkit/PDF/CGPDF.swift | 2 +- Sources/Shared/Toolkit/PDF/PDFDocument.swift | 2 +- Sources/Shared/Toolkit/PDF/PDFKit.swift | 2 +- Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift | 2 +- .../Shared/Toolkit/ReadiumLocalizedString.swift | 2 +- .../Shared/Toolkit/Tokenizer/TextTokenizer.swift | 2 +- Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift | 2 +- .../Toolkit/URL/Absolute URL/AbsoluteURL.swift | 2 +- .../Shared/Toolkit/URL/Absolute URL/FileURL.swift | 2 +- .../Shared/Toolkit/URL/Absolute URL/HTTPURL.swift | 2 +- .../URL/Absolute URL/UnknownAbsoluteURL.swift | 2 +- Sources/Shared/Toolkit/URL/AnyURL.swift | 2 +- Sources/Shared/Toolkit/URL/RelativeURL.swift | 2 +- Sources/Shared/Toolkit/URL/URITemplate.swift | 2 +- Sources/Shared/Toolkit/URL/URLConvertible.swift | 2 +- Sources/Shared/Toolkit/URL/URLExtensions.swift | 2 +- Sources/Shared/Toolkit/URL/URLProtocol.swift | 2 +- Sources/Shared/Toolkit/URL/URLQuery.swift | 2 +- Sources/Shared/Toolkit/Weak.swift | 2 +- Sources/Shared/Toolkit/XML/Fuzi.swift | 2 +- Sources/Shared/Toolkit/XML/XML.swift | 2 +- .../Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift | 2 +- .../Toolkit/ZIP/Minizip/MinizipContainer.swift | 2 +- Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift | 2 +- .../ZIPFoundationArchiveFactory.swift | 2 +- .../ZIPFoundation/ZIPFoundationArchiveOpener.swift | 2 +- .../ZIP/ZIPFoundation/ZIPFoundationContainer.swift | 2 +- Sources/Streamer/Parser/Audio/AudioParser.swift | 2 +- .../Audio/AudioPublicationManifestAugmentor.swift | 2 +- .../Audio/Services/AudioLocatorService.swift | 2 +- .../Parser/CompositePublicationParser.swift | 2 +- .../Streamer/Parser/DefaultPublicationParser.swift | 2 +- .../Streamer/Parser/EPUB/EPUBContainerParser.swift | 2 +- .../Parser/EPUB/EPUBEncryptionParser.swift | 2 +- .../Streamer/Parser/EPUB/EPUBManifestParser.swift | 2 +- .../Streamer/Parser/EPUB/EPUBMetadataParser.swift | 2 +- Sources/Streamer/Parser/EPUB/EPUBParser.swift | 2 +- .../Parser/EPUB/Extensions/Layout+EPUB.swift | 2 +- .../Parser/EPUB/Extensions/LinkRelation+EPUB.swift | 2 +- Sources/Streamer/Parser/EPUB/NCXParser.swift | 2 +- .../Parser/EPUB/NavigationDocumentParser.swift | 2 +- Sources/Streamer/Parser/EPUB/OPFMeta.swift | 2 +- Sources/Streamer/Parser/EPUB/OPFParser.swift | 2 +- .../Resource Transformers/EPUBDeobfuscator.swift | 2 +- .../EPUB/Services/EPUBPositionsService.swift | 2 +- Sources/Streamer/Parser/EPUB/XMLNamespace.swift | 2 +- Sources/Streamer/Parser/Image/ImageParser.swift | 2 +- Sources/Streamer/Parser/PDF/PDFParser.swift | 2 +- .../PDF/Services/LCPDFPositionsService.swift | 2 +- .../PDF/Services/LCPDFTableOfContentsService.swift | 2 +- .../Parser/PDF/Services/PDFPositionsService.swift | 2 +- Sources/Streamer/Parser/PublicationParser.swift | 2 +- .../Parser/Readium/ReadiumWebPubParser.swift | 2 +- Sources/Streamer/PublicationOpener.swift | 2 +- Sources/Streamer/Toolkit/Extensions/Bundle.swift | 2 +- .../Streamer/Toolkit/Extensions/Container.swift | 2 +- Sources/Streamer/Toolkit/StringExtension.swift | 2 +- TestApp/Sources/About/AboutSectionView.swift | 2 +- TestApp/Sources/About/AboutView.swift | 2 +- TestApp/Sources/App/AboutTableViewController.swift | 2 +- TestApp/Sources/App/AppModule.swift | 2 +- TestApp/Sources/App/Readium.swift | 2 +- TestApp/Sources/AppDelegate.swift | 2 +- TestApp/Sources/Common/Paths.swift | 2 +- TestApp/Sources/Common/Publication.swift | 2 +- TestApp/Sources/Common/Toolkit/BarButtonItem.swift | 2 +- .../Common/Toolkit/Extensions/AnyPublisher.swift | 2 +- .../Sources/Common/Toolkit/Extensions/Future.swift | 2 +- .../Common/Toolkit/Extensions/Locator.swift | 2 +- .../Common/Toolkit/Extensions/UIImage.swift | 2 +- .../Toolkit/Extensions/UIViewController.swift | 2 +- .../Sources/Common/Toolkit/Extensions/URL.swift | 2 +- .../Sources/Common/Toolkit/ScreenOrientation.swift | 2 +- TestApp/Sources/Common/UX/IconButton.swift | 2 +- TestApp/Sources/Common/UserError.swift | 2 +- TestApp/Sources/Data/Book.swift | 2 +- TestApp/Sources/Data/Bookmark.swift | 2 +- TestApp/Sources/Data/Database.swift | 2 +- TestApp/Sources/Data/Highlight.swift | 2 +- TestApp/Sources/Data/UserPreferencesStore.swift | 2 +- TestApp/Sources/LCP/LCPModule.swift | 2 +- TestApp/Sources/Library/LibraryError.swift | 2 +- TestApp/Sources/Library/LibraryFactory.swift | 2 +- TestApp/Sources/Library/LibraryModule.swift | 2 +- TestApp/Sources/Library/LibraryService.swift | 2 +- .../Sources/Library/LibraryViewController.swift | 2 +- .../Library/PublicationCollectionViewCell.swift | 2 +- .../Library/PublicationMenuViewController.swift | 2 +- .../Sources/Library/PublicationMetadataView.swift | 2 +- .../OPDS/OPDSCatalogs/EditOPDSCatalogView.swift | 2 +- .../Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift | 2 +- .../Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift | 2 +- .../OPDS/OPDSCatalogs/OPDSCatalogsView.swift | 2 +- .../OPDS/OPDSCatalogs/OPDSCatalogsViewModel.swift | 2 +- TestApp/Sources/OPDS/OPDSFactory.swift | 2 +- TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift | 2 +- TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift | 2 +- .../Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift | 2 +- TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift | 2 +- .../Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift | 2 +- .../OPDS/OPDSFeeds/OPDSPublicationInfoView.swift | 2 +- .../OPDS/OPDSFeeds/OPDSPublicationItemView.swift | 2 +- TestApp/Sources/OPDS/OPDSModule.swift | 2 +- TestApp/Sources/OPDS/OPDSPlaceholderView.swift | 2 +- .../OPDS/OPDSPublicationInfoViewController.swift | 2 +- .../Sources/Reader/Audiobook/AudiobookModule.swift | 2 +- .../Reader/Audiobook/AudiobookViewController.swift | 2 +- TestApp/Sources/Reader/CBZ/CBZModule.swift | 2 +- TestApp/Sources/Reader/CBZ/CBZViewController.swift | 2 +- .../Reader/Common/Bookmark/BookmarkCellView.swift | 2 +- .../DRM/LCPManagementTableViewController.swift | 2 +- .../Sources/Reader/Common/DRM/LCPViewModel.swift | 2 +- .../Common/Highlight/HighlightCellView.swift | 2 +- .../Common/Highlight/HighlightContextMenu.swift | 2 +- .../Reader/Common/Outline/OutlineTableView.swift | 2 +- .../Reader/Common/Outline/OutlineViewModels.swift | 2 +- .../Common/Preferences/UserPreferences.swift | 2 +- .../Reader/Common/ReaderViewController.swift | 2 +- .../Sources/Reader/Common/Search/SearchView.swift | 2 +- .../Reader/Common/Search/SearchViewModel.swift | 2 +- TestApp/Sources/Reader/Common/TTS/TTSView.swift | 2 +- .../Sources/Reader/Common/TTS/TTSViewModel.swift | 2 +- .../Reader/Common/VisualReaderViewController.swift | 2 +- TestApp/Sources/Reader/EPUB/EPUBModule.swift | 2 +- .../Sources/Reader/EPUB/EPUBViewController.swift | 2 +- TestApp/Sources/Reader/PDF/PDFModule.swift | 2 +- TestApp/Sources/Reader/PDF/PDFViewController.swift | 2 +- TestApp/Sources/Reader/ReaderError.swift | 2 +- TestApp/Sources/Reader/ReaderFactory.swift | 2 +- TestApp/Sources/Reader/ReaderFormatModule.swift | 2 +- TestApp/Sources/Reader/ReaderModule.swift | 2 +- TestApp/Sources/Reader/Toast.swift | 2 +- .../Extensions/Date+ISO8601Tests.swift | 2 +- Tests/InternalTests/Extensions/StringTests.swift | 2 +- Tests/InternalTests/Extensions/URLTests.swift | 2 +- .../Content Protection/LCPDecryptorTests.swift | 2 +- Tests/LCPTests/Fixtures.swift | 2 +- Tests/LCPTests/LCPTestClient.swift | 2 +- Tests/NavigatorTests/Asserts.swift | 2 +- .../Audio/PublicationMediaLoaderTests.swift | 2 +- Tests/NavigatorTests/EPUB/CSS/CSSLayoutTests.swift | 2 +- .../EPUB/CSS/CSSRSPropertiesTests.swift | 2 +- .../EPUB/CSS/CSSUserPropertiesTests.swift | 2 +- .../NavigatorTests/EPUB/CSS/ReadiumCSSTests.swift | 2 +- .../EPUB/Preferences/EPUBSettingsTests.swift | 2 +- Tests/NavigatorTests/TTS/TTSVoiceTests.swift | 2 +- .../NavigatorTests/Toolkit/HTMLElementTests.swift | 2 +- .../Toolkit/HTMLInjectionTests.swift | 2 +- .../NavigatorTestHost/AccessibilityID.swift | 2 +- .../UITests/NavigatorTestHost/Container.swift | 2 +- .../UITests/NavigatorTestHost/FixtureList.swift | 2 +- .../Fixtures/PublicationFixture.swift | 2 +- .../UITests/NavigatorTestHost/ListRow.swift | 2 +- .../UITests/NavigatorTestHost/MemoryTracker.swift | 2 +- .../NavigatorTestHost/NavigatorTestHostApp.swift | 2 +- .../UITests/NavigatorTestHost/ReaderView.swift | 2 +- .../NavigatorTestHost/ReaderViewController.swift | 2 +- .../UITests/NavigatorUITests/MemoryLeakTests.swift | 2 +- .../UITests/NavigatorUITests/XCUIApplication.swift | 2 +- .../UITests/NavigatorUITests/XCUIElement.swift | 2 +- Tests/OPDSTests/readium_opds1_1_test.swift | 2 +- Tests/OPDSTests/readium_opds2_0_test.swift | 2 +- Tests/Publications/TestPublications.swift | 2 +- Tests/SharedTests/Asserts.swift | 2 +- Tests/SharedTests/EquatableError.swift | 2 +- Tests/SharedTests/Extensions.swift | 2 +- Tests/SharedTests/Fixtures.swift | 2 +- Tests/SharedTests/JSON.swift | 2 +- Tests/SharedTests/OPDS/OPDSAcquisitionTests.swift | 2 +- Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift | 2 +- Tests/SharedTests/OPDS/OPDSCopiesTests.swift | 2 +- Tests/SharedTests/OPDS/OPDSHoldsTests.swift | 2 +- Tests/SharedTests/OPDS/OPDSPriceTests.swift | 2 +- Tests/SharedTests/ProxyContainer.swift | 2 +- .../AccessibilityMetadataDisplayGuideTests.swift | 2 +- .../Accessibility/AccessibilityTests.swift | 2 +- .../SharedTests/Publication/ContributorTests.swift | 2 +- .../Archive/Properties+ArchiveTests.swift | 2 +- .../Extensions/Audio/Locator+AudioTests.swift | 2 +- .../Extensions/EPUB/EPUBLayoutTests.swift | 2 +- .../Extensions/EPUB/Properties+EPUBTests.swift | 2 +- .../Extensions/EPUB/Publication+EPUBTests.swift | 2 +- .../Extensions/Encryption/EncryptionTests.swift | 2 +- .../Encryption/Properties+EncryptionTests.swift | 2 +- .../Extensions/HTML/DOMRangeTests.swift | 2 +- .../Extensions/HTML/Locator+HTMLTests.swift | 2 +- .../Extensions/OPDS/Properties+OPDSTests.swift | 2 +- .../Extensions/OPDS/Publication+OPDSTests.swift | 2 +- .../Publication/HREFNormalizerTests.swift | 2 +- Tests/SharedTests/Publication/LinkArrayTests.swift | 2 +- Tests/SharedTests/Publication/LinkTests.swift | 2 +- .../Publication/LocalizedStringTests.swift | 2 +- Tests/SharedTests/Publication/LocatorTests.swift | 2 +- Tests/SharedTests/Publication/ManifestTests.swift | 2 +- Tests/SharedTests/Publication/MetadataTests.swift | 2 +- .../SharedTests/Publication/PropertiesTests.swift | 2 +- .../Publication/PublicationCollectionTests.swift | 2 +- .../SharedTests/Publication/PublicationTests.swift | 2 +- .../Publication/ReadingProgressionTests.swift | 2 +- .../ContentProtectionServiceTests.swift | 2 +- .../Content Protection/UserRightsTests.swift | 2 +- .../HTMLResourceContentIteratorTests.swift | 2 +- .../Services/Cover/CoverServiceTests.swift | 2 +- .../Cover/GeneratedCoverServiceTests.swift | 2 +- .../Locator/DefaultLocatorServiceTests.swift | 2 +- .../PerResourcePositionsServiceTests.swift | 2 +- .../Services/Positions/PositionsServiceTests.swift | 2 +- .../Services/PublicationServicesBuilderTests.swift | 2 +- Tests/SharedTests/Publication/SubjectTests.swift | 2 +- Tests/SharedTests/Publication/TDMTests.swift | 2 +- .../Toolkit/Data/Asset/AssetRetrieverTests.swift | 2 +- .../Data/Resource/BufferingResourceTests.swift | 2 +- .../Data/Resource/TailCachingResourceTests.swift | 2 +- Tests/SharedTests/Toolkit/DocumentTypesTests.swift | 2 +- .../Toolkit/Extensions/UIImageTests.swift | 2 +- .../Toolkit/File/DirectoryContainerTests.swift | 2 +- .../Toolkit/Format/FormatSniffersTests.swift | 2 +- .../Toolkit/Format/MediaTypeTests.swift | 2 +- .../Toolkit/HTTP/HTTPProblemDetailsTests.swift | 2 +- .../Toolkit/Tokenizer/TextTokenizerTests.swift | 2 +- Tests/SharedTests/Toolkit/URITemplateTests.swift | 2 +- .../Toolkit/URL/Absolute URL/FileURLTests.swift | 2 +- .../Toolkit/URL/Absolute URL/HTTPURLTests.swift | 2 +- .../URL/Absolute URL/UnknownAbsoluteURLTests.swift | 2 +- Tests/SharedTests/Toolkit/URL/AnyURLTests.swift | 2 +- .../SharedTests/Toolkit/URL/RelativeURLTests.swift | 2 +- Tests/SharedTests/Toolkit/URL/URLQueryTests.swift | 2 +- Tests/SharedTests/Toolkit/XML/XMLTests.swift | 2 +- .../Toolkit/ZIP/MinizipContainerTests.swift | 2 +- .../Toolkit/ZIP/ZIPFoundationContainerTests.swift | 2 +- Tests/StreamerTests/Asserts.swift | 2 +- Tests/StreamerTests/EquatableError.swift | 2 +- Tests/StreamerTests/Extensions.swift | 2 +- Tests/StreamerTests/Fixtures.swift | 2 +- .../Parser/Audio/AudioParserTests.swift | 2 +- .../Audio/Services/AudioLocatorServiceTests.swift | 2 +- .../Parser/EPUB/EPUBContainerParserTests.swift | 2 +- .../Parser/EPUB/EPUBEncryptionParserTests.swift | 2 +- .../Parser/EPUB/EPUBManifestParserTests.swift | 2 +- .../Parser/EPUB/EPUBMetadataParserTests.swift | 2 +- .../StreamerTests/Parser/EPUB/NCXParserTests.swift | 2 +- .../EPUB/NavigationDocumentParserTests.swift | 2 +- .../StreamerTests/Parser/EPUB/OPFParserTests.swift | 2 +- .../EPUBDeobfuscatorTests.swift | 2 +- .../EPUB/Services/EPUBPositionsServiceTests.swift | 2 +- .../Parser/Image/ImageParserTests.swift | 2 +- .../Parser/Readium/ReadiumWebPubParserTests.swift | 2 +- .../Toolkit/Extensions/ContainerTests.swift | 2 +- 545 files changed, 551 insertions(+), 551 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 906c850990..ac57cdd7b3 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -9,12 +9,12 @@ env: platform: ${{ 'iOS Simulator' }} device: ${{ 'iPhone SE (3rd generation)' }} commit_sha: ${{ github.sha }} - DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.3.app/Contents/Developer jobs: build: name: Build - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft }} env: scheme: ${{ 'Readium-Package' }} @@ -42,7 +42,7 @@ jobs: navigator-ui-tests: name: Navigator UI Tests - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft }} steps: - name: Checkout @@ -59,7 +59,7 @@ jobs: lint: name: Lint - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft }} env: scripts: ${{ 'Sources/Navigator/EPUB/Scripts' }} @@ -93,7 +93,7 @@ jobs: int-dev: name: Integration (Local) - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft }} defaults: run: @@ -115,7 +115,7 @@ jobs: int-spm: name: Integration (Swift Package Manager) - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft }} defaults: run: @@ -143,7 +143,7 @@ jobs: int-carthage: name: Integration (Carthage) - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft && github.ref == 'refs/heads/main' }} defaults: run: diff --git a/BuildTools/Empty.swift b/BuildTools/Empty.swift index e54e11f6a4..410a1329ce 100644 --- a/BuildTools/Empty.swift +++ b/BuildTools/Empty.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift index 3df551fd16..b7ab9e8cda 100644 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Adapters/GCDWebServer/ResourceResponse.swift b/Sources/Adapters/GCDWebServer/ResourceResponse.swift index e5a939291e..1624c38efd 100644 --- a/Sources/Adapters/GCDWebServer/ResourceResponse.swift +++ b/Sources/Adapters/GCDWebServer/ResourceResponse.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Adapters/LCPSQLite/Database.swift b/Sources/Adapters/LCPSQLite/Database.swift index 7d33f1417b..4afd1f3406 100644 --- a/Sources/Adapters/LCPSQLite/Database.swift +++ b/Sources/Adapters/LCPSQLite/Database.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift index a085367688..b44cb4013f 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift index 43292afe0a..7936bfe697 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Array.swift b/Sources/Internal/Extensions/Array.swift index 9dad9b4ac8..6c1bf08da8 100644 --- a/Sources/Internal/Extensions/Array.swift +++ b/Sources/Internal/Extensions/Array.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Collection.swift b/Sources/Internal/Extensions/Collection.swift index 224ca63c7d..f8419f53d7 100644 --- a/Sources/Internal/Extensions/Collection.swift +++ b/Sources/Internal/Extensions/Collection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Comparable.swift b/Sources/Internal/Extensions/Comparable.swift index 39bb206a17..3b7480cac6 100644 --- a/Sources/Internal/Extensions/Comparable.swift +++ b/Sources/Internal/Extensions/Comparable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Data.swift b/Sources/Internal/Extensions/Data.swift index 45a5d16963..257b17a8fa 100644 --- a/Sources/Internal/Extensions/Data.swift +++ b/Sources/Internal/Extensions/Data.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Date+ISO8601.swift b/Sources/Internal/Extensions/Date+ISO8601.swift index c5f6a0923c..6f3cc79ec9 100644 --- a/Sources/Internal/Extensions/Date+ISO8601.swift +++ b/Sources/Internal/Extensions/Date+ISO8601.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Double.swift b/Sources/Internal/Extensions/Double.swift index f707486e72..efe9dde61e 100644 --- a/Sources/Internal/Extensions/Double.swift +++ b/Sources/Internal/Extensions/Double.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/NSRegularExpression.swift b/Sources/Internal/Extensions/NSRegularExpression.swift index 4ca10654ef..614d135e6c 100644 --- a/Sources/Internal/Extensions/NSRegularExpression.swift +++ b/Sources/Internal/Extensions/NSRegularExpression.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Number.swift b/Sources/Internal/Extensions/Number.swift index 299774baf1..bd6a9da34b 100644 --- a/Sources/Internal/Extensions/Number.swift +++ b/Sources/Internal/Extensions/Number.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Optional.swift b/Sources/Internal/Extensions/Optional.swift index a73e1ac200..e9fe54171f 100644 --- a/Sources/Internal/Extensions/Optional.swift +++ b/Sources/Internal/Extensions/Optional.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Range.swift b/Sources/Internal/Extensions/Range.swift index a5ae36ee13..7ffc891d1b 100644 --- a/Sources/Internal/Extensions/Range.swift +++ b/Sources/Internal/Extensions/Range.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Result.swift b/Sources/Internal/Extensions/Result.swift index 669f352d0b..9177e23dac 100644 --- a/Sources/Internal/Extensions/Result.swift +++ b/Sources/Internal/Extensions/Result.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Sequence.swift b/Sources/Internal/Extensions/Sequence.swift index 10912954d5..cc9ed5727f 100644 --- a/Sources/Internal/Extensions/Sequence.swift +++ b/Sources/Internal/Extensions/Sequence.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/String.swift b/Sources/Internal/Extensions/String.swift index 900c1f0567..0d74535309 100644 --- a/Sources/Internal/Extensions/String.swift +++ b/Sources/Internal/Extensions/String.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/Task.swift b/Sources/Internal/Extensions/Task.swift index aba07ff6b0..f6358ec44a 100644 --- a/Sources/Internal/Extensions/Task.swift +++ b/Sources/Internal/Extensions/Task.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/UInt64.swift b/Sources/Internal/Extensions/UInt64.swift index aa8b17082a..cc8704c9d6 100644 --- a/Sources/Internal/Extensions/UInt64.swift +++ b/Sources/Internal/Extensions/UInt64.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Extensions/URL.swift b/Sources/Internal/Extensions/URL.swift index cf6f3f298f..a09e089b98 100644 --- a/Sources/Internal/Extensions/URL.swift +++ b/Sources/Internal/Extensions/URL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/JSON.swift b/Sources/Internal/JSON.swift index 4189979709..cc9ad7a80a 100644 --- a/Sources/Internal/JSON.swift +++ b/Sources/Internal/JSON.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/Measure.swift b/Sources/Internal/Measure.swift index 04fdbf789c..686df29bd0 100644 --- a/Sources/Internal/Measure.swift +++ b/Sources/Internal/Measure.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Internal/UTI.swift b/Sources/Internal/UTI.swift index 27ea7b73fd..b72fb381da 100644 --- a/Sources/Internal/UTI.swift +++ b/Sources/Internal/UTI.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Authentications/LCPAuthenticating.swift b/Sources/LCP/Authentications/LCPAuthenticating.swift index da0295db0b..831a709085 100644 --- a/Sources/LCP/Authentications/LCPAuthenticating.swift +++ b/Sources/LCP/Authentications/LCPAuthenticating.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Authentications/LCPDialog.swift b/Sources/LCP/Authentications/LCPDialog.swift index 5f87c04e14..f9d8e4a8d0 100644 --- a/Sources/LCP/Authentications/LCPDialog.swift +++ b/Sources/LCP/Authentications/LCPDialog.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Authentications/LCPDialogAuthentication.swift b/Sources/LCP/Authentications/LCPDialogAuthentication.swift index 748ff4fe04..56505b9724 100644 --- a/Sources/LCP/Authentications/LCPDialogAuthentication.swift +++ b/Sources/LCP/Authentications/LCPDialogAuthentication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Authentications/LCPDialogViewController.swift b/Sources/LCP/Authentications/LCPDialogViewController.swift index f439b9874f..65239de8b0 100644 --- a/Sources/LCP/Authentications/LCPDialogViewController.swift +++ b/Sources/LCP/Authentications/LCPDialogViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Authentications/LCPObservableAuthentication.swift b/Sources/LCP/Authentications/LCPObservableAuthentication.swift index 354d33c61b..e088eccf98 100644 --- a/Sources/LCP/Authentications/LCPObservableAuthentication.swift +++ b/Sources/LCP/Authentications/LCPObservableAuthentication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift index e80db33013..4940739f34 100644 --- a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift +++ b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Content Protection/EncryptionParser.swift b/Sources/LCP/Content Protection/EncryptionParser.swift index f7140803d0..a6ee2fddf4 100644 --- a/Sources/LCP/Content Protection/EncryptionParser.swift +++ b/Sources/LCP/Content Protection/EncryptionParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index 2254000c76..b0da43d686 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Content Protection/LCPDecryptor.swift b/Sources/LCP/Content Protection/LCPDecryptor.swift index 79846f525b..04c2823c3e 100644 --- a/Sources/LCP/Content Protection/LCPDecryptor.swift +++ b/Sources/LCP/Content Protection/LCPDecryptor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/LCPAcquiredPublication.swift b/Sources/LCP/LCPAcquiredPublication.swift index efad0c9a17..aa1f9fe249 100644 --- a/Sources/LCP/LCPAcquiredPublication.swift +++ b/Sources/LCP/LCPAcquiredPublication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/LCPClient.swift b/Sources/LCP/LCPClient.swift index c64c07395d..de7330fa5d 100644 --- a/Sources/LCP/LCPClient.swift +++ b/Sources/LCP/LCPClient.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/LCPError.swift b/Sources/LCP/LCPError.swift index 2d8e464aec..5426d9727a 100644 --- a/Sources/LCP/LCPError.swift +++ b/Sources/LCP/LCPError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/LCPLicense.swift b/Sources/LCP/LCPLicense.swift index a05434fd5c..3db0ba6e9e 100644 --- a/Sources/LCP/LCPLicense.swift +++ b/Sources/LCP/LCPLicense.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/LCPLicenseRepository.swift b/Sources/LCP/LCPLicenseRepository.swift index d69acdc36c..f8d99f9093 100644 --- a/Sources/LCP/LCPLicenseRepository.swift +++ b/Sources/LCP/LCPLicenseRepository.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/LCPPassphraseRepository.swift b/Sources/LCP/LCPPassphraseRepository.swift index 0c489764ac..4d4a8278b1 100644 --- a/Sources/LCP/LCPPassphraseRepository.swift +++ b/Sources/LCP/LCPPassphraseRepository.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/LCPProgress.swift b/Sources/LCP/LCPProgress.swift index d32996600b..bdf8fa971e 100644 --- a/Sources/LCP/LCPProgress.swift +++ b/Sources/LCP/LCPProgress.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/LCPRenewDelegate.swift b/Sources/LCP/LCPRenewDelegate.swift index 29b2d34b49..06b71d8b72 100644 --- a/Sources/LCP/LCPRenewDelegate.swift +++ b/Sources/LCP/LCPRenewDelegate.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index 947edf67a7..6f31dfc8f9 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Container/ContainerLicenseContainer.swift b/Sources/LCP/License/Container/ContainerLicenseContainer.swift index 9e8771b2c2..3ab1994a28 100644 --- a/Sources/LCP/License/Container/ContainerLicenseContainer.swift +++ b/Sources/LCP/License/Container/ContainerLicenseContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Container/LicenseContainer.swift b/Sources/LCP/License/Container/LicenseContainer.swift index cffcada6cb..fdd8adab38 100644 --- a/Sources/LCP/License/Container/LicenseContainer.swift +++ b/Sources/LCP/License/Container/LicenseContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Container/ResourceLicenseContainer.swift b/Sources/LCP/License/Container/ResourceLicenseContainer.swift index ed8ea9ef32..1ed7a33e36 100644 --- a/Sources/LCP/License/Container/ResourceLicenseContainer.swift +++ b/Sources/LCP/License/Container/ResourceLicenseContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/LCPError+wrap.swift b/Sources/LCP/License/LCPError+wrap.swift index 9d2d157c40..6f0fd6d0d5 100644 --- a/Sources/LCP/License/LCPError+wrap.swift +++ b/Sources/LCP/License/LCPError+wrap.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index da345b0509..c7ed839b68 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index e0e087c543..a9ce140e76 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/LCP/ContentKey.swift b/Sources/LCP/License/Model/Components/LCP/ContentKey.swift index 5f780c37d1..6de43fa966 100644 --- a/Sources/LCP/License/Model/Components/LCP/ContentKey.swift +++ b/Sources/LCP/License/Model/Components/LCP/ContentKey.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/LCP/Encryption.swift b/Sources/LCP/License/Model/Components/LCP/Encryption.swift index f2545a8409..cd95b3c52a 100644 --- a/Sources/LCP/License/Model/Components/LCP/Encryption.swift +++ b/Sources/LCP/License/Model/Components/LCP/Encryption.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/LCP/Rights.swift b/Sources/LCP/License/Model/Components/LCP/Rights.swift index 8d5653fc72..f229a0841d 100644 --- a/Sources/LCP/License/Model/Components/LCP/Rights.swift +++ b/Sources/LCP/License/Model/Components/LCP/Rights.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/LCP/Signature.swift b/Sources/LCP/License/Model/Components/LCP/Signature.swift index dd78554ba6..5d642989af 100644 --- a/Sources/LCP/License/Model/Components/LCP/Signature.swift +++ b/Sources/LCP/License/Model/Components/LCP/Signature.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/LCP/User.swift b/Sources/LCP/License/Model/Components/LCP/User.swift index cbe313b951..e0bea43b11 100644 --- a/Sources/LCP/License/Model/Components/LCP/User.swift +++ b/Sources/LCP/License/Model/Components/LCP/User.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/LCP/UserKey.swift b/Sources/LCP/License/Model/Components/LCP/UserKey.swift index ba25f1e743..7bdd7c7857 100644 --- a/Sources/LCP/License/Model/Components/LCP/UserKey.swift +++ b/Sources/LCP/License/Model/Components/LCP/UserKey.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/LSD/Event.swift b/Sources/LCP/License/Model/Components/LSD/Event.swift index da792c868e..6ca69ffbec 100644 --- a/Sources/LCP/License/Model/Components/LSD/Event.swift +++ b/Sources/LCP/License/Model/Components/LSD/Event.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift b/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift index 4ba1ba6769..01d4c3a602 100644 --- a/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift +++ b/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/Link.swift b/Sources/LCP/License/Model/Components/Link.swift index b737af4bcd..42360eb131 100644 --- a/Sources/LCP/License/Model/Components/Link.swift +++ b/Sources/LCP/License/Model/Components/Link.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/Components/Links.swift b/Sources/LCP/License/Model/Components/Links.swift index 02d6979416..ae4bf71b9e 100644 --- a/Sources/LCP/License/Model/Components/Links.swift +++ b/Sources/LCP/License/Model/Components/Links.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/LicenseDocument.swift b/Sources/LCP/License/Model/LicenseDocument.swift index ca47d6ddc5..a9400b74a9 100644 --- a/Sources/LCP/License/Model/LicenseDocument.swift +++ b/Sources/LCP/License/Model/LicenseDocument.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/License/Model/StatusDocument.swift b/Sources/LCP/License/Model/StatusDocument.swift index c9f25d7d82..2997420dc6 100644 --- a/Sources/LCP/License/Model/StatusDocument.swift +++ b/Sources/LCP/License/Model/StatusDocument.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Services/CRLService.swift b/Sources/LCP/Services/CRLService.swift index e5b9282c30..9f31844414 100644 --- a/Sources/LCP/Services/CRLService.swift +++ b/Sources/LCP/Services/CRLService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Services/DeviceService.swift b/Sources/LCP/Services/DeviceService.swift index fdcade3998..ee5104ad44 100644 --- a/Sources/LCP/Services/DeviceService.swift +++ b/Sources/LCP/Services/DeviceService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Services/LicensesService.swift b/Sources/LCP/Services/LicensesService.swift index 437f9d78e4..f1c14d4d0b 100644 --- a/Sources/LCP/Services/LicensesService.swift +++ b/Sources/LCP/Services/LicensesService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index c36ca3b7ff..4a39a4a2ac 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Toolkit/Bundle.swift b/Sources/LCP/Toolkit/Bundle.swift index 775f2e014c..cd8019ceda 100644 --- a/Sources/LCP/Toolkit/Bundle.swift +++ b/Sources/LCP/Toolkit/Bundle.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift b/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift index c67b079002..2c6ada6d42 100644 --- a/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift +++ b/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/LCP/Toolkit/Streamable.swift b/Sources/LCP/Toolkit/Streamable.swift index 2f560f0f77..db0eb2f5bb 100644 --- a/Sources/LCP/Toolkit/Streamable.swift +++ b/Sources/LCP/Toolkit/Streamable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 33261e6ba1..3e1e87fc0d 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift b/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift index ebbaa9f5a1..54e4da684e 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift b/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift index 57e8ace33c..3a92538b14 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift b/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift index 4e2a658bea..be042b74d7 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift index e4b634575d..e49bd62977 100644 --- a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift +++ b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift index 66c76e5655..11590d06d4 100644 --- a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift +++ b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/CBZ/ImageViewController.swift b/Sources/Navigator/CBZ/ImageViewController.swift index b8712d6e49..3d6644487a 100644 --- a/Sources/Navigator/CBZ/ImageViewController.swift +++ b/Sources/Navigator/CBZ/ImageViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Decorator/DecorableNavigator.swift b/Sources/Navigator/Decorator/DecorableNavigator.swift index 38b09f8d45..11aadae536 100644 --- a/Sources/Navigator/Decorator/DecorableNavigator.swift +++ b/Sources/Navigator/Decorator/DecorableNavigator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Decorator/DiffableDecoration.swift b/Sources/Navigator/Decorator/DiffableDecoration.swift index 9a476b5858..5c7a4f02c3 100644 --- a/Sources/Navigator/Decorator/DiffableDecoration.swift +++ b/Sources/Navigator/Decorator/DiffableDecoration.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/DirectionalNavigationAdapter.swift b/Sources/Navigator/DirectionalNavigationAdapter.swift index 3b27ce9a9c..3b81922aad 100644 --- a/Sources/Navigator/DirectionalNavigationAdapter.swift +++ b/Sources/Navigator/DirectionalNavigationAdapter.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/CSS/CSSLayout.swift b/Sources/Navigator/EPUB/CSS/CSSLayout.swift index 138c691bb6..60a1c1a33f 100644 --- a/Sources/Navigator/EPUB/CSS/CSSLayout.swift +++ b/Sources/Navigator/EPUB/CSS/CSSLayout.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/CSS/CSSProperties.swift b/Sources/Navigator/EPUB/CSS/CSSProperties.swift index c68e4b13bb..84112e06cd 100644 --- a/Sources/Navigator/EPUB/CSS/CSSProperties.swift +++ b/Sources/Navigator/EPUB/CSS/CSSProperties.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift index c9411d0de1..a5a1527e7d 100644 --- a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift +++ b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift index 4a3c941795..44ea767458 100644 --- a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift +++ b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/DiffableDecoration+HTML.swift b/Sources/Navigator/EPUB/DiffableDecoration+HTML.swift index adb499a5a7..d34d262555 100644 --- a/Sources/Navigator/EPUB/DiffableDecoration+HTML.swift +++ b/Sources/Navigator/EPUB/DiffableDecoration+HTML.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift index a3a5cc5e77..afd0b37eaa 100644 --- a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 6fa0056dd4..60a9ccf8e2 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 67b89d0f28..68440246af 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 55772bbad2..1436541904 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 573c963714..938eddda10 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index b540d1fb67..fbdd5abd5e 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift index 846f4bb13f..86161350ae 100644 --- a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift +++ b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift index 5dd2036db0..b70a9af8d4 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index 8eb31beb8a..fabc233fef 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift index 4cfab00091..6fa73203c0 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift index f1a7f48b98..ae20df56ca 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/EditingAction.swift b/Sources/Navigator/EditingAction.swift index 7203c9b9ac..4a001d6c9b 100644 --- a/Sources/Navigator/EditingAction.swift +++ b/Sources/Navigator/EditingAction.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/CompositeInputObserver.swift b/Sources/Navigator/Input/CompositeInputObserver.swift index 18716b4d63..0beb8783eb 100644 --- a/Sources/Navigator/Input/CompositeInputObserver.swift +++ b/Sources/Navigator/Input/CompositeInputObserver.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/InputObservable+Legacy.swift b/Sources/Navigator/Input/InputObservable+Legacy.swift index b2074bc4f9..150357025c 100644 --- a/Sources/Navigator/Input/InputObservable+Legacy.swift +++ b/Sources/Navigator/Input/InputObservable+Legacy.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/InputObservable.swift b/Sources/Navigator/Input/InputObservable.swift index e34236397c..ed18b3edd8 100644 --- a/Sources/Navigator/Input/InputObservable.swift +++ b/Sources/Navigator/Input/InputObservable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/InputObservableViewController.swift b/Sources/Navigator/Input/InputObservableViewController.swift index bc96642d4f..a039ac1e35 100644 --- a/Sources/Navigator/Input/InputObservableViewController.swift +++ b/Sources/Navigator/Input/InputObservableViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/InputObserving.swift b/Sources/Navigator/Input/InputObserving.swift index 35ddb1b751..762e9e0d04 100644 --- a/Sources/Navigator/Input/InputObserving.swift +++ b/Sources/Navigator/Input/InputObserving.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift b/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift index 3383b71724..7a51105365 100644 --- a/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift +++ b/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/Key/Key.swift b/Sources/Navigator/Input/Key/Key.swift index 7ecd05ce39..adcaa69310 100644 --- a/Sources/Navigator/Input/Key/Key.swift +++ b/Sources/Navigator/Input/Key/Key.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/Key/KeyEvent.swift b/Sources/Navigator/Input/Key/KeyEvent.swift index bef1086144..e170a27163 100644 --- a/Sources/Navigator/Input/Key/KeyEvent.swift +++ b/Sources/Navigator/Input/Key/KeyEvent.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/Key/KeyModifiers.swift b/Sources/Navigator/Input/Key/KeyModifiers.swift index 99338e2fa6..98542fce85 100644 --- a/Sources/Navigator/Input/Key/KeyModifiers.swift +++ b/Sources/Navigator/Input/Key/KeyModifiers.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/Key/KeyObserver.swift b/Sources/Navigator/Input/Key/KeyObserver.swift index b13b39000e..ddf1bdcd95 100644 --- a/Sources/Navigator/Input/Key/KeyObserver.swift +++ b/Sources/Navigator/Input/Key/KeyObserver.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift b/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift index c952296105..5765e05bf9 100644 --- a/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift +++ b/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/Pointer/DragPointerObserver.swift b/Sources/Navigator/Input/Pointer/DragPointerObserver.swift index 300789c827..2b3728ba23 100644 --- a/Sources/Navigator/Input/Pointer/DragPointerObserver.swift +++ b/Sources/Navigator/Input/Pointer/DragPointerObserver.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Input/Pointer/PointerEvent.swift b/Sources/Navigator/Input/Pointer/PointerEvent.swift index aeabdf0f48..f2a5973ef5 100644 --- a/Sources/Navigator/Input/Pointer/PointerEvent.swift +++ b/Sources/Navigator/Input/Pointer/PointerEvent.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Navigator.swift b/Sources/Navigator/Navigator.swift index 36652b3955..376e8539ce 100644 --- a/Sources/Navigator/Navigator.swift +++ b/Sources/Navigator/Navigator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/PDF/PDFDocumentHolder.swift b/Sources/Navigator/PDF/PDFDocumentHolder.swift index 990ca9e6ba..fcfa9337f5 100644 --- a/Sources/Navigator/PDF/PDFDocumentHolder.swift +++ b/Sources/Navigator/PDF/PDFDocumentHolder.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/PDF/PDFDocumentView.swift b/Sources/Navigator/PDF/PDFDocumentView.swift index 11e9cf70e5..bb61f6873c 100644 --- a/Sources/Navigator/PDF/PDFDocumentView.swift +++ b/Sources/Navigator/PDF/PDFDocumentView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 527c54999a..82af331bb5 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/PDF/PDFTapGestureController.swift b/Sources/Navigator/PDF/PDFTapGestureController.swift index c3a7671f23..a652c23a02 100644 --- a/Sources/Navigator/PDF/PDFTapGestureController.swift +++ b/Sources/Navigator/PDF/PDFTapGestureController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift index 5a61ea2e66..db9a57bae8 100644 --- a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift +++ b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift b/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift index 71a15de0af..3f249416a7 100644 --- a/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift +++ b/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/PDF/Preferences/PDFSettings.swift b/Sources/Navigator/PDF/Preferences/PDFSettings.swift index c370f8cd38..936daee8ab 100644 --- a/Sources/Navigator/PDF/Preferences/PDFSettings.swift +++ b/Sources/Navigator/PDF/Preferences/PDFSettings.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Preferences/Configurable.swift b/Sources/Navigator/Preferences/Configurable.swift index a07b169198..eb426b4b91 100644 --- a/Sources/Navigator/Preferences/Configurable.swift +++ b/Sources/Navigator/Preferences/Configurable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Preferences/MappedPreference.swift b/Sources/Navigator/Preferences/MappedPreference.swift index 3659b0f7d6..6d58906680 100644 --- a/Sources/Navigator/Preferences/MappedPreference.swift +++ b/Sources/Navigator/Preferences/MappedPreference.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Preferences/Preference.swift b/Sources/Navigator/Preferences/Preference.swift index 2240648519..63eb9ad4e3 100644 --- a/Sources/Navigator/Preferences/Preference.swift +++ b/Sources/Navigator/Preferences/Preference.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Preferences/PreferencesEditor.swift b/Sources/Navigator/Preferences/PreferencesEditor.swift index 4e3c488a80..7b9a82f64b 100644 --- a/Sources/Navigator/Preferences/PreferencesEditor.swift +++ b/Sources/Navigator/Preferences/PreferencesEditor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Preferences/ProgressionStrategy.swift b/Sources/Navigator/Preferences/ProgressionStrategy.swift index b7371ca46b..81b7f860c8 100644 --- a/Sources/Navigator/Preferences/ProgressionStrategy.swift +++ b/Sources/Navigator/Preferences/ProgressionStrategy.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Preferences/ProxyPreference.swift b/Sources/Navigator/Preferences/ProxyPreference.swift index 9a3c817486..999457a186 100644 --- a/Sources/Navigator/Preferences/ProxyPreference.swift +++ b/Sources/Navigator/Preferences/ProxyPreference.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Preferences/Types.swift b/Sources/Navigator/Preferences/Types.swift index ef8a923e87..c27430d58f 100644 --- a/Sources/Navigator/Preferences/Types.swift +++ b/Sources/Navigator/Preferences/Types.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/ReadingOrder.swift b/Sources/Navigator/ReadingOrder.swift index 67596d0267..6c6a80211e 100644 --- a/Sources/Navigator/ReadingOrder.swift +++ b/Sources/Navigator/ReadingOrder.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/SelectableNavigator.swift b/Sources/Navigator/SelectableNavigator.swift index 4b5769746b..dc52a04838 100644 --- a/Sources/Navigator/SelectableNavigator.swift +++ b/Sources/Navigator/SelectableNavigator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/TTS/AVTTSEngine.swift b/Sources/Navigator/TTS/AVTTSEngine.swift index d3177c46b3..75afef5139 100644 --- a/Sources/Navigator/TTS/AVTTSEngine.swift +++ b/Sources/Navigator/TTS/AVTTSEngine.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index efbc73ac1b..c57ad11b11 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/TTS/TTSEngine.swift b/Sources/Navigator/TTS/TTSEngine.swift index 3d513bd3d5..66ea8e5eab 100644 --- a/Sources/Navigator/TTS/TTSEngine.swift +++ b/Sources/Navigator/TTS/TTSEngine.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/TTS/TTSVoice.swift b/Sources/Navigator/TTS/TTSVoice.swift index d84c060914..a68a171eb7 100644 --- a/Sources/Navigator/TTS/TTSVoice.swift +++ b/Sources/Navigator/TTS/TTSVoice.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/CompletionList.swift b/Sources/Navigator/Toolkit/CompletionList.swift index c9b2b2df71..a6a2bba587 100644 --- a/Sources/Navigator/Toolkit/CompletionList.swift +++ b/Sources/Navigator/Toolkit/CompletionList.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/CursorList.swift b/Sources/Navigator/Toolkit/CursorList.swift index 40c5cd4a95..1b2e27522b 100644 --- a/Sources/Navigator/Toolkit/CursorList.swift +++ b/Sources/Navigator/Toolkit/CursorList.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/Extensions/Bundle.swift b/Sources/Navigator/Toolkit/Extensions/Bundle.swift index 285f422278..6d7a1c8589 100644 --- a/Sources/Navigator/Toolkit/Extensions/Bundle.swift +++ b/Sources/Navigator/Toolkit/Extensions/Bundle.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/Extensions/CGRect.swift b/Sources/Navigator/Toolkit/Extensions/CGRect.swift index da3e6d6c82..7918813320 100644 --- a/Sources/Navigator/Toolkit/Extensions/CGRect.swift +++ b/Sources/Navigator/Toolkit/Extensions/CGRect.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/Extensions/Language.swift b/Sources/Navigator/Toolkit/Extensions/Language.swift index db6224296c..f83734b840 100644 --- a/Sources/Navigator/Toolkit/Extensions/Language.swift +++ b/Sources/Navigator/Toolkit/Extensions/Language.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/Extensions/Range.swift b/Sources/Navigator/Toolkit/Extensions/Range.swift index 82035f5063..5e7b9a2c48 100644 --- a/Sources/Navigator/Toolkit/Extensions/Range.swift +++ b/Sources/Navigator/Toolkit/Extensions/Range.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/Extensions/UIColor.swift b/Sources/Navigator/Toolkit/Extensions/UIColor.swift index be90574a70..6520aeff6d 100644 --- a/Sources/Navigator/Toolkit/Extensions/UIColor.swift +++ b/Sources/Navigator/Toolkit/Extensions/UIColor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/Extensions/UIView.swift b/Sources/Navigator/Toolkit/Extensions/UIView.swift index de65e35d02..4586ada069 100644 --- a/Sources/Navigator/Toolkit/Extensions/UIView.swift +++ b/Sources/Navigator/Toolkit/Extensions/UIView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/Extensions/WKWebView.swift b/Sources/Navigator/Toolkit/Extensions/WKWebView.swift index 2c8aada663..d23a7a4f60 100644 --- a/Sources/Navigator/Toolkit/Extensions/WKWebView.swift +++ b/Sources/Navigator/Toolkit/Extensions/WKWebView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/HTMLInjection.swift b/Sources/Navigator/Toolkit/HTMLInjection.swift index b56d8970c8..6f961db1e5 100644 --- a/Sources/Navigator/Toolkit/HTMLInjection.swift +++ b/Sources/Navigator/Toolkit/HTMLInjection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/PaginationView.swift b/Sources/Navigator/Toolkit/PaginationView.swift index b12d4dbb83..3702adfd8c 100644 --- a/Sources/Navigator/Toolkit/PaginationView.swift +++ b/Sources/Navigator/Toolkit/PaginationView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/ReadiumNavigatorLocalizedString.swift b/Sources/Navigator/Toolkit/ReadiumNavigatorLocalizedString.swift index 7f3267023e..619a1d5e1e 100644 --- a/Sources/Navigator/Toolkit/ReadiumNavigatorLocalizedString.swift +++ b/Sources/Navigator/Toolkit/ReadiumNavigatorLocalizedString.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/TargetAction.swift b/Sources/Navigator/Toolkit/TargetAction.swift index 84ab698732..8195efc7d5 100644 --- a/Sources/Navigator/Toolkit/TargetAction.swift +++ b/Sources/Navigator/Toolkit/TargetAction.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/Toolkit/WebView.swift b/Sources/Navigator/Toolkit/WebView.swift index 7d843722b1..ce6d97c5ab 100644 --- a/Sources/Navigator/Toolkit/WebView.swift +++ b/Sources/Navigator/Toolkit/WebView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Navigator/VisualNavigator.swift b/Sources/Navigator/VisualNavigator.swift index 5edc793f52..6c9a508e5c 100644 --- a/Sources/Navigator/VisualNavigator.swift +++ b/Sources/Navigator/VisualNavigator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index 535926b15e..da0fe2a4ca 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index 98deabb553..8b76eb713f 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/OPDS/OPDSParser.swift b/Sources/OPDS/OPDSParser.swift index 4b1125ed89..99139d004b 100644 --- a/Sources/OPDS/OPDSParser.swift +++ b/Sources/OPDS/OPDSParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/OPDS/ParseData.swift b/Sources/OPDS/ParseData.swift index 2ae11131a9..116c57ef0d 100644 --- a/Sources/OPDS/ParseData.swift +++ b/Sources/OPDS/ParseData.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/OPDS/URLHelper.swift b/Sources/OPDS/URLHelper.swift index 0fe7c4555e..5157fc5dff 100644 --- a/Sources/OPDS/URLHelper.swift +++ b/Sources/OPDS/URLHelper.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/OPDS/XMLNamespace.swift b/Sources/OPDS/XMLNamespace.swift index b379820a38..940929c4cf 100644 --- a/Sources/OPDS/XMLNamespace.swift +++ b/Sources/OPDS/XMLNamespace.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Logger/Loggable.swift b/Sources/Shared/Logger/Loggable.swift index 2c6ee4b7a8..b15a9e7e19 100644 --- a/Sources/Shared/Logger/Loggable.swift +++ b/Sources/Shared/Logger/Loggable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Logger/Logger.swift b/Sources/Shared/Logger/Logger.swift index d8ed23b876..c0514188a5 100644 --- a/Sources/Shared/Logger/Logger.swift +++ b/Sources/Shared/Logger/Logger.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Logger/LoggerStub.swift b/Sources/Shared/Logger/LoggerStub.swift index 3a5d5f2c55..c4f55e5554 100644 --- a/Sources/Shared/Logger/LoggerStub.swift +++ b/Sources/Shared/Logger/LoggerStub.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/OPDS/Facet.swift b/Sources/Shared/OPDS/Facet.swift index fc38245855..8eb52e1c28 100644 --- a/Sources/Shared/OPDS/Facet.swift +++ b/Sources/Shared/OPDS/Facet.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/OPDS/Feed.swift b/Sources/Shared/OPDS/Feed.swift index 9c77f8b816..9cfb95977e 100644 --- a/Sources/Shared/OPDS/Feed.swift +++ b/Sources/Shared/OPDS/Feed.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/OPDS/Group.swift b/Sources/Shared/OPDS/Group.swift index 960fed4da1..992abf7bd1 100644 --- a/Sources/Shared/OPDS/Group.swift +++ b/Sources/Shared/OPDS/Group.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/OPDS/OPDSAcquisition.swift b/Sources/Shared/OPDS/OPDSAcquisition.swift index 902fa779f6..cce371695e 100644 --- a/Sources/Shared/OPDS/OPDSAcquisition.swift +++ b/Sources/Shared/OPDS/OPDSAcquisition.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/OPDS/OPDSAvailability.swift b/Sources/Shared/OPDS/OPDSAvailability.swift index d013952745..de92f7cff5 100644 --- a/Sources/Shared/OPDS/OPDSAvailability.swift +++ b/Sources/Shared/OPDS/OPDSAvailability.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/OPDS/OPDSCopies.swift b/Sources/Shared/OPDS/OPDSCopies.swift index 0cece14339..2bb38c910c 100644 --- a/Sources/Shared/OPDS/OPDSCopies.swift +++ b/Sources/Shared/OPDS/OPDSCopies.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/OPDS/OPDSHolds.swift b/Sources/Shared/OPDS/OPDSHolds.swift index 4804418f3b..a47d7ba2ae 100644 --- a/Sources/Shared/OPDS/OPDSHolds.swift +++ b/Sources/Shared/OPDS/OPDSHolds.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/OPDS/OPDSPrice.swift b/Sources/Shared/OPDS/OPDSPrice.swift index 9da287c135..1b45581d67 100644 --- a/Sources/Shared/OPDS/OPDSPrice.swift +++ b/Sources/Shared/OPDS/OPDSPrice.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/OPDS/OpdsMetadata.swift b/Sources/Shared/OPDS/OpdsMetadata.swift index 7661313574..26b9d0aa62 100644 --- a/Sources/Shared/OPDS/OpdsMetadata.swift +++ b/Sources/Shared/OPDS/OpdsMetadata.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Accessibility/Accessibility.swift b/Sources/Shared/Publication/Accessibility/Accessibility.swift index fbe8d01985..dbdb40ae1c 100644 --- a/Sources/Shared/Publication/Accessibility/Accessibility.swift +++ b/Sources/Shared/Publication/Accessibility/Accessibility.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift b/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift index e4738f066c..95ee4466f8 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift index aeb73d3036..3845e61e19 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Contributor.swift b/Sources/Shared/Publication/Contributor.swift index 46f52c7de7..fadcb8d096 100644 --- a/Sources/Shared/Publication/Contributor.swift +++ b/Sources/Shared/Publication/Contributor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift b/Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift index b530040f73..752a824788 100644 --- a/Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift +++ b/Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift b/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift index 4aafa53a6a..4d9fb60002 100644 --- a/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift +++ b/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift b/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift index 62963d391e..3b9a8b9940 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift index 501ce61225..09b540d214 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift index cf7e59abd5..5a0123970e 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift index e4c9815ebe..ff5539079f 100644 --- a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift +++ b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift b/Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift index 7184f412df..8212ace922 100644 --- a/Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift +++ b/Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift index d82f309b58..859b736829 100644 --- a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift +++ b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/HTML/Locator+HTML.swift b/Sources/Shared/Publication/Extensions/HTML/Locator+HTML.swift index 61be92254a..ef066045d3 100644 --- a/Sources/Shared/Publication/Extensions/HTML/Locator+HTML.swift +++ b/Sources/Shared/Publication/Extensions/HTML/Locator+HTML.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift b/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift index f8f25e21e5..f8ca8389a7 100644 --- a/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift +++ b/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/OPDS/Publication+OPDS.swift b/Sources/Shared/Publication/Extensions/OPDS/Publication+OPDS.swift index c3a8fdc60f..3018cf6966 100644 --- a/Sources/Shared/Publication/Extensions/OPDS/Publication+OPDS.swift +++ b/Sources/Shared/Publication/Extensions/OPDS/Publication+OPDS.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift b/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift index 843c561d1c..b920665a2d 100644 --- a/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift +++ b/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/Presentation/Presentation.swift b/Sources/Shared/Publication/Extensions/Presentation/Presentation.swift index c39e6f9bd7..553a088edc 100644 --- a/Sources/Shared/Publication/Extensions/Presentation/Presentation.swift +++ b/Sources/Shared/Publication/Extensions/Presentation/Presentation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift b/Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift index 2c5d6dd507..1fa9251a8c 100644 --- a/Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift +++ b/Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/HREFNormalizer.swift b/Sources/Shared/Publication/HREFNormalizer.swift index 5d8ce6cec5..8ef9f7ae9c 100644 --- a/Sources/Shared/Publication/HREFNormalizer.swift +++ b/Sources/Shared/Publication/HREFNormalizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Layout.swift b/Sources/Shared/Publication/Layout.swift index 971e945f34..1e0651172f 100644 --- a/Sources/Shared/Publication/Layout.swift +++ b/Sources/Shared/Publication/Layout.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index 4b6f745eea..94384437b8 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/LinkRelation.swift b/Sources/Shared/Publication/LinkRelation.swift index a6f70b86af..813672f8c9 100644 --- a/Sources/Shared/Publication/LinkRelation.swift +++ b/Sources/Shared/Publication/LinkRelation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/LocalizedString.swift b/Sources/Shared/Publication/LocalizedString.swift index d3de980d0e..aff8825929 100644 --- a/Sources/Shared/Publication/LocalizedString.swift +++ b/Sources/Shared/Publication/LocalizedString.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Locator.swift b/Sources/Shared/Publication/Locator.swift index 92bacac493..2481f3f0dd 100644 --- a/Sources/Shared/Publication/Locator.swift +++ b/Sources/Shared/Publication/Locator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Manifest.swift b/Sources/Shared/Publication/Manifest.swift index 72c867c3d5..cbe83b64bc 100644 --- a/Sources/Shared/Publication/Manifest.swift +++ b/Sources/Shared/Publication/Manifest.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/ManifestTransformer.swift b/Sources/Shared/Publication/ManifestTransformer.swift index 5cb4a54609..3056198829 100644 --- a/Sources/Shared/Publication/ManifestTransformer.swift +++ b/Sources/Shared/Publication/ManifestTransformer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift b/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift index 6467683a78..f5e7d06f91 100644 --- a/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift +++ b/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift b/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift index cd0c43f93a..913a28e6c2 100644 --- a/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift +++ b/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Metadata.swift b/Sources/Shared/Publication/Metadata.swift index 15f14a76ee..b43d9640fd 100644 --- a/Sources/Shared/Publication/Metadata.swift +++ b/Sources/Shared/Publication/Metadata.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Properties.swift b/Sources/Shared/Publication/Properties.swift index 9a909602bf..0a7c0b0a26 100644 --- a/Sources/Shared/Publication/Properties.swift +++ b/Sources/Shared/Publication/Properties.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Protection/ContentProtection.swift b/Sources/Shared/Publication/Protection/ContentProtection.swift index db3c9e779c..83ea59659d 100644 --- a/Sources/Shared/Publication/Protection/ContentProtection.swift +++ b/Sources/Shared/Publication/Protection/ContentProtection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift index 8bda96f119..1909fe6b79 100644 --- a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift +++ b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index 6045c81232..8eb50124e2 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/PublicationCollection.swift b/Sources/Shared/Publication/PublicationCollection.swift index 522709a11e..86b187a798 100644 --- a/Sources/Shared/Publication/PublicationCollection.swift +++ b/Sources/Shared/Publication/PublicationCollection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/ReadingProgression.swift b/Sources/Shared/Publication/ReadingProgression.swift index bf6ea51290..2942876a86 100644 --- a/Sources/Shared/Publication/ReadingProgression.swift +++ b/Sources/Shared/Publication/ReadingProgression.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift b/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift index 4ae9398585..6464318c11 100644 --- a/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift +++ b/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift index 50c53d1bbc..43198edf72 100644 --- a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift +++ b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index ef543343cf..b06d606518 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Content/ContentService.swift b/Sources/Shared/Publication/Services/Content/ContentService.swift index a4c15a7751..eab94e7bc8 100644 --- a/Sources/Shared/Publication/Services/Content/ContentService.swift +++ b/Sources/Shared/Publication/Services/Content/ContentService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift index b97aa5a0bd..be87b88d61 100644 --- a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift +++ b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift index ab7ba00bd4..49ef83e2e1 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift index 2ace7f311c..f2b3c15612 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Cover/CoverService.swift b/Sources/Shared/Publication/Services/Cover/CoverService.swift index 6f16fbc7ae..c38e71c8d7 100644 --- a/Sources/Shared/Publication/Services/Cover/CoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/CoverService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift index 786ddb482a..ce9b10ad70 100644 --- a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift index 60b009c03b..a4ca37b17a 100644 --- a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Locator/LocatorService.swift b/Sources/Shared/Publication/Services/Locator/LocatorService.swift index 9c89e211c7..28a242d11f 100644 --- a/Sources/Shared/Publication/Services/Locator/LocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/LocatorService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift index 1cb3a05f17..e34c7343d6 100644 --- a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift index 0dbbd0071e..1bdc239308 100644 --- a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Positions/PositionsService.swift b/Sources/Shared/Publication/Services/Positions/PositionsService.swift index 514881fe96..5fb0adaf38 100644 --- a/Sources/Shared/Publication/Services/Positions/PositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/PublicationService.swift b/Sources/Shared/Publication/Services/PublicationService.swift index 79fd86f8a4..c38e7cc9a0 100644 --- a/Sources/Shared/Publication/Services/PublicationService.swift +++ b/Sources/Shared/Publication/Services/PublicationService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift index 730c88e4bf..bb05a34cef 100644 --- a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift +++ b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Search/SearchService.swift b/Sources/Shared/Publication/Services/Search/SearchService.swift index a6d8c49b27..9a88c7d3fd 100644 --- a/Sources/Shared/Publication/Services/Search/SearchService.swift +++ b/Sources/Shared/Publication/Services/Search/SearchService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Search/StringSearchService.swift b/Sources/Shared/Publication/Services/Search/StringSearchService.swift index fe74f017da..851c6d4372 100644 --- a/Sources/Shared/Publication/Services/Search/StringSearchService.swift +++ b/Sources/Shared/Publication/Services/Search/StringSearchService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Services/Table Of Contents/TableOfContentsService.swift b/Sources/Shared/Publication/Services/Table Of Contents/TableOfContentsService.swift index 45e2ae9865..b177c08137 100644 --- a/Sources/Shared/Publication/Services/Table Of Contents/TableOfContentsService.swift +++ b/Sources/Shared/Publication/Services/Table Of Contents/TableOfContentsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/Subject.swift b/Sources/Shared/Publication/Subject.swift index c7c8682e92..73876aef47 100644 --- a/Sources/Shared/Publication/Subject.swift +++ b/Sources/Shared/Publication/Subject.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Publication/TDM.swift b/Sources/Shared/Publication/TDM.swift index f8760f64ca..bf4cc30148 100644 --- a/Sources/Shared/Publication/TDM.swift +++ b/Sources/Shared/Publication/TDM.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift index f47380a4b3..e22b0c463f 100644 --- a/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift index 27d23fc22c..3e7e9502f2 100644 --- a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift +++ b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift index b1130519cb..79c57930c1 100644 --- a/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift index 75a75bef0b..73e1bdfa56 100644 --- a/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Atomic.swift b/Sources/Shared/Toolkit/Atomic.swift index 100703a181..0a5bc21ba8 100644 --- a/Sources/Shared/Toolkit/Atomic.swift +++ b/Sources/Shared/Toolkit/Atomic.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Cancellable.swift b/Sources/Shared/Toolkit/Cancellable.swift index f7cab4c7c4..a59090fc14 100644 --- a/Sources/Shared/Toolkit/Cancellable.swift +++ b/Sources/Shared/Toolkit/Cancellable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Closeable.swift b/Sources/Shared/Toolkit/Closeable.swift index ef4e084550..0ae54d84c2 100644 --- a/Sources/Shared/Toolkit/Closeable.swift +++ b/Sources/Shared/Toolkit/Closeable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/ControlFlow.swift b/Sources/Shared/Toolkit/ControlFlow.swift index b7e2ee150f..3f951cb9e8 100644 --- a/Sources/Shared/Toolkit/ControlFlow.swift +++ b/Sources/Shared/Toolkit/ControlFlow.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Asset/Asset.swift b/Sources/Shared/Toolkit/Data/Asset/Asset.swift index 7ee5a6277e..9091a34e68 100644 --- a/Sources/Shared/Toolkit/Data/Asset/Asset.swift +++ b/Sources/Shared/Toolkit/Data/Asset/Asset.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift index 3ad3055644..76137ec203 100644 --- a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift +++ b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Container/Container.swift b/Sources/Shared/Toolkit/Data/Container/Container.swift index f62a2b2bcc..244c78fb49 100644 --- a/Sources/Shared/Toolkit/Data/Container/Container.swift +++ b/Sources/Shared/Toolkit/Data/Container/Container.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift b/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift index 774de4c86e..8cc915a671 100644 --- a/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift +++ b/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift b/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift index 81f336c4c9..45a61b1815 100644 --- a/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift +++ b/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/ReadError.swift b/Sources/Shared/Toolkit/Data/ReadError.swift index 6248635ac4..d3372842e7 100644 --- a/Sources/Shared/Toolkit/Data/ReadError.swift +++ b/Sources/Shared/Toolkit/Data/ReadError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift b/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift index f2a4c07da0..0fb74c5daf 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index 4833dcf1c6..e9da665e5c 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift index c7338990ec..f49db5bf1a 100644 --- a/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/DataResource.swift b/Sources/Shared/Toolkit/Data/Resource/DataResource.swift index 5f8d1bf61f..d86afe802a 100644 --- a/Sources/Shared/Toolkit/Data/Resource/DataResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/DataResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift index 5056757dc1..7cc0f39341 100644 --- a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/Resource.swift b/Sources/Shared/Toolkit/Data/Resource/Resource.swift index 1dc21d5625..fc6648130f 100644 --- a/Sources/Shared/Toolkit/Data/Resource/Resource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/Resource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift index 46bf4a4e40..f5f14faaed 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift index b1721f4e01..7745466786 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift index 0602e88c61..65585d1574 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift index 0f95f2c068..e31d6c9bce 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift index 672be9f497..d5d605ac87 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Data/Streamable.swift b/Sources/Shared/Toolkit/Data/Streamable.swift index 06d8538aa0..b3df3d3905 100644 --- a/Sources/Shared/Toolkit/Data/Streamable.swift +++ b/Sources/Shared/Toolkit/Data/Streamable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/DebugError.swift b/Sources/Shared/Toolkit/DebugError.swift index 53b44609db..0268e1dfc3 100644 --- a/Sources/Shared/Toolkit/DebugError.swift +++ b/Sources/Shared/Toolkit/DebugError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/DocumentTypes.swift b/Sources/Shared/Toolkit/DocumentTypes.swift index 3714970546..0096d65cb7 100644 --- a/Sources/Shared/Toolkit/DocumentTypes.swift +++ b/Sources/Shared/Toolkit/DocumentTypes.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Either.swift b/Sources/Shared/Toolkit/Either.swift index c5eb6e701b..122339ca17 100644 --- a/Sources/Shared/Toolkit/Either.swift +++ b/Sources/Shared/Toolkit/Either.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Extensions/Bundle.swift b/Sources/Shared/Toolkit/Extensions/Bundle.swift index fa13d46f5e..6ff7bb8abe 100644 --- a/Sources/Shared/Toolkit/Extensions/Bundle.swift +++ b/Sources/Shared/Toolkit/Extensions/Bundle.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Extensions/Optional.swift b/Sources/Shared/Toolkit/Extensions/Optional.swift index a36492c741..20d12083e0 100644 --- a/Sources/Shared/Toolkit/Extensions/Optional.swift +++ b/Sources/Shared/Toolkit/Extensions/Optional.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Extensions/Range.swift b/Sources/Shared/Toolkit/Extensions/Range.swift index a5d9bbe245..df5f1ebaff 100644 --- a/Sources/Shared/Toolkit/Extensions/Range.swift +++ b/Sources/Shared/Toolkit/Extensions/Range.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Extensions/String.swift b/Sources/Shared/Toolkit/Extensions/String.swift index 0a957a4732..17e707625d 100644 --- a/Sources/Shared/Toolkit/Extensions/String.swift +++ b/Sources/Shared/Toolkit/Extensions/String.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Extensions/StringEncoding.swift b/Sources/Shared/Toolkit/Extensions/StringEncoding.swift index 540650555d..1600599a70 100644 --- a/Sources/Shared/Toolkit/Extensions/StringEncoding.swift +++ b/Sources/Shared/Toolkit/Extensions/StringEncoding.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Extensions/UIImage.swift b/Sources/Shared/Toolkit/Extensions/UIImage.swift index 317a4d2e7a..7dfe604d5b 100644 --- a/Sources/Shared/Toolkit/Extensions/UIImage.swift +++ b/Sources/Shared/Toolkit/Extensions/UIImage.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/File/DirectoryContainer.swift b/Sources/Shared/Toolkit/File/DirectoryContainer.swift index 700ff93580..d8c428f0b0 100644 --- a/Sources/Shared/Toolkit/File/DirectoryContainer.swift +++ b/Sources/Shared/Toolkit/File/DirectoryContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/File/FileContainer.swift b/Sources/Shared/Toolkit/File/FileContainer.swift index 748a5b32dd..20990411d7 100644 --- a/Sources/Shared/Toolkit/File/FileContainer.swift +++ b/Sources/Shared/Toolkit/File/FileContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/File/FileResource.swift b/Sources/Shared/Toolkit/File/FileResource.swift index d5d93d9819..61c0354907 100644 --- a/Sources/Shared/Toolkit/File/FileResource.swift +++ b/Sources/Shared/Toolkit/File/FileResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/File/FileResourceFactory.swift b/Sources/Shared/Toolkit/File/FileResourceFactory.swift index 87db6623b6..b71b282ba7 100644 --- a/Sources/Shared/Toolkit/File/FileResourceFactory.swift +++ b/Sources/Shared/Toolkit/File/FileResourceFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/File/FileSystemError.swift b/Sources/Shared/Toolkit/File/FileSystemError.swift index ed5b0ef265..e5c2f77604 100644 --- a/Sources/Shared/Toolkit/File/FileSystemError.swift +++ b/Sources/Shared/Toolkit/File/FileSystemError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/FileExtension.swift b/Sources/Shared/Toolkit/FileExtension.swift index 839c92bdad..86380c0c3c 100644 --- a/Sources/Shared/Toolkit/FileExtension.swift +++ b/Sources/Shared/Toolkit/FileExtension.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Format.swift b/Sources/Shared/Toolkit/Format/Format.swift index 896846d176..2594abdf52 100644 --- a/Sources/Shared/Toolkit/Format/Format.swift +++ b/Sources/Shared/Toolkit/Format/Format.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/FormatSniffer.swift b/Sources/Shared/Toolkit/Format/FormatSniffer.swift index 5ed1a8fb58..a87674ab5b 100644 --- a/Sources/Shared/Toolkit/Format/FormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/FormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift index e7c7659fd9..3da2c94f10 100644 --- a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift +++ b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index 1f5291c6df..7441861b57 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift index f6e076539d..50792893f2 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift index dd2a2abe23..5fb871721e 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift index 6e25c9a01f..310e5eb669 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift index 63942e94ef..bee791a34d 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift index bdbbeaecca..1fdfdcd79b 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift index 801b60aa66..57f936a7ce 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift index 7f286d021d..94f752efb4 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift index db622f859b..7feb15dc83 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift index 7505e7b0f5..23300a9dcc 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift index dd4173524b..3247d0fcd8 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift index b401d23ab0..84f952dd09 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift index e697a6b7f4..618cc5f5ea 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift index 7c35608119..97d50afa87 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift index a42498be02..6fa4b1217d 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift index 6cb93addb1..59a0151073 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift index dfb1db8e69..af5ed82b28 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift index 6717a551de..250fe66575 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift index 708cf1c368..528346788c 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index 08b2591661..f31ca9a714 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index 6cbf662901..b43b27a3c3 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPContainer.swift b/Sources/Shared/Toolkit/HTTP/HTTPContainer.swift index b2b2505f37..6146be9911 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPContainer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPError.swift b/Sources/Shared/Toolkit/HTTP/HTTPError.swift index 983ae59483..29e2914dbe 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPError.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift b/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift index 0658dbe1f8..2aa39d849c 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift index 3e04961e76..f6ca759d1e 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift index 57e66a0f3e..5b9ab5639e 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift b/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift index fa5f938683..526dc7e85d 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift index 50ec137b2c..24098445da 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/JSON.swift b/Sources/Shared/Toolkit/JSON.swift index d2cc38b955..9f2bbeb019 100644 --- a/Sources/Shared/Toolkit/JSON.swift +++ b/Sources/Shared/Toolkit/JSON.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Language.swift b/Sources/Shared/Toolkit/Language.swift index b2ee31c807..55b659a6c0 100644 --- a/Sources/Shared/Toolkit/Language.swift +++ b/Sources/Shared/Toolkit/Language.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Logging/WarningLogger.swift b/Sources/Shared/Toolkit/Logging/WarningLogger.swift index b76a530b29..768656ac79 100644 --- a/Sources/Shared/Toolkit/Logging/WarningLogger.swift +++ b/Sources/Shared/Toolkit/Logging/WarningLogger.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Media/AudioSession.swift b/Sources/Shared/Toolkit/Media/AudioSession.swift index 8e3ae77ece..9808849e97 100644 --- a/Sources/Shared/Toolkit/Media/AudioSession.swift +++ b/Sources/Shared/Toolkit/Media/AudioSession.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift index acf9c67b48..5c2904f7e9 100644 --- a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift +++ b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Observable.swift b/Sources/Shared/Toolkit/Observable.swift index 910cc7d392..322dae1890 100644 --- a/Sources/Shared/Toolkit/Observable.swift +++ b/Sources/Shared/Toolkit/Observable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/PDF/CGPDF.swift b/Sources/Shared/Toolkit/PDF/CGPDF.swift index f484d101f2..8b4d0c3c31 100644 --- a/Sources/Shared/Toolkit/PDF/CGPDF.swift +++ b/Sources/Shared/Toolkit/PDF/CGPDF.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/PDF/PDFDocument.swift b/Sources/Shared/Toolkit/PDF/PDFDocument.swift index e35327bc58..06f67f1243 100644 --- a/Sources/Shared/Toolkit/PDF/PDFDocument.swift +++ b/Sources/Shared/Toolkit/PDF/PDFDocument.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/PDF/PDFKit.swift b/Sources/Shared/Toolkit/PDF/PDFKit.swift index aa17486bc4..b2645ea9b0 100644 --- a/Sources/Shared/Toolkit/PDF/PDFKit.swift +++ b/Sources/Shared/Toolkit/PDF/PDFKit.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift b/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift index a4a653ade9..72fa1f658c 100644 --- a/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift +++ b/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/ReadiumLocalizedString.swift b/Sources/Shared/Toolkit/ReadiumLocalizedString.swift index ab0e323f82..9c76a887dd 100644 --- a/Sources/Shared/Toolkit/ReadiumLocalizedString.swift +++ b/Sources/Shared/Toolkit/ReadiumLocalizedString.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift b/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift index 208018dc9b..efd7cc9645 100644 --- a/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift +++ b/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift b/Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift index bc2de1e181..c739402c39 100644 --- a/Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift +++ b/Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift index c70043ed71..66ea5a4241 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift index 596a234694..eb3f01b8d1 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift index 85452efb02..48aba28980 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift index 910709c222..7e5b81131e 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/AnyURL.swift b/Sources/Shared/Toolkit/URL/AnyURL.swift index 8c46d63784..8ad56f5bba 100644 --- a/Sources/Shared/Toolkit/URL/AnyURL.swift +++ b/Sources/Shared/Toolkit/URL/AnyURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index 05e7e364b8..e362c61e0c 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/URITemplate.swift b/Sources/Shared/Toolkit/URL/URITemplate.swift index 8d17a2a3bf..94f6b072a8 100644 --- a/Sources/Shared/Toolkit/URL/URITemplate.swift +++ b/Sources/Shared/Toolkit/URL/URITemplate.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/URLConvertible.swift b/Sources/Shared/Toolkit/URL/URLConvertible.swift index ef2f405ed9..b3e4f4ae1a 100644 --- a/Sources/Shared/Toolkit/URL/URLConvertible.swift +++ b/Sources/Shared/Toolkit/URL/URLConvertible.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/URLExtensions.swift b/Sources/Shared/Toolkit/URL/URLExtensions.swift index 418737dd2a..9252f7ab45 100644 --- a/Sources/Shared/Toolkit/URL/URLExtensions.swift +++ b/Sources/Shared/Toolkit/URL/URLExtensions.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/URLProtocol.swift b/Sources/Shared/Toolkit/URL/URLProtocol.swift index 32c62d5f0b..1a976769c4 100644 --- a/Sources/Shared/Toolkit/URL/URLProtocol.swift +++ b/Sources/Shared/Toolkit/URL/URLProtocol.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/URL/URLQuery.swift b/Sources/Shared/Toolkit/URL/URLQuery.swift index 5d15b0777f..ef02bd5514 100644 --- a/Sources/Shared/Toolkit/URL/URLQuery.swift +++ b/Sources/Shared/Toolkit/URL/URLQuery.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/Weak.swift b/Sources/Shared/Toolkit/Weak.swift index 58bbe95179..0d8adf5c4c 100644 --- a/Sources/Shared/Toolkit/Weak.swift +++ b/Sources/Shared/Toolkit/Weak.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/XML/Fuzi.swift b/Sources/Shared/Toolkit/XML/Fuzi.swift index 056087c14b..7276cf180c 100644 --- a/Sources/Shared/Toolkit/XML/Fuzi.swift +++ b/Sources/Shared/Toolkit/XML/Fuzi.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/XML/XML.swift b/Sources/Shared/Toolkit/XML/XML.swift index be6c100b58..81f6f33045 100644 --- a/Sources/Shared/Toolkit/XML/XML.swift +++ b/Sources/Shared/Toolkit/XML/XML.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift index 809fdd6a37..4312b1008a 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift index ffc8659fde..7761bb99e6 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift index 9b66cd4ec4..1b2476637f 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift index 86cd0cbfac..09dddd1a3d 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift index fff6932024..1c09e67e55 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift index 8fe7ec0ffc..7ab6d3aac4 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index bc6b201240..0d9a461fcf 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift index f59830e3ea..3402b0726a 100644 --- a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift +++ b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift index 7f0b9eb102..5fea158fe0 100644 --- a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift +++ b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/CompositePublicationParser.swift b/Sources/Streamer/Parser/CompositePublicationParser.swift index c1005c9d42..49f53633aa 100644 --- a/Sources/Streamer/Parser/CompositePublicationParser.swift +++ b/Sources/Streamer/Parser/CompositePublicationParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/DefaultPublicationParser.swift b/Sources/Streamer/Parser/DefaultPublicationParser.swift index ac22c6e8f4..1466d9cac8 100644 --- a/Sources/Streamer/Parser/DefaultPublicationParser.swift +++ b/Sources/Streamer/Parser/DefaultPublicationParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/EPUBContainerParser.swift b/Sources/Streamer/Parser/EPUB/EPUBContainerParser.swift index ee64f36d8b..3f79004753 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBContainerParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBContainerParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/EPUBEncryptionParser.swift b/Sources/Streamer/Parser/EPUB/EPUBEncryptionParser.swift index 97f6a0b52c..18a9817509 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBEncryptionParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBEncryptionParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift index b0291e0293..5d821ff63e 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift index b387990f64..147104c7ba 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index fecbc5dbc2..a0a97904fd 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/Extensions/Layout+EPUB.swift b/Sources/Streamer/Parser/EPUB/Extensions/Layout+EPUB.swift index a06ab03a18..9da0f56b36 100644 --- a/Sources/Streamer/Parser/EPUB/Extensions/Layout+EPUB.swift +++ b/Sources/Streamer/Parser/EPUB/Extensions/Layout+EPUB.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/Extensions/LinkRelation+EPUB.swift b/Sources/Streamer/Parser/EPUB/Extensions/LinkRelation+EPUB.swift index 3ac64004b4..ff5ce48468 100644 --- a/Sources/Streamer/Parser/EPUB/Extensions/LinkRelation+EPUB.swift +++ b/Sources/Streamer/Parser/EPUB/Extensions/LinkRelation+EPUB.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/NCXParser.swift b/Sources/Streamer/Parser/EPUB/NCXParser.swift index e7193296f3..2b5ec7d8e6 100644 --- a/Sources/Streamer/Parser/EPUB/NCXParser.swift +++ b/Sources/Streamer/Parser/EPUB/NCXParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/NavigationDocumentParser.swift b/Sources/Streamer/Parser/EPUB/NavigationDocumentParser.swift index 6bce202b74..80fb012e88 100644 --- a/Sources/Streamer/Parser/EPUB/NavigationDocumentParser.swift +++ b/Sources/Streamer/Parser/EPUB/NavigationDocumentParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/OPFMeta.swift b/Sources/Streamer/Parser/EPUB/OPFMeta.swift index 2418eeda1e..f536b99926 100644 --- a/Sources/Streamer/Parser/EPUB/OPFMeta.swift +++ b/Sources/Streamer/Parser/EPUB/OPFMeta.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 35e219ca70..9d28bf5e31 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift index 1852d0519c..7b9fbfe95d 100644 --- a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift +++ b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift index da3759a2b4..26a81e59ab 100644 --- a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +++ b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/EPUB/XMLNamespace.swift b/Sources/Streamer/Parser/EPUB/XMLNamespace.swift index 258de892da..46017431ac 100644 --- a/Sources/Streamer/Parser/EPUB/XMLNamespace.swift +++ b/Sources/Streamer/Parser/EPUB/XMLNamespace.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index ba2d51a77a..09fc983b03 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index ba57715f08..5d0dc667a2 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift index 59eb13245f..bc8778a084 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift index b17d83a88d..001acc8e6d 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift index 6bdfdf2cac..f4a3fc3b03 100644 --- a/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/PublicationParser.swift b/Sources/Streamer/Parser/PublicationParser.swift index 1ba32de9dc..7d68072d77 100644 --- a/Sources/Streamer/Parser/PublicationParser.swift +++ b/Sources/Streamer/Parser/PublicationParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index 34dd520f2a..ea8a9439d4 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/PublicationOpener.swift b/Sources/Streamer/PublicationOpener.swift index f9db6c90e5..3b7d13ccc8 100644 --- a/Sources/Streamer/PublicationOpener.swift +++ b/Sources/Streamer/PublicationOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Toolkit/Extensions/Bundle.swift b/Sources/Streamer/Toolkit/Extensions/Bundle.swift index 7220889f6f..c90b400258 100644 --- a/Sources/Streamer/Toolkit/Extensions/Bundle.swift +++ b/Sources/Streamer/Toolkit/Extensions/Bundle.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Toolkit/Extensions/Container.swift b/Sources/Streamer/Toolkit/Extensions/Container.swift index 794e5393bd..87766875ab 100644 --- a/Sources/Streamer/Toolkit/Extensions/Container.swift +++ b/Sources/Streamer/Toolkit/Extensions/Container.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Streamer/Toolkit/StringExtension.swift b/Sources/Streamer/Toolkit/StringExtension.swift index d71f03bf66..85395ba131 100644 --- a/Sources/Streamer/Toolkit/StringExtension.swift +++ b/Sources/Streamer/Toolkit/StringExtension.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/About/AboutSectionView.swift b/TestApp/Sources/About/AboutSectionView.swift index 2b7da92e35..51aa7e12cb 100644 --- a/TestApp/Sources/About/AboutSectionView.swift +++ b/TestApp/Sources/About/AboutSectionView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/About/AboutView.swift b/TestApp/Sources/About/AboutView.swift index 9a010deb1c..b1d627232d 100644 --- a/TestApp/Sources/About/AboutView.swift +++ b/TestApp/Sources/About/AboutView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/App/AboutTableViewController.swift b/TestApp/Sources/App/AboutTableViewController.swift index 5c8bba553e..391f3d6f39 100644 --- a/TestApp/Sources/App/AboutTableViewController.swift +++ b/TestApp/Sources/App/AboutTableViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index 5eedd8fda6..5e70e5ed4b 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index 4d4c5df86f..3aec73e4df 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index d2800ecdf1..44d90ac125 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Paths.swift b/TestApp/Sources/Common/Paths.swift index 6b2194e8b8..d33e8f0037 100644 --- a/TestApp/Sources/Common/Paths.swift +++ b/TestApp/Sources/Common/Paths.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Publication.swift b/TestApp/Sources/Common/Publication.swift index 14d279f604..5b46327a47 100644 --- a/TestApp/Sources/Common/Publication.swift +++ b/TestApp/Sources/Common/Publication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Toolkit/BarButtonItem.swift b/TestApp/Sources/Common/Toolkit/BarButtonItem.swift index 5baf9c17ac..eade10fa5b 100644 --- a/TestApp/Sources/Common/Toolkit/BarButtonItem.swift +++ b/TestApp/Sources/Common/Toolkit/BarButtonItem.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift b/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift index 245e58da14..b7805048e1 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/Future.swift b/TestApp/Sources/Common/Toolkit/Extensions/Future.swift index b4799fe4e1..64df77cb41 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/Future.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/Future.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/Locator.swift b/TestApp/Sources/Common/Toolkit/Extensions/Locator.swift index bed03455a0..0d4c43208f 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/Locator.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/Locator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/UIImage.swift b/TestApp/Sources/Common/Toolkit/Extensions/UIImage.swift index 336be39e90..21e7e0afed 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/UIImage.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/UIImage.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift b/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift index e045f69730..9c19a0bb4a 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/URL.swift b/TestApp/Sources/Common/Toolkit/Extensions/URL.swift index 3037baddcd..3aa0999133 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/URL.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/URL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/Toolkit/ScreenOrientation.swift b/TestApp/Sources/Common/Toolkit/ScreenOrientation.swift index cc27d9cb21..1b87bf8af0 100644 --- a/TestApp/Sources/Common/Toolkit/ScreenOrientation.swift +++ b/TestApp/Sources/Common/Toolkit/ScreenOrientation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/UX/IconButton.swift b/TestApp/Sources/Common/UX/IconButton.swift index 0c97d84d4f..fc405c385a 100644 --- a/TestApp/Sources/Common/UX/IconButton.swift +++ b/TestApp/Sources/Common/UX/IconButton.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Common/UserError.swift b/TestApp/Sources/Common/UserError.swift index 320d588b5b..20f7ece207 100644 --- a/TestApp/Sources/Common/UserError.swift +++ b/TestApp/Sources/Common/UserError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Data/Book.swift b/TestApp/Sources/Data/Book.swift index f7c672567e..2929de5d2f 100644 --- a/TestApp/Sources/Data/Book.swift +++ b/TestApp/Sources/Data/Book.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Data/Bookmark.swift b/TestApp/Sources/Data/Bookmark.swift index 6c89756a98..1bc5ba0c06 100644 --- a/TestApp/Sources/Data/Bookmark.swift +++ b/TestApp/Sources/Data/Bookmark.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Data/Database.swift b/TestApp/Sources/Data/Database.swift index 9dfa7635f8..79a7c7025b 100644 --- a/TestApp/Sources/Data/Database.swift +++ b/TestApp/Sources/Data/Database.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Data/Highlight.swift b/TestApp/Sources/Data/Highlight.swift index 750d7911ca..4bdb1d96a4 100644 --- a/TestApp/Sources/Data/Highlight.swift +++ b/TestApp/Sources/Data/Highlight.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Data/UserPreferencesStore.swift b/TestApp/Sources/Data/UserPreferencesStore.swift index 8110e18077..089c7dfd48 100644 --- a/TestApp/Sources/Data/UserPreferencesStore.swift +++ b/TestApp/Sources/Data/UserPreferencesStore.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/LCP/LCPModule.swift b/TestApp/Sources/LCP/LCPModule.swift index e7c47dda7e..cbeafb581b 100644 --- a/TestApp/Sources/LCP/LCPModule.swift +++ b/TestApp/Sources/LCP/LCPModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Library/LibraryError.swift b/TestApp/Sources/Library/LibraryError.swift index 27d7f6a92e..93456ae1a4 100644 --- a/TestApp/Sources/Library/LibraryError.swift +++ b/TestApp/Sources/Library/LibraryError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Library/LibraryFactory.swift b/TestApp/Sources/Library/LibraryFactory.swift index 90d2fa0e4d..d8d5e8e0d6 100644 --- a/TestApp/Sources/Library/LibraryFactory.swift +++ b/TestApp/Sources/Library/LibraryFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Library/LibraryModule.swift b/TestApp/Sources/Library/LibraryModule.swift index 11ea59bbb1..35fbb9af4b 100644 --- a/TestApp/Sources/Library/LibraryModule.swift +++ b/TestApp/Sources/Library/LibraryModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Library/LibraryService.swift b/TestApp/Sources/Library/LibraryService.swift index 1afe6ef315..b0307d6e3a 100644 --- a/TestApp/Sources/Library/LibraryService.swift +++ b/TestApp/Sources/Library/LibraryService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Library/LibraryViewController.swift b/TestApp/Sources/Library/LibraryViewController.swift index 7891546054..0bde055d36 100644 --- a/TestApp/Sources/Library/LibraryViewController.swift +++ b/TestApp/Sources/Library/LibraryViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Library/PublicationCollectionViewCell.swift b/TestApp/Sources/Library/PublicationCollectionViewCell.swift index 18720116e5..de660b7ce7 100644 --- a/TestApp/Sources/Library/PublicationCollectionViewCell.swift +++ b/TestApp/Sources/Library/PublicationCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Library/PublicationMenuViewController.swift b/TestApp/Sources/Library/PublicationMenuViewController.swift index 7055693b56..d0a8b42d88 100644 --- a/TestApp/Sources/Library/PublicationMenuViewController.swift +++ b/TestApp/Sources/Library/PublicationMenuViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Library/PublicationMetadataView.swift b/TestApp/Sources/Library/PublicationMetadataView.swift index 4b1ca02206..344619cfb6 100644 --- a/TestApp/Sources/Library/PublicationMetadataView.swift +++ b/TestApp/Sources/Library/PublicationMetadataView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/EditOPDSCatalogView.swift b/TestApp/Sources/OPDS/OPDSCatalogs/EditOPDSCatalogView.swift index ceeb9ae8b3..8245d0cb12 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/EditOPDSCatalogView.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/EditOPDSCatalogView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift index e3aecc3ab5..6ccf144e1a 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift index 029b496a03..770066fc2f 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift index 5c7dd8ce5d..e15e157391 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsViewModel.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsViewModel.swift index 1d1a79344f..3054711d7f 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsViewModel.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSFactory.swift b/TestApp/Sources/OPDS/OPDSFactory.swift index 89bba69bdd..f111e8bfac 100644 --- a/TestApp/Sources/OPDS/OPDSFactory.swift +++ b/TestApp/Sources/OPDS/OPDSFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift index 83424e4163..bbc11e5c52 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift index 1f1f799699..62c0097d88 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift index 725862b877..df028f214c 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift index fa8daaff54..a472b6f8cf 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift index 3a22b81fea..a0f69868ce 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift index 7dac50ce96..04efd269cc 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift index c993953579..37905a8558 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSModule.swift b/TestApp/Sources/OPDS/OPDSModule.swift index 74b248d0f0..7468714f76 100644 --- a/TestApp/Sources/OPDS/OPDSModule.swift +++ b/TestApp/Sources/OPDS/OPDSModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSPlaceholderView.swift b/TestApp/Sources/OPDS/OPDSPlaceholderView.swift index 51d50e9b01..a07bcac19e 100644 --- a/TestApp/Sources/OPDS/OPDSPlaceholderView.swift +++ b/TestApp/Sources/OPDS/OPDSPlaceholderView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift b/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift index 9d35d2d6ab..f1eb9b3be1 100644 --- a/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift +++ b/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Audiobook/AudiobookModule.swift b/TestApp/Sources/Reader/Audiobook/AudiobookModule.swift index 69680462f0..e8ac883f46 100644 --- a/TestApp/Sources/Reader/Audiobook/AudiobookModule.swift +++ b/TestApp/Sources/Reader/Audiobook/AudiobookModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Audiobook/AudiobookViewController.swift b/TestApp/Sources/Reader/Audiobook/AudiobookViewController.swift index 8fe9d9bfd9..ea74ea20e3 100644 --- a/TestApp/Sources/Reader/Audiobook/AudiobookViewController.swift +++ b/TestApp/Sources/Reader/Audiobook/AudiobookViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/CBZ/CBZModule.swift b/TestApp/Sources/Reader/CBZ/CBZModule.swift index 63cd1ae29a..e8c3a945ec 100644 --- a/TestApp/Sources/Reader/CBZ/CBZModule.swift +++ b/TestApp/Sources/Reader/CBZ/CBZModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/CBZ/CBZViewController.swift b/TestApp/Sources/Reader/CBZ/CBZViewController.swift index 3ecde62f2c..491d2f9099 100644 --- a/TestApp/Sources/Reader/CBZ/CBZViewController.swift +++ b/TestApp/Sources/Reader/CBZ/CBZViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/Bookmark/BookmarkCellView.swift b/TestApp/Sources/Reader/Common/Bookmark/BookmarkCellView.swift index dc03dea363..972dbede8e 100644 --- a/TestApp/Sources/Reader/Common/Bookmark/BookmarkCellView.swift +++ b/TestApp/Sources/Reader/Common/Bookmark/BookmarkCellView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift b/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift index a1f1fbd0a3..9baa50b959 100644 --- a/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift +++ b/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/DRM/LCPViewModel.swift b/TestApp/Sources/Reader/Common/DRM/LCPViewModel.swift index 07b3a4361c..bdcc80d511 100644 --- a/TestApp/Sources/Reader/Common/DRM/LCPViewModel.swift +++ b/TestApp/Sources/Reader/Common/DRM/LCPViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/Highlight/HighlightCellView.swift b/TestApp/Sources/Reader/Common/Highlight/HighlightCellView.swift index c9355916fe..9b4e1be45c 100644 --- a/TestApp/Sources/Reader/Common/Highlight/HighlightCellView.swift +++ b/TestApp/Sources/Reader/Common/Highlight/HighlightCellView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/Highlight/HighlightContextMenu.swift b/TestApp/Sources/Reader/Common/Highlight/HighlightContextMenu.swift index 10bc44af22..edf4c5b90e 100644 --- a/TestApp/Sources/Reader/Common/Highlight/HighlightContextMenu.swift +++ b/TestApp/Sources/Reader/Common/Highlight/HighlightContextMenu.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift b/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift index 89ddd58079..b0cbef7d81 100644 --- a/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift +++ b/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift b/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift index cd1b94f940..6d6eeab416 100644 --- a/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift +++ b/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index 381b215dc3..fa5c045229 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/ReaderViewController.swift b/TestApp/Sources/Reader/Common/ReaderViewController.swift index 6b746bbffb..735cd42154 100644 --- a/TestApp/Sources/Reader/Common/ReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/ReaderViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/Search/SearchView.swift b/TestApp/Sources/Reader/Common/Search/SearchView.swift index 7b9ae886db..fda7c372a8 100644 --- a/TestApp/Sources/Reader/Common/Search/SearchView.swift +++ b/TestApp/Sources/Reader/Common/Search/SearchView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift b/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift index bbcba00efc..a7ab9724b5 100644 --- a/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift +++ b/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/TTS/TTSView.swift b/TestApp/Sources/Reader/Common/TTS/TTSView.swift index a8b8a385c0..3b9bb9df6f 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSView.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift index 0d74896ee0..3a0635594c 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift index ad81edc043..7a0cb2ef48 100644 --- a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/EPUB/EPUBModule.swift b/TestApp/Sources/Reader/EPUB/EPUBModule.swift index 25867167f8..538ffa6d6b 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBModule.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift index fb414bf066..63713d0403 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/PDF/PDFModule.swift b/TestApp/Sources/Reader/PDF/PDFModule.swift index a716fb9f59..91c181acc9 100644 --- a/TestApp/Sources/Reader/PDF/PDFModule.swift +++ b/TestApp/Sources/Reader/PDF/PDFModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/PDF/PDFViewController.swift b/TestApp/Sources/Reader/PDF/PDFViewController.swift index 1bffcd829e..21700a90df 100644 --- a/TestApp/Sources/Reader/PDF/PDFViewController.swift +++ b/TestApp/Sources/Reader/PDF/PDFViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/ReaderError.swift b/TestApp/Sources/Reader/ReaderError.swift index dff8e629d1..48081cf787 100644 --- a/TestApp/Sources/Reader/ReaderError.swift +++ b/TestApp/Sources/Reader/ReaderError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/ReaderFactory.swift b/TestApp/Sources/Reader/ReaderFactory.swift index 239ffb5f86..70b62be01e 100644 --- a/TestApp/Sources/Reader/ReaderFactory.swift +++ b/TestApp/Sources/Reader/ReaderFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/ReaderFormatModule.swift b/TestApp/Sources/Reader/ReaderFormatModule.swift index ba4fc210f1..f290cabf88 100644 --- a/TestApp/Sources/Reader/ReaderFormatModule.swift +++ b/TestApp/Sources/Reader/ReaderFormatModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/ReaderModule.swift b/TestApp/Sources/Reader/ReaderModule.swift index b71d040d7f..ef8a0e11e2 100644 --- a/TestApp/Sources/Reader/ReaderModule.swift +++ b/TestApp/Sources/Reader/ReaderModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/TestApp/Sources/Reader/Toast.swift b/TestApp/Sources/Reader/Toast.swift index f5241860a1..e6805fd93a 100644 --- a/TestApp/Sources/Reader/Toast.swift +++ b/TestApp/Sources/Reader/Toast.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift b/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift index 6cbf9c0aaa..96c5614f2e 100644 --- a/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift +++ b/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/InternalTests/Extensions/StringTests.swift b/Tests/InternalTests/Extensions/StringTests.swift index 490e8d28d1..880c60f1d3 100644 --- a/Tests/InternalTests/Extensions/StringTests.swift +++ b/Tests/InternalTests/Extensions/StringTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/InternalTests/Extensions/URLTests.swift b/Tests/InternalTests/Extensions/URLTests.swift index d504656ba9..25bc250099 100644 --- a/Tests/InternalTests/Extensions/URLTests.swift +++ b/Tests/InternalTests/Extensions/URLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift b/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift index 9a6c179f54..d65405fa5f 100644 --- a/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift +++ b/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/LCPTests/Fixtures.swift b/Tests/LCPTests/Fixtures.swift index 12fe8585bf..a14e0787ee 100644 --- a/Tests/LCPTests/Fixtures.swift +++ b/Tests/LCPTests/Fixtures.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/LCPTests/LCPTestClient.swift b/Tests/LCPTests/LCPTestClient.swift index 275e72ce49..83ddeb53c4 100644 --- a/Tests/LCPTests/LCPTestClient.swift +++ b/Tests/LCPTests/LCPTestClient.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/Asserts.swift b/Tests/NavigatorTests/Asserts.swift index 1f85398741..3bd1e882bd 100644 --- a/Tests/NavigatorTests/Asserts.swift +++ b/Tests/NavigatorTests/Asserts.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift index 80823af50b..21a52d7bb5 100644 --- a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift +++ b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/EPUB/CSS/CSSLayoutTests.swift b/Tests/NavigatorTests/EPUB/CSS/CSSLayoutTests.swift index 3e103bb6b2..12a9b13aa9 100644 --- a/Tests/NavigatorTests/EPUB/CSS/CSSLayoutTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/CSSLayoutTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift b/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift index fa6f88f14f..f77423b170 100644 --- a/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift b/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift index cd1d529b95..61cdc151b1 100644 --- a/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/EPUB/CSS/ReadiumCSSTests.swift b/Tests/NavigatorTests/EPUB/CSS/ReadiumCSSTests.swift index 861cbbe419..a4f76d9cf7 100644 --- a/Tests/NavigatorTests/EPUB/CSS/ReadiumCSSTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/ReadiumCSSTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/EPUB/Preferences/EPUBSettingsTests.swift b/Tests/NavigatorTests/EPUB/Preferences/EPUBSettingsTests.swift index 105f02d5fa..ebcb7d19c4 100644 --- a/Tests/NavigatorTests/EPUB/Preferences/EPUBSettingsTests.swift +++ b/Tests/NavigatorTests/EPUB/Preferences/EPUBSettingsTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/TTS/TTSVoiceTests.swift b/Tests/NavigatorTests/TTS/TTSVoiceTests.swift index cd4e6f2aed..955409517e 100644 --- a/Tests/NavigatorTests/TTS/TTSVoiceTests.swift +++ b/Tests/NavigatorTests/TTS/TTSVoiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift b/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift index f99d869289..b292acf638 100644 --- a/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift +++ b/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/Toolkit/HTMLInjectionTests.swift b/Tests/NavigatorTests/Toolkit/HTMLInjectionTests.swift index 7c1b29a6d3..5a42c9c3a7 100644 --- a/Tests/NavigatorTests/Toolkit/HTMLInjectionTests.swift +++ b/Tests/NavigatorTests/Toolkit/HTMLInjectionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift index f82f4fe46f..325caf4101 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift index 6b9d43931b..bce1fc3815 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift index 20d81f62d6..b4536efe25 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift index 7ca33ee7d5..88c0308381 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift index a04fa0c53b..2f04dcfa46 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift index f5bbdb8022..4738211734 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift index 75ad431087..4d719a0def 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift index a180aa756c..43320747bc 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift index c65cc77b5c..e84842b068 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift index 88283538a4..089e7eb2f7 100644 --- a/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift index 6165e84291..02817ace04 100644 --- a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift index 95bd6f9eac..635587bc5b 100644 --- a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/OPDSTests/readium_opds1_1_test.swift b/Tests/OPDSTests/readium_opds1_1_test.swift index 26746efd47..556c74c4ce 100644 --- a/Tests/OPDSTests/readium_opds1_1_test.swift +++ b/Tests/OPDSTests/readium_opds1_1_test.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/OPDSTests/readium_opds2_0_test.swift b/Tests/OPDSTests/readium_opds2_0_test.swift index eb3083e73e..c8a1802f98 100644 --- a/Tests/OPDSTests/readium_opds2_0_test.swift +++ b/Tests/OPDSTests/readium_opds2_0_test.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/Publications/TestPublications.swift b/Tests/Publications/TestPublications.swift index d17564c8cf..420e90b60a 100644 --- a/Tests/Publications/TestPublications.swift +++ b/Tests/Publications/TestPublications.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Asserts.swift b/Tests/SharedTests/Asserts.swift index ff464a08ca..8dc483b96a 100644 --- a/Tests/SharedTests/Asserts.swift +++ b/Tests/SharedTests/Asserts.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/EquatableError.swift b/Tests/SharedTests/EquatableError.swift index bf3bc0c567..a1c3353b3b 100644 --- a/Tests/SharedTests/EquatableError.swift +++ b/Tests/SharedTests/EquatableError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Extensions.swift b/Tests/SharedTests/Extensions.swift index dbbd600041..6b6c1221f3 100644 --- a/Tests/SharedTests/Extensions.swift +++ b/Tests/SharedTests/Extensions.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Fixtures.swift b/Tests/SharedTests/Fixtures.swift index d232a7e594..c530e4d54b 100644 --- a/Tests/SharedTests/Fixtures.swift +++ b/Tests/SharedTests/Fixtures.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/JSON.swift b/Tests/SharedTests/JSON.swift index b57c0b37ca..f5ff174bc2 100644 --- a/Tests/SharedTests/JSON.swift +++ b/Tests/SharedTests/JSON.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/OPDS/OPDSAcquisitionTests.swift b/Tests/SharedTests/OPDS/OPDSAcquisitionTests.swift index a80a6355ef..0633a98a07 100644 --- a/Tests/SharedTests/OPDS/OPDSAcquisitionTests.swift +++ b/Tests/SharedTests/OPDS/OPDSAcquisitionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift b/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift index 5d1b5318ab..64785c7a58 100644 --- a/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift +++ b/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/OPDS/OPDSCopiesTests.swift b/Tests/SharedTests/OPDS/OPDSCopiesTests.swift index d8385958a3..47a10da306 100644 --- a/Tests/SharedTests/OPDS/OPDSCopiesTests.swift +++ b/Tests/SharedTests/OPDS/OPDSCopiesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/OPDS/OPDSHoldsTests.swift b/Tests/SharedTests/OPDS/OPDSHoldsTests.swift index 3d9ca2d867..67991790bf 100644 --- a/Tests/SharedTests/OPDS/OPDSHoldsTests.swift +++ b/Tests/SharedTests/OPDS/OPDSHoldsTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/OPDS/OPDSPriceTests.swift b/Tests/SharedTests/OPDS/OPDSPriceTests.swift index 04bf8212b7..136a280c75 100644 --- a/Tests/SharedTests/OPDS/OPDSPriceTests.swift +++ b/Tests/SharedTests/OPDS/OPDSPriceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/ProxyContainer.swift b/Tests/SharedTests/ProxyContainer.swift index 5fbb91cdba..35d4c952cc 100644 --- a/Tests/SharedTests/ProxyContainer.swift +++ b/Tests/SharedTests/ProxyContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift b/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift index b3ffb77830..e6d72e1fab 100644 --- a/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift +++ b/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Accessibility/AccessibilityTests.swift b/Tests/SharedTests/Publication/Accessibility/AccessibilityTests.swift index 72f5145ab1..bd5228fb69 100644 --- a/Tests/SharedTests/Publication/Accessibility/AccessibilityTests.swift +++ b/Tests/SharedTests/Publication/Accessibility/AccessibilityTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/ContributorTests.swift b/Tests/SharedTests/Publication/ContributorTests.swift index f2c027fbd1..72cc201ffc 100644 --- a/Tests/SharedTests/Publication/ContributorTests.swift +++ b/Tests/SharedTests/Publication/ContributorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/Archive/Properties+ArchiveTests.swift b/Tests/SharedTests/Publication/Extensions/Archive/Properties+ArchiveTests.swift index 34ee9761d3..f64f797f0d 100644 --- a/Tests/SharedTests/Publication/Extensions/Archive/Properties+ArchiveTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Archive/Properties+ArchiveTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift index ebc4706304..a483ceee78 100644 --- a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/EPUBLayoutTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/EPUBLayoutTests.swift index 90b27bae05..eb03f53caa 100644 --- a/Tests/SharedTests/Publication/Extensions/EPUB/EPUBLayoutTests.swift +++ b/Tests/SharedTests/Publication/Extensions/EPUB/EPUBLayoutTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift index e43b3db63f..0d2f07fd55 100644 --- a/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift +++ b/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/Publication+EPUBTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/Publication+EPUBTests.swift index f7ecb24cc1..7f2acd6f2d 100644 --- a/Tests/SharedTests/Publication/Extensions/EPUB/Publication+EPUBTests.swift +++ b/Tests/SharedTests/Publication/Extensions/EPUB/Publication+EPUBTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/Encryption/EncryptionTests.swift b/Tests/SharedTests/Publication/Extensions/Encryption/EncryptionTests.swift index 6f16d3abb8..9debc4ea77 100644 --- a/Tests/SharedTests/Publication/Extensions/Encryption/EncryptionTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Encryption/EncryptionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/Encryption/Properties+EncryptionTests.swift b/Tests/SharedTests/Publication/Extensions/Encryption/Properties+EncryptionTests.swift index b3fdc40e7d..bf4641f440 100644 --- a/Tests/SharedTests/Publication/Extensions/Encryption/Properties+EncryptionTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Encryption/Properties+EncryptionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/HTML/DOMRangeTests.swift b/Tests/SharedTests/Publication/Extensions/HTML/DOMRangeTests.swift index 9fc9e78047..97ae6288d9 100644 --- a/Tests/SharedTests/Publication/Extensions/HTML/DOMRangeTests.swift +++ b/Tests/SharedTests/Publication/Extensions/HTML/DOMRangeTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/HTML/Locator+HTMLTests.swift b/Tests/SharedTests/Publication/Extensions/HTML/Locator+HTMLTests.swift index 56d7ddce51..e2622abf46 100644 --- a/Tests/SharedTests/Publication/Extensions/HTML/Locator+HTMLTests.swift +++ b/Tests/SharedTests/Publication/Extensions/HTML/Locator+HTMLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift b/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift index 95372c23f8..f86050afc0 100644 --- a/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift +++ b/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Extensions/OPDS/Publication+OPDSTests.swift b/Tests/SharedTests/Publication/Extensions/OPDS/Publication+OPDSTests.swift index 757cf46541..dbda440e55 100644 --- a/Tests/SharedTests/Publication/Extensions/OPDS/Publication+OPDSTests.swift +++ b/Tests/SharedTests/Publication/Extensions/OPDS/Publication+OPDSTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/HREFNormalizerTests.swift b/Tests/SharedTests/Publication/HREFNormalizerTests.swift index f77e0213a2..1011b350c3 100644 --- a/Tests/SharedTests/Publication/HREFNormalizerTests.swift +++ b/Tests/SharedTests/Publication/HREFNormalizerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/LinkArrayTests.swift b/Tests/SharedTests/Publication/LinkArrayTests.swift index 011fea654e..383841b5f4 100644 --- a/Tests/SharedTests/Publication/LinkArrayTests.swift +++ b/Tests/SharedTests/Publication/LinkArrayTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/LinkTests.swift b/Tests/SharedTests/Publication/LinkTests.swift index ee6dac7bb0..f0d0b39d68 100644 --- a/Tests/SharedTests/Publication/LinkTests.swift +++ b/Tests/SharedTests/Publication/LinkTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/LocalizedStringTests.swift b/Tests/SharedTests/Publication/LocalizedStringTests.swift index 6c5103864c..64f79f81b8 100644 --- a/Tests/SharedTests/Publication/LocalizedStringTests.swift +++ b/Tests/SharedTests/Publication/LocalizedStringTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/LocatorTests.swift b/Tests/SharedTests/Publication/LocatorTests.swift index be4764f5ac..8fb6b87c0c 100644 --- a/Tests/SharedTests/Publication/LocatorTests.swift +++ b/Tests/SharedTests/Publication/LocatorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/ManifestTests.swift b/Tests/SharedTests/Publication/ManifestTests.swift index 04ff656567..044dc3c8b5 100644 --- a/Tests/SharedTests/Publication/ManifestTests.swift +++ b/Tests/SharedTests/Publication/ManifestTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/MetadataTests.swift b/Tests/SharedTests/Publication/MetadataTests.swift index 11636594e7..856506efb7 100644 --- a/Tests/SharedTests/Publication/MetadataTests.swift +++ b/Tests/SharedTests/Publication/MetadataTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/PropertiesTests.swift b/Tests/SharedTests/Publication/PropertiesTests.swift index c83d103246..2a9f00dc50 100644 --- a/Tests/SharedTests/Publication/PropertiesTests.swift +++ b/Tests/SharedTests/Publication/PropertiesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/PublicationCollectionTests.swift b/Tests/SharedTests/Publication/PublicationCollectionTests.swift index 1a8c534052..381207b070 100644 --- a/Tests/SharedTests/Publication/PublicationCollectionTests.swift +++ b/Tests/SharedTests/Publication/PublicationCollectionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/PublicationTests.swift b/Tests/SharedTests/Publication/PublicationTests.swift index 8672465445..305613d28a 100644 --- a/Tests/SharedTests/Publication/PublicationTests.swift +++ b/Tests/SharedTests/Publication/PublicationTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/ReadingProgressionTests.swift b/Tests/SharedTests/Publication/ReadingProgressionTests.swift index 0e9cd87b3e..ba0dfdac37 100644 --- a/Tests/SharedTests/Publication/ReadingProgressionTests.swift +++ b/Tests/SharedTests/Publication/ReadingProgressionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift index ff2b0b10b9..968c162ba0 100644 --- a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Services/Content Protection/UserRightsTests.swift b/Tests/SharedTests/Publication/Services/Content Protection/UserRightsTests.swift index a41929230d..c6b9cdea1d 100644 --- a/Tests/SharedTests/Publication/Services/Content Protection/UserRightsTests.swift +++ b/Tests/SharedTests/Publication/Services/Content Protection/UserRightsTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift index c8380d6a6a..367a9db33c 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift index 9aab141c26..ede34be533 100644 --- a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift b/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift index 6b22bef465..0fca96d6f2 100644 --- a/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift index 683738322c..41817cbba6 100644 --- a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift b/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift index 64efb13866..0d35295b3d 100644 --- a/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift index e6ee531f92..457fce0bc4 100644 --- a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift index bbf8c52726..616b3ab83b 100644 --- a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift +++ b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/SubjectTests.swift b/Tests/SharedTests/Publication/SubjectTests.swift index 9526c31d16..a9959d257c 100644 --- a/Tests/SharedTests/Publication/SubjectTests.swift +++ b/Tests/SharedTests/Publication/SubjectTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Publication/TDMTests.swift b/Tests/SharedTests/Publication/TDMTests.swift index 3679095f68..31421d5cda 100644 --- a/Tests/SharedTests/Publication/TDMTests.swift +++ b/Tests/SharedTests/Publication/TDMTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/Data/Asset/AssetRetrieverTests.swift b/Tests/SharedTests/Toolkit/Data/Asset/AssetRetrieverTests.swift index 9254b002d0..5776c420fc 100644 --- a/Tests/SharedTests/Toolkit/Data/Asset/AssetRetrieverTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Asset/AssetRetrieverTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift index 4b8812de7d..b0bdd9a759 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift index cf3745baed..515d4dab5e 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/DocumentTypesTests.swift b/Tests/SharedTests/Toolkit/DocumentTypesTests.swift index 4b615abcf7..53e4310e34 100644 --- a/Tests/SharedTests/Toolkit/DocumentTypesTests.swift +++ b/Tests/SharedTests/Toolkit/DocumentTypesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/Extensions/UIImageTests.swift b/Tests/SharedTests/Toolkit/Extensions/UIImageTests.swift index e129dfca50..0892c6d1f2 100644 --- a/Tests/SharedTests/Toolkit/Extensions/UIImageTests.swift +++ b/Tests/SharedTests/Toolkit/Extensions/UIImageTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift b/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift index ac262b9b86..7cd069aaa0 100644 --- a/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift +++ b/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift b/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift index 62e8b75807..977003d166 100644 --- a/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift +++ b/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift b/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift index 2cbfdde547..614e70ee76 100644 --- a/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift +++ b/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift index aaf9222cc2..a2a639a3b9 100644 --- a/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/Tokenizer/TextTokenizerTests.swift b/Tests/SharedTests/Toolkit/Tokenizer/TextTokenizerTests.swift index 2b69c59d7b..be0cecf4e6 100644 --- a/Tests/SharedTests/Toolkit/Tokenizer/TextTokenizerTests.swift +++ b/Tests/SharedTests/Toolkit/Tokenizer/TextTokenizerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/URITemplateTests.swift b/Tests/SharedTests/Toolkit/URITemplateTests.swift index 7f1bb2b634..f3210897d9 100644 --- a/Tests/SharedTests/Toolkit/URITemplateTests.swift +++ b/Tests/SharedTests/Toolkit/URITemplateTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift index b033592c04..5141ea005d 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift index cdbd8d7712..097476491f 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift index 7484281fc0..13ebfa33cf 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift b/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift index ed7881e3d7..9dc6084580 100644 --- a/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift b/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift index 2dc3225de1..1390cd8d92 100644 --- a/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift b/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift index 4acfade51c..2fa0ff10c9 100644 --- a/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift +++ b/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/XML/XMLTests.swift b/Tests/SharedTests/Toolkit/XML/XMLTests.swift index dd59e4409d..6931dd2c43 100644 --- a/Tests/SharedTests/Toolkit/XML/XMLTests.swift +++ b/Tests/SharedTests/Toolkit/XML/XMLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift b/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift index 1891c8320e..236c9df436 100644 --- a/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift +++ b/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift b/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift index db1b72e8d5..319a9c1b07 100644 --- a/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift +++ b/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Asserts.swift b/Tests/StreamerTests/Asserts.swift index 66a2378f8c..57bb5ebce2 100644 --- a/Tests/StreamerTests/Asserts.swift +++ b/Tests/StreamerTests/Asserts.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/EquatableError.swift b/Tests/StreamerTests/EquatableError.swift index fcaa6754a7..0b9a356c68 100644 --- a/Tests/StreamerTests/EquatableError.swift +++ b/Tests/StreamerTests/EquatableError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Extensions.swift b/Tests/StreamerTests/Extensions.swift index dbbd600041..6b6c1221f3 100644 --- a/Tests/StreamerTests/Extensions.swift +++ b/Tests/StreamerTests/Extensions.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Fixtures.swift b/Tests/StreamerTests/Fixtures.swift index bf41940f81..78cb22d894 100644 --- a/Tests/StreamerTests/Fixtures.swift +++ b/Tests/StreamerTests/Fixtures.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift b/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift index 6f8cf1991f..c1ec0eee02 100644 --- a/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift +++ b/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift index fff5170180..c5bd99899d 100644 --- a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift +++ b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBContainerParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBContainerParserTests.swift index 0a1e537293..99a19e1411 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBContainerParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBContainerParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift index 540f4ca597..bc4e377adf 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift index 3ad62840e0..0405fa9888 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift index 14920fa893..25877ed2e3 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/EPUB/NCXParserTests.swift b/Tests/StreamerTests/Parser/EPUB/NCXParserTests.swift index 1e1e3f45ef..30f208f99e 100644 --- a/Tests/StreamerTests/Parser/EPUB/NCXParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/NCXParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/EPUB/NavigationDocumentParserTests.swift b/Tests/StreamerTests/Parser/EPUB/NavigationDocumentParserTests.swift index fbb9f5ce3c..c422bd75bc 100644 --- a/Tests/StreamerTests/Parser/EPUB/NavigationDocumentParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/NavigationDocumentParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift index 42193b482c..5e4ca281d3 100644 --- a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift b/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift index af8d1bb6ff..f04a0c6d62 100644 --- a/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift index 9f81852dde..fa8b66facd 100644 --- a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index f889fbf9e1..b06ac3fbcf 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift b/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift index ac391efebf..4f2424e447 100644 --- a/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift +++ b/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift b/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift index c88bc89879..a0dd77af00 100644 --- a/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift +++ b/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // From 24fad0d51ff1d0604b47dfca909570669686a876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 7 Jan 2026 16:30:36 +0100 Subject: [PATCH 15/55] Update outdated documentation (#690) --- CHANGELOG.md | 4 +- docs/Guides/EPUB Fonts.md | 120 ---------------------------- docs/Guides/Navigator/EPUB Fonts.md | 14 ++-- docs/Guides/Navigator/Navigator.md | 27 +++---- docs/Guides/Navigator/SwiftUI.md | 12 ++- docs/Guides/TTS.md | 2 +- 6 files changed, 30 insertions(+), 149 deletions(-) delete mode 100644 docs/Guides/EPUB Fonts.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a5d0b0ef..298194a384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -523,8 +523,8 @@ All notable changes to this project will be documented in this file. Take a look * New `VisualNavigatorDelegate` APIs to handle keyboard events (contributed by [@lukeslu](https://github.com/readium/swift-toolkit/pull/267)). * This can be used to turn pages with the arrow keys, for example. -* [Support for custom fonts with the EPUB navigator](docs/Guides/EPUB%20Fonts.md). -* A brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](docs/Guides/Navigator%20Preferences.md) and [migration guide](docs/Migration%20Guide.md). +* [Support for custom fonts with the EPUB navigator](docs/Guides/Navigator/EPUB%20Fonts.md). +* A brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](docs/Guides/Navigator/Preferences.md) and [migration guide](docs/Migration%20Guide.md). * New EPUB user preferences: * `fontWeight` - Base text font weight. * `textNormalization` - Normalize font style, weight and variants, which improves accessibility. diff --git a/docs/Guides/EPUB Fonts.md b/docs/Guides/EPUB Fonts.md deleted file mode 100644 index e61d78a665..0000000000 --- a/docs/Guides/EPUB Fonts.md +++ /dev/null @@ -1,120 +0,0 @@ -# Font families in the EPUB navigator - -Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](Navigator%20Preferences.md). - -> [!NOTE] -> You cannot change the default font family of a fixed-layout EPUB (with zoomable pages), as it is similar to a PDF or a comic book. - -## Available font families - -iOS ships with a large collection of font families that you can use directly in the EPUB preferences. [Take a look at the Apple catalog of System Fonts](https://developer.apple.com/fonts/system-fonts/). - -To improve readability, Readium embeds three additional font families designed for accessibility: - -* [OpenDyslexic](https://opendyslexic.org/) -* [AccessibleDfA](https://github.com/Orange-OpenSource/font-accessible-dfa), by Orange -* [iA Writer Duospace](https://github.com/iaolo/iA-Fonts/tree/master/iA%20Writer%20Duospace), by iA - -You can use all the iOS System Fonts out of the box with the EPUB navigator: - -```swift -epubNavigator.submitPreferences(EPUBPreferences( - fontFamily: "Palatino" -)) -``` - -Alternatively, extend `FontFamily` to benefit from the compiler type safety: - -```swift -extension FontFamily { - public static let palatino: FontFamily = "Palatino" -} - -epubNavigator.submitPreferences(EPUBPreferences( - fontFamily: .palatino -)) -``` - -For your convenience, a number of [recommended fonts](https://readium.org/readium-css/docs/CSS09-default_fonts) are pre-declared in the `FontFamily` type: Iowan Old Style, Palatino, Athelas, Georgia, Helvetica Neue, Seravek and Arial. - -## Setting the available font families in the user interface - -If you build your settings user interface with the EPUB Preferences Editor, you can customize the list of available font families using `with(supportedValues:)`. - -```swift -epubPreferencesEditor.fontFamily.with(supportedValues: [ - nil, // A `nil` value means that the original author font will be used. - .palatino, - .helveticaNeue, - .iaWriterDuospace, - .accessibleDfA, - .openDyslexic -]) -``` - -## How to add custom font families? - -To offer more choices to your users, you must embed and declare custom font families. Use the following steps: - -1. Get the font files in the desired format, such as .ttf and .otf. [Google Fonts](https://fonts.google.com/) is a good source of free fonts. -2. Add the files to your app target from Xcode. -3. Declare new extensions for your custom font families to make them first-class citizens. This is optional but convenient. - ```swift - extension FontFamily { - public static let literata: FontFamily = "Literata" - public static let atkinsonHyperlegible: FontFamily = "Atkinson Hyperlegible" - } - ``` -4. Configure the EPUB navigator with a declaration of the font faces for all the additional font families. - ```swift - let resources = Bundle.main.resourceURL! - let navigator = try EPUBNavigatorViewController( - publication: publication, - initialLocation: locator, - config: .init( - fontFamilyDeclarations: [ - CSSFontFamilyDeclaration( - fontFamily: .literata, - fontFaces: [ - // Literata is a variable font family, so we can provide a font weight range. - // https://fonts.google.com/knowledge/glossary/variable_fonts - CSSFontFace( - file: resources.appendingPathComponent("Literata-VariableFont_opsz,wght.ttf"), - style: .normal, weight: .variable(200...900) - ), - CSSFontFace( - file: resources.appendingPathComponent("Literata-Italic-VariableFont_opsz,wght.ttf"), - style: .italic, weight: .variable(200...900) - ) - ] - ).eraseToAnyHTMLFontFamilyDeclaration(), - - CSSFontFamilyDeclaration( - fontFamily: .atkinsonHyperlegible, - fontFaces: [ - CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Regular.ttf"), - style: .normal, weight: .standard(.normal) - ), - CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Italic.ttf"), - style: .italic, weight: .standard(.normal) - ), - CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Bold.ttf"), - style: .normal, weight: .standard(.bold) - ), - CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-BoldItalic.ttf"), - style: .italic, weight: .standard(.bold) - ), - ] - ).eraseToAnyHTMLFontFamilyDeclaration() - ] - ), - httpServer: GCDHTTPServer.shared - ) - ``` - -You are now ready to use your custom font families. - diff --git a/docs/Guides/Navigator/EPUB Fonts.md b/docs/Guides/Navigator/EPUB Fonts.md index e61d78a665..fc49be60e2 100644 --- a/docs/Guides/Navigator/EPUB Fonts.md +++ b/docs/Guides/Navigator/EPUB Fonts.md @@ -67,7 +67,7 @@ To offer more choices to your users, you must embed and declare custom font fami ``` 4. Configure the EPUB navigator with a declaration of the font faces for all the additional font families. ```swift - let resources = Bundle.main.resourceURL! + let resources = FileURL(url: Bundle.main.resourceURL!)! let navigator = try EPUBNavigatorViewController( publication: publication, initialLocation: locator, @@ -79,11 +79,11 @@ To offer more choices to your users, you must embed and declare custom font fami // Literata is a variable font family, so we can provide a font weight range. // https://fonts.google.com/knowledge/glossary/variable_fonts CSSFontFace( - file: resources.appendingPathComponent("Literata-VariableFont_opsz,wght.ttf"), + file: resources.appendingPath("Literata-VariableFont_opsz,wght.ttf", isDirectory: false), style: .normal, weight: .variable(200...900) ), CSSFontFace( - file: resources.appendingPathComponent("Literata-Italic-VariableFont_opsz,wght.ttf"), + file: resources.appendingPath("Literata-Italic-VariableFont_opsz,wght.ttf", isDirectory: false), style: .italic, weight: .variable(200...900) ) ] @@ -93,19 +93,19 @@ To offer more choices to your users, you must embed and declare custom font fami fontFamily: .atkinsonHyperlegible, fontFaces: [ CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Regular.ttf"), + file: resources.appendingPath("Atkinson-Hyperlegible-Regular.ttf", isDirectory: false), style: .normal, weight: .standard(.normal) ), CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Italic.ttf"), + file: resources.appendingPath("Atkinson-Hyperlegible-Italic.ttf", isDirectory: false), style: .italic, weight: .standard(.normal) ), CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Bold.ttf"), + file: resources.appendingPath("Atkinson-Hyperlegible-Bold.ttf", isDirectory: false), style: .normal, weight: .standard(.bold) ), CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-BoldItalic.ttf"), + file: resources.appendingPath("Atkinson-Hyperlegible-BoldItalic.ttf", isDirectory: false), style: .italic, weight: .standard(.bold) ), ] diff --git a/docs/Guides/Navigator/Navigator.md b/docs/Guides/Navigator/Navigator.md index ff5cd2d5f7..4a00a4a61b 100644 --- a/docs/Guides/Navigator/Navigator.md +++ b/docs/Guides/Navigator/Navigator.md @@ -167,30 +167,21 @@ To find the total positions in the publication, use `publication.positions.count ## Navigating with edge taps and keyboard arrows -Readium provides a `DirectionalNavigationAdapter` helper to turn pages using arrow and space keys or screen taps. +Readium provides a `DirectionalNavigationAdapter` helper to turn pages when the user taps the edge of the screen or presses the arrow or space keys. -You can use it from your `VisualNavigatorDelegate` implementation: +Bind it to your `Navigator` instance before adding your own input observers, so it takes precedence. ```swift -extension MyReader: VisualNavigatorDelegate { +DirectionalNavigationAdapter().bind(to: navigator) - func navigator(_ navigator: VisualNavigator, didTapAt point: CGPoint) { - // Turn pages when tapping the edge of the screen. - guard !DirectionalNavigationAdapter(navigator: navigator).didTap(at: point) else { - return - } - - toggleNavigationBar() - } - - func navigator(_ navigator: VisualNavigator, didPressKey event: KeyEvent) { - // Turn pages when pressing the arrow keys. - DirectionalNavigationAdapter(navigator: navigator).didPressKey(event: event) - } -} +// Toggle the navigation bar when the user taps outside the edge zones. +navigator.addObserver(.tap { [weak self] _ in + self?.toggleNavigationBar() + return true +}) ``` -`DirectionalNavigationAdapter` offers a lot of customization options. Take a look at its API. +`DirectionalNavigationAdapter` offers many customization options. Take a look at its API. ## User preferences diff --git a/docs/Guides/Navigator/SwiftUI.md b/docs/Guides/Navigator/SwiftUI.md index 5ca3b13fef..dab87260d6 100644 --- a/docs/Guides/Navigator/SwiftUI.md +++ b/docs/Guides/Navigator/SwiftUI.md @@ -133,4 +133,14 @@ let view = ReaderView( ## Handling touch and keyboard events -You still need to implement the `VisualNavigatorDelegate` protocol to handle gestures in the navigator. Avoid using SwiftUI touch modifiers, as they will prevent the user from interacting with the book. +Use the navigator's input observer API to handle touch and keyboard events. Avoid using SwiftUI touch modifiers, as they will prevent the user from interacting with the book. + +```swift +// Example: Toggle UI visibility when the user taps the navigator. +navigator.addObserver(.tap { [weak viewModel] _ in + viewModel?.toggleUIVisibility() + return true +}) +``` + +See the [Input guide](Input.md) for more details on handling user input. diff --git a/docs/Guides/TTS.md b/docs/Guides/TTS.md index 31505058e3..6c3eb7c253 100644 --- a/docs/Guides/TTS.md +++ b/docs/Guides/TTS.md @@ -93,7 +93,7 @@ While `PublicationSpeechSynthesizer` is completely independent from `Navigator` `PublicationSpeechSynthesizer.start()` takes a starting `Locator` for parameter. You can use it to begin the playback from the currently visible page in a `VisualNavigator` using `firstVisibleElementLocator()`. ```swift -navigator.firstVisibleElementLocator { start in +if let start = await navigator.firstVisibleElementLocator() { synthesizer.start(from: start) } ``` From 59c1f62885f89107e0d3d1306461f9d0fe12e705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 9 Jan 2026 16:03:30 +0100 Subject: [PATCH 16/55] Parse `ComicInfo.xml` metadata in Comic Books archives (#691) --- CHANGELOG.md | 9 +- .../Parser/Image/ComicInfoParser.swift | 357 +++++++++++ .../Streamer/Parser/Image/ImageParser.swift | 64 +- Support/Carthage/.xcodegen | 5 + .../Readium.xcodeproj/project.pbxproj | 4 + .../StreamerTests/Fixtures/test-comicinfo.cbz | Bin 0 -> 1316 bytes .../Parser/Image/ComicInfoParserTests.swift | 575 ++++++++++++++++++ .../Parser/Image/ImageParserTests.swift | 31 + 8 files changed, 1035 insertions(+), 10 deletions(-) create mode 100644 Sources/Streamer/Parser/Image/ComicInfoParser.swift create mode 100644 Tests/StreamerTests/Fixtures/test-comicinfo.cbz create mode 100644 Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 298194a384..4261e0f11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,14 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. - +## [Unreleased] + +### Added + +#### Streamer + +* The `ImageParser` now extracts metadata from `ComicInfo.xml` files in CBZ archives. + ## [3.6.0] diff --git a/Sources/Streamer/Parser/Image/ComicInfoParser.swift b/Sources/Streamer/Parser/Image/ComicInfoParser.swift new file mode 100644 index 0000000000..310d042c3c --- /dev/null +++ b/Sources/Streamer/Parser/Image/ComicInfoParser.swift @@ -0,0 +1,357 @@ +// +// 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 ReadiumFuzi +import ReadiumShared + +/// Parses ComicInfo.xml metadata from CBZ archives. +/// +/// ComicInfo.xml is a metadata format originating from the ComicRack +/// application. +/// See: https://anansi-project.github.io/docs/comicinfo/documentation +struct ComicInfoParser { + /// Parses ComicInfo.xml data and returns the parsed metadata. + static func parse(data: Data, warnings: WarningLogger?) -> ComicInfo? { + guard let document = try? XMLDocument(data: data) else { + warnings?.log(ComicInfoWarning(message: "Failed to parse ComicInfo.xml")) + return nil + } + + guard let root = document.root, root.tag == "ComicInfo" else { + warnings?.log(ComicInfoWarning(message: "ComicInfo.xml root element is not ")) + return nil + } + + return ComicInfo(element: root) + } +} + +/// Warning raised when parsing a ComicInfo.xml file. +struct ComicInfoWarning: Warning { + let message: String + var severity: WarningSeverityLevel { .minor } + var tag: String { "comicinfo" } +} + +/// Parsed representation of ComicInfo.xml data. +/// +/// Only metadata fields that map to RWPM are exposed as first-class properties. +/// All other fields are available in the `otherMetadata` dictionary. +/// +/// See https://anansi-project.github.io/docs/comicinfo/documentation +struct ComicInfo { + /// Title of the book. + var title: String? + + /// Title of the series the book is part of. + var series: String? + + /// Number of the book in the series. + var number: String? + + /// Alternate series name, used for cross-over story arcs. + var alternateSeries: String? + + /// Number of the book in the alternate series. + var alternateNumber: String? + + /// A description or summary of the book. + var summary: String? + + /// Person or organization responsible for publishing, releasing, or + /// issuing a resource. + var publisher: String? + + /// An imprint is a group of publications under the umbrella of a larger + /// imprint or publisher. + var imprint: String? + + /// Release year of the book. + var year: Int? + + /// Release month of the book. + var month: Int? + + /// Release day of the book. + var day: Int? + + /// Language of the book using IETF BCP 47 language tags. + var languageISO: String? + + /// Global Trade Item Number identifying the book (ISBN, EAN, etc.). + var gtin: String? + + /// People or organizations responsible for creating the scenario. + var writers: [String] = [] + + /// People or organizations responsible for drawing the art. + var pencillers: [String] = [] + + /// People or organizations responsible for inking the pencil art. + var inkers: [String] = [] + + /// People or organizations responsible for applying color to drawings. + var colorists: [String] = [] + + /// People or organizations responsible for drawing text and speech bubbles. + var letterers: [String] = [] + + /// People or organizations responsible for drawing the cover art. + var coverArtists: [String] = [] + + /// People or organizations responsible for preparing the resource for + /// production. + var editors: [String] = [] + + /// People or organizations responsible for rendering text from one language + /// into another. + var translators: [String] = [] + + /// Genres of the book or series (e.g., Science-Fiction, Shonen). + var genres: [String] = [] + + /// Whether the book is a manga. The value `.yesAndRightToLeft` indicates + /// right-to-left reading direction. + var manga: Manga? + + /// Page information parsed from the element. + var pages: [PageInfo] = [] + + /// Returns the first page with the given type, if any. + func firstPageWithType(_ type: PageType) -> PageInfo? { + pages.first { $0.type == type } + } + + /// All other metadata fields not directly mapped to RWPM. + /// + /// Keys are the XML tag names (e.g., "Volume", "Characters", "AgeRating"). + /// Values are strings as they appear in the XML. + var otherMetadata: [String: String] = [:] + + /// URL prefix for otherMetadata keys when converting to RWPM. + private static let otherMetadataPrefix = "https://anansi-project.github.io/docs/comicinfo/documentation#" + + init(element: ReadiumFuzi.XMLElement) { + for child in element.children { + guard let tag = child.tag else { continue } + + // Pages element has no text content, only child elements + if tag == "Pages" { + pages = child.children(tag: "Page").compactMap { PageInfo(element: $0) } + continue + } + + let value = child.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { continue } + + switch tag { + // Core + case "AlternateNumber": alternateNumber = value + case "AlternateSeries": alternateSeries = value + case "Day": day = Int(value) + case "GTIN": gtin = value + case "Genre": genres = value.splitComma() + case "Imprint": imprint = value + case "LanguageISO": languageISO = value + case "Manga": manga = Manga(rawValue: value) + case "Month": month = Int(value) + case "Number": number = value + case "Publisher": publisher = value + case "Series": series = value + case "Summary": summary = value + case "Title": title = value + case "Year": year = Int(value) + + // Contributors + case "Colorist": colorists = value.splitComma() + case "CoverArtist": coverArtists = value.splitComma() + case "Editor": editors = value.splitComma() + case "Inker": inkers = value.splitComma() + case "Letterer": letterers = value.splitComma() + case "Penciller": pencillers = value.splitComma() + case "Translator": translators = value.splitComma() + case "Writer": writers = value.splitComma() + + // Everything else goes to otherMetadata + default: otherMetadata[tag] = value + } + } + } + + /// Converts to RWPM Metadata. + func toMetadata() -> Metadata { + // Build published date from year/month/day + var published: Date? + if let year = year { + var components = DateComponents() + components.year = year + components.month = month ?? 1 + components.day = day ?? 1 + published = Calendar(identifier: .gregorian).date(from: components) + } + + // Parse series + var belongsToSeries: [Contributor] = [] + if let series = series { + let position = number.flatMap { Double($0) } + belongsToSeries.append(Contributor(name: series, position: position)) + } + if let alternateSeries = alternateSeries { + let position = alternateNumber.flatMap { Double($0) } + belongsToSeries.append(Contributor(name: alternateSeries, position: position)) + } + + // Build other metadata with specification URL prefix + var rwpmOtherMetadata: [String: Any] = [:] + for (key, value) in otherMetadata { + rwpmOtherMetadata[Self.otherMetadataPrefix + key.lowercased()] = value + } + + return Metadata( + identifier: gtin, + title: title, + published: published, + languages: languageISO.map { [$0] } ?? [], + subjects: genres.map { Subject(name: $0) }, + authors: writers.map { Contributor(name: $0) }, + translators: translators.map { Contributor(name: $0) }, + editors: editors.map { Contributor(name: $0) }, + letterers: letterers.map { Contributor(name: $0) }, + pencilers: pencillers.map { Contributor(name: $0) }, + colorists: colorists.map { Contributor(name: $0) }, + inkers: inkers.map { Contributor(name: $0) }, + contributors: coverArtists.map { Contributor(name: $0, role: "cov") }, + publishers: publisher.map { [Contributor(name: $0)] } ?? [], + imprints: imprint.map { [Contributor(name: $0)] } ?? [], + readingProgression: (manga == .yesAndRightToLeft) ? .rtl : .auto, + description: summary, + belongsToSeries: belongsToSeries, + otherMetadata: rwpmOtherMetadata + ) + } + + // MARK: - ComicInfo Types + + /// Page type values from the ComicInfo specification. + /// + /// See: https://anansi-project.github.io/docs/comicinfo/documentation#type + enum PageType: Hashable, Sendable { + case frontCover + case innerCover + case roundup + case story + case advertisement + case editorial + case letters + case preview + case backCover + case other + case deleted + + /// Case-insensitive initializer. + init?(rawValue: String) { + switch rawValue.lowercased() { + case "frontcover": self = .frontCover + case "innercover": self = .innerCover + case "roundup": self = .roundup + case "story": self = .story + case "advertisement": self = .advertisement + case "editorial": self = .editorial + case "letters": self = .letters + case "preview": self = .preview + case "backcover": self = .backCover + case "other": self = .other + case "deleted", "delete": self = .deleted + default: return nil + } + } + } + + /// Information about a single page from ComicInfo.xml. + /// + /// See: https://anansi-project.github.io/docs/comicinfo/documentation#pages--comicpageinfo + struct PageInfo: Hashable, Sendable { + /// Zero-based index of this page in the reading order. + let image: Int + + /// The type/purpose of this page. + let type: PageType? + + /// Whether this is a double-page spread. + let doublePage: Bool? + + /// File size in bytes. + let imageSize: Int64? + + /// Page key/identifier. + let key: String? + + /// Bookmark name for this page. + let bookmark: String? + + /// Width of the page image in pixels. + let imageWidth: Int? + + /// Height of the page image in pixels. + let imageHeight: Int? + + /// Parses a PageInfo from an XML element. + init?(element: ReadiumFuzi.XMLElement) { + guard + let imageStr = element.attr("Image"), + let image = Int(imageStr) + else { + return nil + } + + self.image = image + type = element.attr("Type").flatMap { PageType(rawValue: $0) } + doublePage = element.attr("DoublePage").flatMap { + switch $0.lowercased() { + case "true", "1": return true + case "false", "0": return false + default: return nil + } + } + imageSize = element.attr("ImageSize").flatMap { Int64($0) } + key = element.attr("Key") + bookmark = element.attr("Bookmark") + imageWidth = element.attr("ImageWidth").flatMap { Int($0) } + imageHeight = element.attr("ImageHeight").flatMap { Int($0) } + } + } + + /// Manga field values indicating whether the book is a manga and its + /// reading direction. + /// + /// See: https://anansi-project.github.io/docs/comicinfo/documentation#manga + enum Manga { + case unknown + case no + case yes + case yesAndRightToLeft + + /// Case-insensitive initializer. + init?(rawValue: String) { + switch rawValue.lowercased() { + case "unknown": self = .unknown + case "no": self = .no + case "yes": self = .yes + case "yesandrighttoleft": self = .yesAndRightToLeft + default: return nil + } + } + } +} + +private extension String { + func splitComma() -> [String] { + split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } +} diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index 09fc983b03..4cfeb67dc5 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -54,7 +54,7 @@ public final class ImageParser: PublicationParser { return makeBuilder( container: container, readingOrder: [(container.entry, asset.format)], - title: nil + fallbackTitle: nil ) } @@ -66,16 +66,34 @@ public final class ImageParser: PublicationParser { return .failure(.formatNotSupported) } + // Parse ComicInfo.xml metadata if present + let comicInfo = await parseComicInfo(from: asset.container, warnings: warnings) + let fallbackTitle = asset.container.guessTitle(ignoring: ignores) + return await makeReadingOrder(for: asset.container) .flatMap { readingOrder in makeBuilder( container: asset.container, readingOrder: readingOrder, - title: asset.container.guessTitle(ignoring: ignores) + fallbackTitle: fallbackTitle, + comicInfo: comicInfo ) } } + /// Finds and parses the ComicInfo.xml file from the container. + private func parseComicInfo(from container: Container, warnings: WarningLogger?) async -> ComicInfo? { + // Look for ComicInfo.xml at the root or in a subdirectory + guard + let url = container.entries.first(where: { $0.lastPathSegment?.lowercased() == "comicinfo.xml" }), + let data = try? await container.readData(at: url) + else { + return nil + } + + return ComicInfoParser.parse(data: data, warnings: warnings) + } + private func makeReadingOrder(for container: Container) async -> Result<[(AnyURL, Format)], PublicationParseError> { await container .sniffFormats( @@ -113,7 +131,8 @@ public final class ImageParser: PublicationParser { private func makeBuilder( container: Container, readingOrder: [(AnyURL, Format)], - title: String? + fallbackTitle: String?, + comicInfo: ComicInfo? = nil ) -> Result { guard !readingOrder.isEmpty else { return .failure(.reading(.decoding("No bitmap resources found in the publication"))) @@ -126,15 +145,42 @@ public final class ImageParser: PublicationParser { ) } - // First valid resource is the cover. - readingOrder[0].rels = [.cover] + // Determine cover page index + let coverIndex: Int + if + let coverPage = comicInfo?.firstPageWithType(.frontCover), + coverPage.image >= 0, + coverPage.image < readingOrder.count + { + coverIndex = coverPage.image + } else { + // Default: first resource is the cover + coverIndex = 0 + } + readingOrder[coverIndex].rels.append(.cover) + + // Determine story start index (where actual content begins) + // Only set if different from cover page (prefer .cover if same page) + if + let storyPage = comicInfo?.firstPageWithType(.story), + storyPage.image >= 0, + storyPage.image < readingOrder.count, + storyPage.image != coverIndex + { + readingOrder[storyPage.image].rels.append(.start) + } + + // Build metadata from ComicInfo or use defaults + var metadata = comicInfo?.toMetadata() ?? Metadata() + metadata.conformsTo = [.divina] + + if metadata.localizedTitle == nil, let fallbackTitle = fallbackTitle { + metadata.localizedTitle = .nonlocalized(fallbackTitle) + } return .success(Publication.Builder( manifest: Manifest( - metadata: Metadata( - conformsTo: [.divina], - title: title - ), + metadata: metadata, readingOrder: readingOrder ), container: container, diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 39fd18900c..925b48de9f 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -391,6 +391,7 @@ ../../Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift ../../Sources/LCP/Toolkit/Streamable.swift ../../Sources/Navigator +../../Sources/Navigator/.DS_Store ../../Sources/Navigator/Audiobook ../../Sources/Navigator/Audiobook/AudioNavigator.swift ../../Sources/Navigator/Audiobook/Preferences @@ -411,12 +412,14 @@ ../../Sources/Navigator/EPUB/Assets/fxl-spread-one.html ../../Sources/Navigator/EPUB/Assets/fxl-spread-two.html ../../Sources/Navigator/EPUB/Assets/Static +../../Sources/Navigator/EPUB/Assets/Static/.DS_Store ../../Sources/Navigator/EPUB/Assets/Static/fonts ../../Sources/Navigator/EPUB/Assets/Static/fonts/OpenDyslexic-Bold.otf ../../Sources/Navigator/EPUB/Assets/Static/fonts/OpenDyslexic-BoldItalic.otf ../../Sources/Navigator/EPUB/Assets/Static/fonts/OpenDyslexic-Italic.otf ../../Sources/Navigator/EPUB/Assets/Static/fonts/OpenDyslexic-Regular.otf ../../Sources/Navigator/EPUB/Assets/Static/readium-css +../../Sources/Navigator/EPUB/Assets/Static/readium-css/.DS_Store ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css @@ -536,6 +539,7 @@ ../../Sources/Navigator/Input/Pointer/PointerEvent.swift ../../Sources/Navigator/Navigator.swift ../../Sources/Navigator/PDF +../../Sources/Navigator/PDF/.DS_Store ../../Sources/Navigator/PDF/PDFDocumentHolder.swift ../../Sources/Navigator/PDF/PDFDocumentView.swift ../../Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -838,6 +842,7 @@ ../../Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift ../../Sources/Streamer/Parser/EPUB/XMLNamespace.swift ../../Sources/Streamer/Parser/Image +../../Sources/Streamer/Parser/Image/ComicInfoParser.swift ../../Sources/Streamer/Parser/Image/ImageParser.swift ../../Sources/Streamer/Parser/PDF ../../Sources/Streamer/Parser/PDF/PDFParser.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index e83acbe46f..15bc6bad1d 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ 14BDA1321CFDC946E840A5E9 /* HTTPRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7214B2366A4E024517FF8C76 /* HTTPRequest.swift */; }; 14CEE9163A37EBD75C93820D /* Locator+Audio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2732AFC91AB15FA09C60207A /* Locator+Audio.swift */; }; 1574884ABA50F0E3A05051B8 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBB57FCAEE605484A7290DBB /* Atomic.swift */; }; + 15CD611A4A806F4A3350AA34 /* ComicInfoParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471465B269CB080E6585E2A /* ComicInfoParser.swift */; }; 1600DB04CEACF97EE8AD9CEE /* URLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258351CE21165EDED7F87878 /* URLProtocol.swift */; }; 17108D46A0353A254DA193B0 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D002FDDAD1A21AC5BB84CE /* Container.swift */; }; 1752D756BED37325D6D4ED29 /* ReadiumInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42FD63C2720614E558522675 /* ReadiumInternal.framework */; }; @@ -680,6 +681,7 @@ 819D931708B3EE95CF9ADFED /* OPDSCopies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSCopies.swift; sourceTree = ""; }; 8240F845F35439807CE8AF65 /* ContentProtectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentProtectionService.swift; sourceTree = ""; }; 8456BF3665A9B9C0AE4CC158 /* Locator+HTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+HTML.swift"; sourceTree = ""; }; + 8471465B269CB080E6585E2A /* ComicInfoParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComicInfoParser.swift; sourceTree = ""; }; 85F7D914B293DF0A912613D2 /* DirectionalNavigationAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectionalNavigationAdapter.swift; sourceTree = ""; }; 868A38C213F1D0BAF276CF97 /* SingleResourceContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleResourceContainer.swift; sourceTree = ""; }; 87629BF68F1EDBF06FC0AD54 /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; @@ -1958,6 +1960,7 @@ E830BA9857151F8B2BA9705D /* Image */ = { isa = PBXGroup; children = ( + 8471465B269CB080E6585E2A /* ComicInfoParser.swift */, 37087C0D0B36FE7F20F1C891 /* ImageParser.swift */, ); path = Image; @@ -2462,6 +2465,7 @@ 57583D27AB12063C3D114A47 /* AudioParser.swift in Sources */, A9DFAA4F1D752E15B432FFAB /* AudioPublicationManifestAugmentor.swift in Sources */, 61BBCC98965E362FA840DBB8 /* Bundle.swift in Sources */, + 15CD611A4A806F4A3350AA34 /* ComicInfoParser.swift in Sources */, D8B99CCB17F5E71AB3F7CE84 /* CompositePublicationParser.swift in Sources */, 17108D46A0353A254DA193B0 /* Container.swift in Sources */, 694AAAD5C14BC33891458A4C /* DataCompression.swift in Sources */, diff --git a/Tests/StreamerTests/Fixtures/test-comicinfo.cbz b/Tests/StreamerTests/Fixtures/test-comicinfo.cbz new file mode 100644 index 0000000000000000000000000000000000000000..a91655130445311ff19062369a15123a84d2a51b GIT binary patch literal 1316 zcmWIWW@Zs#U|`^22=35`aa!28Q-_g(A)bkWftNvs!8t!SGubmQEnlx9HzzcNlYv?8 zYJ4ULmsW5yFtU6Fss(E=o#g9(*np?){cq90^BZzR75E~TWeKei;#m^rB(wY6Tcxd> zr;fY)w_WCUnr}_}tFNz*uY4Plv;TMB+=EuBU(_P?!#SB_XD`0=R*iGt&RU;sMT%w1 z!b{glD~t2U2Ws7K)Hzsbwk+aZ48y^b>dP2b9}skj`tU+<1OH#fHG9NuRz-;a+PA6s z%#8oKYma}HPL8~As$b4*`;pB`?xFJoB4@6ZbPmD9TjaJ*{{cKA;Zk!>n^R8QnWpBtX z-cat!k7|zGb&|(3FRXlW?}5eQu3QD-aI51w??3MK{9twZA@|YAM_J$S+$%Y>e#7fd z-8~x*hwA(lKRk0K??L^4Q{@AILCysXas~z;V330uP=X5>7++V2?-Cr zektH&y40g^<{*oT`V{v2%t1Eu^!gMDSEu)Sj?gSLr!ZLR&&1~xV-%+d zkR2``m@UsZR6eVp-k`(gA8tRr)Q{5_=oCgKIc8jWO9B`=3=F{R!?2_g#6rtytdN|B z5$hl$aitxIkqit13~wEafhHrTB%pa%QxU?6nCS>+9>|H?QOrY0Rm2&MnktZub^(?( p2&Y1fhNcnXj7E)RWTVexF&Y}@xQu3H1H}d_5N-i_If@y?0|3Kzjd}n8 literal 0 HcmV?d00001 diff --git a/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift b/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift new file mode 100644 index 0000000000..c740337099 --- /dev/null +++ b/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift @@ -0,0 +1,575 @@ +// +// 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 +@testable import ReadiumStreamer +import XCTest + +class ComicInfoParserTests: XCTestCase { + // MARK: - Basic Parsing + + func testParseMinimalComicInfo() { + let xml = """ + + + Test Issue + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.title, "Test Issue") + } + + func testParseCompleteComicInfo() { + let xml = """ + + + The Beginning + Batman + 1 + The Dark Knight returns... + 2020 + 3 + 15 + Frank Miller, Bob Kane + Jim Lee + Scott Williams + Alex Sinclair + Richard Starkings + Jim Lee + Bob Harras + John Doe + DC Comics + Vertigo + Superhero, Action + en + 978-1234567890 + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.title, "The Beginning") + XCTAssertEqual(result?.series, "Batman") + XCTAssertEqual(result?.number, "1") + XCTAssertEqual(result?.summary, "The Dark Knight returns...") + XCTAssertEqual(result?.year, 2020) + XCTAssertEqual(result?.month, 3) + XCTAssertEqual(result?.day, 15) + XCTAssertEqual(result?.writers, ["Frank Miller", "Bob Kane"]) + XCTAssertEqual(result?.pencillers, ["Jim Lee"]) + XCTAssertEqual(result?.inkers, ["Scott Williams"]) + XCTAssertEqual(result?.colorists, ["Alex Sinclair"]) + XCTAssertEqual(result?.letterers, ["Richard Starkings"]) + XCTAssertEqual(result?.coverArtists, ["Jim Lee"]) + XCTAssertEqual(result?.editors, ["Bob Harras"]) + XCTAssertEqual(result?.translators, ["John Doe"]) + XCTAssertEqual(result?.publisher, "DC Comics") + XCTAssertEqual(result?.imprint, "Vertigo") + XCTAssertEqual(result?.genres, ["Superhero", "Action"]) + XCTAssertEqual(result?.languageISO, "en") + XCTAssertEqual(result?.gtin, "978-1234567890") + } + + func testParseReturnsNilForInvalidXML() { + let xml = "not valid xml" + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertNil(result) + } + + func testParseReturnsNilForWrongRootElement() { + let xml = """ + + + Test + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertNil(result) + } + + // MARK: - Other Metadata + + func testOtherMetadataCollectsUnknownTags() { + let xml = """ + + + Test + 2 + Batman, Robin + Teen + Custom Value + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertEqual(result?.otherMetadata["Volume"], "2") + XCTAssertEqual(result?.otherMetadata["Characters"], "Batman, Robin") + XCTAssertEqual(result?.otherMetadata["AgeRating"], "Teen") + XCTAssertEqual(result?.otherMetadata["CustomTag"], "Custom Value") + } + + // MARK: - Cover Page Detection + + func testFirstPageWithTypeFrontCover() { + let xml = """ + + + Test + + + + + + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertEqual(result?.firstPageWithType(.frontCover)?.image, 1) + } + + func testFirstPageWithTypeReturnsNilWhenNoCover() { + let xml = """ + + + Test + + + + + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertNil(result?.firstPageWithType(.frontCover)) + } + + func testFirstPageWithTypeReturnsNilWhenNoPagesElement() { + let xml = """ + + + Test + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertNil(result?.firstPageWithType(.frontCover)) + } + + // MARK: - PageType Parsing + + func testPageTypeCaseInsensitiveParsing() { + XCTAssertEqual(ComicInfo.PageType(rawValue: "FrontCover"), .frontCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "frontcover"), .frontCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "FRONTCOVER"), .frontCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Story"), .story) + XCTAssertEqual(ComicInfo.PageType(rawValue: "BackCover"), .backCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "InnerCover"), .innerCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Roundup"), .roundup) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Advertisement"), .advertisement) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Editorial"), .editorial) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Letters"), .letters) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Preview"), .preview) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Other"), .other) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Deleted"), .deleted) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Delete"), .deleted) + } + + func testPageTypeReturnsNilForUnknownValue() { + XCTAssertNil(ComicInfo.PageType(rawValue: "UnknownType")) + XCTAssertNil(ComicInfo.PageType(rawValue: "")) + } + + // MARK: - PageInfo Parsing + + func testPageInfoParsesAllAttributes() { + let xml = """ + + + + + + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertEqual(result?.pages.count, 1) + let page = result?.pages.first + XCTAssertEqual(page?.image, 0) + XCTAssertEqual(page?.type, .frontCover) + XCTAssertEqual(page?.doublePage, false) + XCTAssertEqual(page?.imageSize, 150_202) + XCTAssertEqual(page?.key, "cover") + XCTAssertEqual(page?.bookmark, "Cover") + XCTAssertEqual(page?.imageWidth, 800) + XCTAssertEqual(page?.imageHeight, 1200) + } + + func testPageInfoRequiresImageAttribute() { + let xml = """ + + + + + + + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + // Only the page with Image attribute should be parsed + XCTAssertEqual(result?.pages.count, 1) + XCTAssertEqual(result?.pages.first?.image, 1) + } + + func testPageInfoWithMinimalAttributes() { + let xml = """ + + + + + + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertEqual(result?.pages.count, 1) + let page = result?.pages.first + XCTAssertEqual(page?.image, 0) + XCTAssertNil(page?.type) + XCTAssertNil(page?.doublePage) + XCTAssertNil(page?.imageSize) + XCTAssertNil(page?.key) + XCTAssertNil(page?.bookmark) + XCTAssertNil(page?.imageWidth) + XCTAssertNil(page?.imageHeight) + } + + func testPageInfoDoublePageBooleanParsing() { + let xml = """ + + + + + + + + + + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertEqual(result?.pages.count, 5) + XCTAssertEqual(result?.pages[0].doublePage, true) + XCTAssertEqual(result?.pages[1].doublePage, true) + XCTAssertEqual(result?.pages[2].doublePage, true) + XCTAssertEqual(result?.pages[3].doublePage, false) + XCTAssertEqual(result?.pages[4].doublePage, false) + } + + // MARK: - Story Start Detection + + func testFirstPageWithTypeStory() { + let xml = """ + + + + + + + + + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertEqual(result?.firstPageWithType(.frontCover)?.image, 0) + XCTAssertEqual(result?.firstPageWithType(.story)?.image, 2) + } + + func testFirstPageWithTypeStoryReturnsNilWhenNoStoryPages() { + let xml = """ + + + + + + + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertEqual(result?.firstPageWithType(.frontCover)?.image, 0) + XCTAssertNil(result?.firstPageWithType(.story)) + } + + func testFirstPageWithTypeStoryReturnsNilWhenNoPagesElement() { + let xml = """ + + + Test + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + + XCTAssertNil(result?.firstPageWithType(.story)) + } + + // MARK: - Metadata Conversion + + func testToMetadataWithSeriesAndNumber() { + let xml = """ + + + Issue 5 + Amazing Comics + 5 + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.title, "Issue 5") + XCTAssertEqual(metadata?.belongsToSeries.count, 1) + XCTAssertEqual(metadata?.belongsToSeries.first?.name, "Amazing Comics") + XCTAssertEqual(metadata?.belongsToSeries.first?.position, 5.0) + } + + func testToMetadataWithAlternateSeries() { + let xml = """ + + + Crossover Issue + Batman + 10 + Justice League + 3 + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.belongsToSeries.count, 2) + XCTAssertEqual(metadata?.belongsToSeries[0].name, "Batman") + XCTAssertEqual(metadata?.belongsToSeries[0].position, 10.0) + XCTAssertEqual(metadata?.belongsToSeries[1].name, "Justice League") + XCTAssertEqual(metadata?.belongsToSeries[1].position, 3.0) + } + + func testToMetadataWithFractionalNumber() { + let xml = """ + + + Issue 5.5 + Amazing Comics + 5.5 + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.belongsToSeries.first?.position, 5.5) + } + + func testToMetadataWithNonNumericNumber() { + let xml = """ + + + Annual Issue + Amazing Comics + Annual 1 + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + // Non-numeric number should result in nil position + XCTAssertNil(metadata?.belongsToSeries.first?.position) + } + + func testToMetadataMangaYesAndRightToLeftSetsRTL() { + let xml = """ + + + Manga + YesAndRightToLeft + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.readingProgression, .rtl) + } + + func testToMetadataMangaYesDoesNotSetRTL() { + let xml = """ + + + Manga + Yes + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.readingProgression, .auto) + } + + func testToMetadataMangaNoSetsAuto() { + let xml = """ + + + Comic + No + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.readingProgression, .auto) + } + + func testToMetadataMangaCaseInsensitiveParsing() { + let xml = """ + + + Manga + YESANDRIGHTTOLEFT + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.readingProgression, .rtl) + } + + func testToMetadataContributors() { + let xml = """ + + + Test + Frank Miller, Bob Kane + Jim Lee + Alex Ross + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.authors.count, 2) + XCTAssertEqual(metadata?.authors.map(\.name), ["Frank Miller", "Bob Kane"]) + XCTAssertEqual(metadata?.pencilers.count, 1) + XCTAssertEqual(metadata?.pencilers.first?.name, "Jim Lee") + XCTAssertEqual(metadata?.contributors.count, 1) + XCTAssertEqual(metadata?.contributors.first?.name, "Alex Ross") + XCTAssertEqual(metadata?.contributors.first?.roles, ["cov"]) + } + + func testToMetadataSubjects() { + let xml = """ + + + Test + Superhero, Action, Adventure + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.subjects.count, 3) + XCTAssertEqual(metadata?.subjects.map(\.name), ["Superhero", "Action", "Adventure"]) + } + + func testToMetadataPublishedDate() { + let xml = """ + + + Test + 2020 + 6 + 15 + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + let calendar = Calendar(identifier: .gregorian) + let components = calendar.dateComponents([.year, .month, .day], from: metadata!.published!) + + XCTAssertEqual(components.year, 2020) + XCTAssertEqual(components.month, 6) + XCTAssertEqual(components.day, 15) + } + + func testToMetadataPublishedDateYearOnly() { + let xml = """ + + + Test + 2020 + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + let calendar = Calendar(identifier: .gregorian) + let components = calendar.dateComponents([.year, .month, .day], from: metadata!.published!) + + XCTAssertEqual(components.year, 2020) + XCTAssertEqual(components.month, 1) // Default to January + XCTAssertEqual(components.day, 1) // Default to 1st + } + + func testToMetadataOtherMetadata() { + let xml = """ + + + Test + 2 + Batman, Robin + Teen + + """ + + let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.otherMetadata["https://anansi-project.github.io/docs/comicinfo/documentation#volume"] as? String, "2") + XCTAssertEqual(metadata?.otherMetadata["https://anansi-project.github.io/docs/comicinfo/documentation#characters"] as? String, "Batman, Robin") + XCTAssertEqual(metadata?.otherMetadata["https://anansi-project.github.io/docs/comicinfo/documentation#agerating"] as? String, "Teen") + } +} diff --git a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index b06ac3fbcf..31c880d7f7 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -13,6 +13,7 @@ class ImageParserTests: XCTestCase { var parser: ImageParser! var cbzAsset: Asset! + var cbzWithComicInfoAsset: Asset! var jpgAsset: Asset! override func setUp() async throws { @@ -23,6 +24,11 @@ class ImageParserTests: XCTestCase { format: Format(specifications: .zip, .informalComic, mediaType: .cbz, fileExtension: "cbz") ).get()) + cbzWithComicInfoAsset = try await .container(ZIPArchiveOpener().open( + resource: FileResource(file: fixtures.url(for: "test-comicinfo.cbz")), + format: Format(specifications: .zip, .informalComic, mediaType: .cbz, fileExtension: "cbz") + ).get()) + jpgAsset = .resource(ResourceAsset( resource: FileResource(file: fixtures.url(for: "futuristic_tales/Cory Doctorow's Futuristic Tales of the Here and Now/a-fc.jpg")), format: Format(specifications: .jpeg, mediaType: .jpeg, fileExtension: "jpeg") @@ -132,4 +138,29 @@ class ImageParserTests: XCTestCase { ), ]) } + + func testParsesMetadataFromComicInfo() async throws { + let publication = try await parser.parse(asset: cbzWithComicInfoAsset, warnings: nil).get().build() + + XCTAssertEqual(publication.metadata.conformsTo, [.divina]) + XCTAssertEqual(publication.metadata.title, "Test Comic Issue") + XCTAssertEqual(publication.metadata.publishers.map(\.name), ["Test Publisher"]) + XCTAssertEqual(publication.metadata.languages, ["en"]) + XCTAssertEqual(publication.metadata.description, "A test comic for unit testing.") + XCTAssertEqual(publication.metadata.subjects.map(\.name), ["Action", "Adventure"]) + XCTAssertEqual(publication.metadata.belongsToSeries.count, 1) + XCTAssertEqual(publication.metadata.belongsToSeries.first?.name, "Test Series") + XCTAssertEqual(publication.metadata.belongsToSeries.first?.position, 5.0) + XCTAssertEqual(publication.metadata.authors.map(\.name), ["Test Writer"]) + XCTAssertEqual(publication.metadata.pencilers.map(\.name), ["Test Artist"]) + + let coverLink = publication.linkWithRel(.cover) + XCTAssertNotNil(coverLink) + XCTAssertEqual(coverLink?.href, "TestComic/page-01.png") + + // Story starts at page-02 (index 1), which is different from cover (index 0) + let startLink = publication.linkWithRel(.start) + XCTAssertNotNil(startLink) + XCTAssertEqual(startLink?.href, "TestComic/page-02.png") + } } From dad94388f64e0db6315584c1c48d72feca41082b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 12 Jan 2026 15:02:50 +0100 Subject: [PATCH 17/55] Support for Divina in the EPUB navigator (#692) --- CHANGELOG.md | 4 ++ .../scripts/readium-fixed-wrapper-one.js | 2 +- .../scripts/readium-fixed-wrapper-two.js | 2 +- .../Navigator/EPUB/Assets/fxl-spread-two.html | 4 +- .../Navigator/EPUB/Scripts/src/fixed-page.js | 38 +++++++++++++++--- .../Scripts/src/index-fixed-wrapper-two.js | 14 ++++--- Sources/Shared/Publication/Properties.swift | 11 ++++- .../Streamer/Parser/Image/ImageParser.swift | 13 ++++++ .../NavigatorUITests/XCUIApplication.swift | 2 +- .../StreamerTests/Fixtures/test-comicinfo.cbz | Bin 1316 -> 1328 bytes .../Parser/Image/ImageParserTests.swift | 13 ++++++ 11 files changed, 86 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4261e0f11c..01251861a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. Take a look ### Added +#### Navigator + +* Support for displaying Divina (image-based publications like CBZ) in the fixed-layout EPUB navigator. + #### Streamer * The `ImageParser` now extracts metadata from `ComicInfo.xml` files in CBZ archives. diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js index fbfbd2519e..f693293842 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};var i=function(t,i){var o=null,l=null,a=null,r=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,u=document.getElementById("page");u.addEventListener("load",(function(){var t=u.contentWindow.document.querySelector("meta[name=viewport]");if(t){for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var l=Number.parseFloat(i.width),a=Number.parseFloat(i.height);l&&a&&(o={width:l,height:a},d())}}));var c=u.closest(".viewport");function d(){if(o&&l&&a){u.style.width=o.width+"px",u.style.height=o.height+"px";var t,i=l.width/o.width,c=l.height/o.height;t=r===n.WIDTH?i:Math.min(i,c);var d=o.height*t,h=s===e.SINGLE||s===e.SPREAD_CENTER;if(r===n.WIDTH&&d>l.height)u.style.top=a.top+"px",u.style.transform=h?"translateX(-50%)":"none";else{var f=a.top-a.bottom;u.style.top="calc(50% + "+f+"px)",u.style.transform=h?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,u.addEventListener("load",(function i(){u.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,u.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),u.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,o=null,u.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return u.contentWindow.eval(t)},setViewport:function(t,e,i){l=t,a=e,Object.values(n).includes(i)&&(r=i),d()},show:function(){c.style.display="block"},hide:function(){c.style.display="none"}}}(0,e.SINGLE);t.g.spread={load:function(t){0!==t.length&&i.load(t[0],(function(){webkit.messageHandlers.spreadLoaded.postMessage({})}))},eval:function(t,e){var n;if("#"===t||""===t||(null===(n=i.link)||void 0===n?void 0:n.href)===t)return i.eval(e)},setViewport:function(t,e,n){i.setViewport(t,e,n)}}})(); +(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};var i=function(t,i){var l=null,o=null,a=null,r=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,u=document.getElementById("page");u.addEventListener("load",(function(){var t,e,n;l=null!==(t=null!==(e=function(){var t=u.contentWindow.document.querySelector("meta[name=viewport]");if(!t)return null;for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var l=Number.parseFloat(i.width),o=Number.parseFloat(i.height);return l&&o?{width:l,height:o}:null}())&&void 0!==e?e:(n=u.contentWindow.document.querySelector("img"))&&n.naturalWidth&&n.naturalHeight?{width:n.naturalWidth,height:n.naturalHeight}:null)&&void 0!==t?t:o,c()}));var d=u.closest(".viewport");function c(){if(l&&o&&a){u.style.width=l.width+"px",u.style.height=l.height+"px";var t,i=o.width/l.width,d=o.height/l.height;t=r===n.WIDTH?i:Math.min(i,d);var c=l.height*t,h=s===e.SINGLE||s===e.SPREAD_CENTER;if(r===n.WIDTH&&c>o.height)u.style.top=a.top+"px",u.style.transform=h?"translateX(-50%)":"none";else{var f=a.top-a.bottom;u.style.top="calc(50% + "+f+"px)",u.style.transform=h?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,u.addEventListener("load",(function i(){u.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,u.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),u.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,l=null,u.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return u.contentWindow.eval(t)},setViewport:function(t,e,i){o=t,a=e,Object.values(n).includes(i)&&(r=i),c()},show:function(){d.style.display="block"},hide:function(){d.style.display="none"}}}(0,e.SINGLE);t.g.spread={load:function(t){0!==t.length&&i.load(t[0],(function(){webkit.messageHandlers.spreadLoaded.postMessage({})}))},eval:function(t,e){var n;if("#"===t||""===t||(null===(n=i.link)||void 0===n?void 0:n.href)===t)return i.eval(e)},setViewport:function(t,e,n){i.setViewport(t,e,n)}}})(); //# sourceMappingURL=readium-fixed-wrapper-one.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js index db8191ee78..148f3816f1 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};function i(t,i){var o=null,r=null,l=null,a=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,c=document.getElementById(t);c.addEventListener("load",(function(){var t=c.contentWindow.document.querySelector("meta[name=viewport]");if(t){for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var r=Number.parseFloat(i.width),l=Number.parseFloat(i.height);r&&l&&(o={width:r,height:l},d())}}));var u=c.closest(".viewport");function d(){if(o&&r&&l){c.style.width=o.width+"px",c.style.height=o.height+"px";var t,i=r.width/o.width,u=r.height/o.height;t=a===n.WIDTH?i:Math.min(i,u);var d=o.height*t,f=s===e.SINGLE||s===e.SPREAD_CENTER;if(a===n.WIDTH&&d>r.height)c.style.top=l.top+"px",c.style.transform=f?"translateX(-50%)":"none";else{var h=l.top-l.bottom;c.style.top="calc(50% + "+h+"px)",c.style.transform=f?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,c.addEventListener("load",(function i(){c.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,c.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),c.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,o=null,c.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return c.contentWindow.eval(t)},setViewport:function(t,e,i){r=t,l=e,Object.values(n).includes(i)&&(a=i),d()},show:function(){u.style.display="block"},hide:function(){u.style.display="none"}}}var o={left:i("page-left",e.SPREAD_LEFT),right:i("page-right",e.SPREAD_RIGHT),center:i("page-center",e.SPREAD_CENTER)};function r(t){for(const e in o)t(o[e])}t.g.spread={load:function(t){function e(){o.left.isLoading||o.right.isLoading||o.center.isLoading||webkit.messageHandlers.spreadLoaded.postMessage({})}r((function(t){t.reset(),t.hide()}));for(const n in t){const i=t[n],r=o[i.page];r&&(r.show(),r.load(i,e))}},eval:function(t,e){if("#"===t||""===t)r((function(t){t.eval(e)}));else{var n=function(t){for(const i in o){var e,n=o[i];if((null===(e=n.link)||void 0===e?void 0:e.href)===t)return n}return null}(t);if(n)return n.eval(e)}},setViewport:function(t,e,n){t.width/=2,o.left.setViewport(t,{top:e.top,right:0,bottom:e.bottom,left:e.left},n),o.right.setViewport(t,{top:e.top,right:e.right,bottom:e.bottom,left:0},n),o.center.setViewport(t,{top:e.top,right:0,bottom:e.bottom,left:0},n)}}})(); +(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};function i(t,i){var o=null,r=null,l=null,a=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,u=document.getElementById(t);u.addEventListener("load",(function(){var t,e,n;o=null!==(t=null!==(e=function(){var t=u.contentWindow.document.querySelector("meta[name=viewport]");if(!t)return null;for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var o=Number.parseFloat(i.width),r=Number.parseFloat(i.height);return o&&r?{width:o,height:r}:null}())&&void 0!==e?e:(n=u.contentWindow.document.querySelector("img"))&&n.naturalWidth&&n.naturalHeight?{width:n.naturalWidth,height:n.naturalHeight}:null)&&void 0!==t?t:r,h()}));var c=u.closest(".viewport");function h(){if(o&&r&&l){u.style.width=o.width+"px",u.style.height=o.height+"px";var t,i=r.width/o.width,c=r.height/o.height;t=a===n.WIDTH?i:Math.min(i,c);var h=o.height*t,d=s===e.SINGLE||s===e.SPREAD_CENTER;if(a===n.WIDTH&&h>r.height)u.style.top=l.top+"px",u.style.transform=d?"translateX(-50%)":"none";else{var f=l.top-l.bottom;u.style.top="calc(50% + "+f+"px)",u.style.transform=d?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,u.addEventListener("load",(function i(){u.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,u.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),u.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,o=null,u.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return u.contentWindow.eval(t)},setViewport:function(t,e,i){r=t,l=e,Object.values(n).includes(i)&&(a=i),h()},show:function(){c.style.display="block"},hide:function(){c.style.display="none"}}}var o={left:i("page-left",e.SPREAD_LEFT),right:i("page-right",e.SPREAD_RIGHT),center:i("page-center",e.SPREAD_CENTER)};function r(t){for(const e in o)t(o[e])}t.g.spread={load:function(t){function e(){o.left.isLoading||o.right.isLoading||o.center.isLoading||webkit.messageHandlers.spreadLoaded.postMessage({})}r((function(t){t.reset(),t.hide()}));for(const n in t){const i=t[n],r=o[i.page];r&&(r.show(),r.load(i,e))}},eval:function(t,e){if("#"===t||""===t)r((function(t){t.eval(e)}));else{var n=function(t){for(const i in o){var e,n=o[i];if((null===(e=n.link)||void 0===e?void 0:e.href)===t)return n}return null}(t);if(n)return n.eval(e)}},setViewport:function(t,e,n){var i={width:t.width/2,height:t.height};o.left.setViewport(i,{top:e.top,right:0,bottom:e.bottom,left:e.left},n),o.right.setViewport(i,{top:e.top,right:e.right,bottom:e.bottom,left:0},n),o.center.setViewport(t,{top:e.top,right:e.right,bottom:e.bottom,left:e.left},n)}}})(); //# sourceMappingURL=readium-fixed-wrapper-two.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/fxl-spread-two.html b/Sources/Navigator/EPUB/Assets/fxl-spread-two.html index 466977e6a8..07768fe23e 100644 --- a/Sources/Navigator/EPUB/Assets/fxl-spread-two.html +++ b/Sources/Navigator/EPUB/Assets/fxl-spread-two.html @@ -26,8 +26,8 @@ } #viewport-center { - left: 50%; - transform: translateX(-50%); + width: 100%; + left: 0; } .page { diff --git a/Sources/Navigator/EPUB/Scripts/src/fixed-page.js b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js index 91121e5899..cd4f2a98a3 100644 --- a/Sources/Navigator/EPUB/Scripts/src/fixed-page.js +++ b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js @@ -38,19 +38,32 @@ export function FixedPage(iframeId, pageType) { // iFrame containing the page. var _iframe = document.getElementById(iframeId); - _iframe.addEventListener("load", loadPageSize); + _iframe.addEventListener("load", onLoad); // Viewport element containing the iFrame. var _viewport = _iframe.closest(".viewport"); + function onLoad() { + // Parses the page size from the viewport meta tag of the loaded resource, + // or extracts natural dimensions from images loaded directly in the iframe. + // As a fallback, we consider that the page spans the size of the viewport. + _pageSize = + parsePageSizeFromViewportMetaTag() ?? + parsePageSizeFromEmbeddedImage() ?? + _viewportSize; + + layoutPage(); + } + // Parses the page size from the viewport meta tag of the loaded resource. - function loadPageSize() { + function parsePageSizeFromViewportMetaTag() { var viewport = _iframe.contentWindow.document.querySelector( "meta[name=viewport]" ); if (!viewport) { - return; + return null; } + var regex = /(\w+) *= *([^\s,]+)/g; var properties = {}; var match; @@ -59,10 +72,23 @@ export function FixedPage(iframeId, pageType) { } var width = Number.parseFloat(properties.width); var height = Number.parseFloat(properties.height); - if (width && height) { - _pageSize = { width: width, height: height }; - layoutPage(); + if (!width || !height) { + return null; + } + + return { width: width, height: height }; + } + + // Parses the page size from the natural dimensions of images loaded directly in the iframe. + // + // When a browser loads an image URL in an iframe, it renders the image + // in a minimal HTML document with an element. + function parsePageSizeFromEmbeddedImage() { + var img = _iframe.contentWindow.document.querySelector("img"); + if (!img || !img.naturalWidth || !img.naturalHeight) { + return null; } + return { width: img.naturalWidth, height: img.naturalHeight }; } // Layouts the page iframe and scale it according to the current fit mode. diff --git a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js index 3c31f660a1..90ebe221d6 100644 --- a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js +++ b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js @@ -77,10 +77,13 @@ global.spread = { // Updates the available viewport to display the resources. setViewport: function (viewportSize, safeAreaInsets, fit) { - viewportSize.width /= 2; + var halfViewportSize = { + width: viewportSize.width / 2, + height: viewportSize.height, + }; pages.left.setViewport( - viewportSize, + halfViewportSize, { top: safeAreaInsets.top, right: 0, @@ -91,7 +94,7 @@ global.spread = { ); pages.right.setViewport( - viewportSize, + halfViewportSize, { top: safeAreaInsets.top, right: safeAreaInsets.right, @@ -101,13 +104,14 @@ global.spread = { fit ); + // Center pages use the full viewport to fit the screen. pages.center.setViewport( viewportSize, { top: safeAreaInsets.top, - right: 0, + right: safeAreaInsets.right, bottom: safeAreaInsets.bottom, - left: 0, + left: safeAreaInsets.left, }, fit ); diff --git a/Sources/Shared/Publication/Properties.swift b/Sources/Shared/Publication/Properties.swift index 0a7c0b0a26..7a7cf433be 100644 --- a/Sources/Shared/Publication/Properties.swift +++ b/Sources/Shared/Publication/Properties.swift @@ -54,10 +54,19 @@ public struct Properties: Hashable, Loggable, WarningLogger, Sendable { /// /// https://github.com/readium/webpub-manifest/blob/master/properties.md#core-properties public extension Properties { + private static var pageKey: String { "page" } + /// Indicates how the linked resource should be displayed in a reading /// environment that displays synthetic spreads. var page: Page? { - parseRaw(otherProperties["page"]) + get { parseRaw(otherProperties[Self.pageKey]) } + set { + if let newValue = newValue { + otherProperties[Self.pageKey] = newValue.rawValue + } else { + otherProperties.removeValue(forKey: Self.pageKey) + } + } } /// Indicates how the linked resource should be displayed in a reading diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index 4cfeb67dc5..822688c5ee 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -173,11 +173,24 @@ public final class ImageParser: PublicationParser { // Build metadata from ComicInfo or use defaults var metadata = comicInfo?.toMetadata() ?? Metadata() metadata.conformsTo = [.divina] + metadata.layout = .fixed if metadata.localizedTitle == nil, let fallbackTitle = fallbackTitle { metadata.localizedTitle = .nonlocalized(fallbackTitle) } + // Display the first page on its own by default. + readingOrder[0].properties.page = .center + + // Apply center page layout for double-page spreads + if let pages = comicInfo?.pages { + for pageInfo in pages where pageInfo.doublePage == true { + if readingOrder.indices.contains(pageInfo.image) { + readingOrder[pageInfo.image].properties.page = .center + } + } + } + return .success(Publication.Builder( manifest: Manifest( metadata: metadata, diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift index 02817ace04..5ae97ea7bd 100644 --- a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift @@ -34,7 +34,7 @@ extension XCUIApplication { /// A timeout is used to make sure the memory is cleared. @discardableResult func assertAllMemoryDeallocated() -> Self { - switches[.allMemoryDeallocated].assertIs(true, waitForTimeout: 30) + switches[.allMemoryDeallocated].assertIs(true, waitForTimeout: 120) return self } } diff --git a/Tests/StreamerTests/Fixtures/test-comicinfo.cbz b/Tests/StreamerTests/Fixtures/test-comicinfo.cbz index a91655130445311ff19062369a15123a84d2a51b..35c30b2a6ac04ad402dabc0c386f39670df64864 100644 GIT binary patch delta 541 zcmZ3&wSlWXz?+#xgn@y9gW*D|PE6Ccf0`DI3=D-#3=F&sG7Qf7xtYnHd1?826}dT~ zA)E}%68BRwLAbPnn}Lz#D^M*9P&Y&ABwzo-20U%=e~Sj5-;g7!z!$kJiz9^d*pg5u znce5!DsAOFb^ORbTQ$Gq7HisHeSLj=<=YUQ{q6PhWDi=Ueo>n3pDxLKcgEgJca^yJ z?X5jCtw<>>Ex>nWw6Z#ne4yt2<~0W@#e&zA=rufeBD|S(^#MVbs1HvBH}D*&$$l*C;d*_Vt<1Dpy!02 ziBpfSKYGlAdtSW_li3cYtKXG_Bi7Fm|8w3i;>OogvTko=lq$~&{A9Y-q$AZIy)W_T zKaN}M-M<&HIIiFN&grh>&f4hPzmU zn~$=t;kj3GZ2gAM6Yo|f`-gA*V}EQO4BgScA<4zq$28MVh1_oXR83yP4+{|RpytI71irk#g z5KabWxvTM+AY59(&A`a=6{r@hy>ybV|6v23w)ek91J7^B5mn%eT$UxYLWpNcn3K%z zb8nTla-KTw^51ru-)X)z?XSMRKECp8NY4J>^?h>>TBUwbi_{P2WR9J^_|jW7&V4&; zeYO=TmMse}T_>$9&LbbFb-z*PV5Qlzh<7mz2T!UmV_1Da&?V}_3&9Qie;L>85w}?t zA^vOMrsgv<{_Czi{#iOX^1`WpIkW9YHY>S@&JT#3x$ctnL3hu0t1tdjk!MkNeAXT+ zx8KanE$T!43&9V4+5xfVhBLpOsyz7rO-HNq@;jM_o(Y_^bbex-!ELnKHt%O!>T%-? zd7XFNLM(ejcJYRCSAJA;{F06hjKZ)4VFES~&?SrMo$kwtj2B#RXDc1DKD4;UpT v`>_atMRgfnCik=GKvkS)kzzbQ`4x+f6e#)wyjj^m?qLPOEx<^PVg~U5;aSTw diff --git a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index 31c880d7f7..20794f8dfd 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -163,4 +163,17 @@ class ImageParserTests: XCTestCase { XCTAssertNotNil(startLink) XCTAssertEqual(startLink?.href, "TestComic/page-02.png") } + + func testDoublePageSpreadSetsCenterPage() async throws { + let publication = try await parser.parse(asset: cbzWithComicInfoAsset, warnings: nil).get().build() + + // Page 0 (cover) should have center page property (default behavior) + XCTAssertEqual(publication.readingOrder[0].properties.page, .center) + + // Page 1 should not have center page property + XCTAssertNil(publication.readingOrder[1].properties.page) + + // Page 2 has DoublePage="True" in ComicInfo.xml, should have center page + XCTAssertEqual(publication.readingOrder[2].properties.page, .center) + } } From 29768046f8b67f8b43526984bcf672350721a2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 12 Jan 2026 17:12:08 +0100 Subject: [PATCH 18/55] Optimize sniffing the `Format` of `Container` resources (#693) --- .../Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift | 6 +++++- .../Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift | 6 +++++- .../Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift | 6 +++++- .../Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift | 6 +++++- .../Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift | 6 +++++- .../Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift | 6 +++++- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift index 7feb15dc83..8c324b2358 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift @@ -29,7 +29,11 @@ public struct HTMLFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { - await blob.readAsXML() + guard !format.hasSpecification || format.conformsTo(.xml) else { + return .success(nil) + } + + return await blob.readAsXML() .asyncMap { document in if let format = sniffDocument(document) { return format diff --git a/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift index 23300a9dcc..1cce9c9f20 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift @@ -28,7 +28,11 @@ public struct JSONFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { - await blob.readAsJSON() + guard !format.hasSpecification else { + return .success(nil) + } + + return await blob.readAsJSON() .map { guard $0 != nil else { return nil diff --git a/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift index 97d50afa87..cde825e181 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift @@ -24,8 +24,12 @@ public struct PDFFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { + guard !format.hasSpecification else { + return .success(nil) + } + // https://en.wikipedia.org/wiki/List_of_file_signatures - await blob.read(range: 0 ..< 5) + return await blob.read(range: 0 ..< 5) .map { data in guard String(data: data, encoding: .utf8) == "%PDF-" else { return nil diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift index 6fa4b1217d..11bbf15d27 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift @@ -22,8 +22,12 @@ public struct RARFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { + guard !format.hasSpecification else { + return .success(nil) + } + // https://en.wikipedia.org/wiki/List_of_file_signatures - await blob.read(range: 0 ..< 8) + return await blob.read(range: 0 ..< 8) .map { data in guard data.count > 8, diff --git a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift index 250fe66575..b2f8b00e99 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift @@ -22,7 +22,11 @@ public struct XMLFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { - await blob.readAsXML() + guard !format.hasSpecification else { + return .success(nil) + } + + return await blob.readAsXML() .map { guard $0 != nil else { return nil diff --git a/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift index 528346788c..6b0f13ca97 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift @@ -22,8 +22,12 @@ public struct ZIPFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { + guard !format.hasSpecification else { + return .success(nil) + } + // https://en.wikipedia.org/wiki/List_of_file_signatures - await blob.read(range: 0 ..< 4) + return await blob.read(range: 0 ..< 4) .map { data in guard data == Data([0x50, 0x4B, 0x03, 0x04]) || From a44f08511ffe7240c3d83c61393fb7bf48c25083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 15 Jan 2026 17:04:29 +0100 Subject: [PATCH 19/55] Add support for images in EPUB reading order (#695) --- .github/workflows/checks.yml | 4 +- CHANGELOG.md | 16 ++ .../scripts/readium-fixed-wrapper-one.js | 2 +- .../scripts/readium-fixed-wrapper-two.js | 2 +- .../Navigator/EPUB/Scripts/src/fixed-page.js | 61 ++++++- .../PDF/PDFNavigatorViewController.swift | 9 +- Sources/Shared/Publication/Manifest.swift | 2 +- .../Parser/EPUB/EPUBManifestParser.swift | 3 +- Sources/Streamer/Parser/EPUB/OPFParser.swift | 153 +++++++++++----- .../Streamer/Parser/Image/ImageParser.swift | 3 - .../Fixtures/OPF/all-images-in-spine.opf | 18 ++ .../Fixtures/OPF/fallback-general.opf | 16 ++ .../Fixtures/OPF/fallback-html-in-spine.opf | 16 ++ .../OPF/fallback-image-html-mixed.opf | 15 ++ .../Fixtures/OPF/fallback-image-in-spine.opf | 16 ++ .../Parser/EPUB/EPUBManifestParserTests.swift | 39 ++-- .../Parser/EPUB/OPFParserTests.swift | 168 ++++++++++++++---- .../Parser/Image/ImageParserTests.swift | 5 +- 18 files changed, 433 insertions(+), 115 deletions(-) create mode 100644 Tests/StreamerTests/Fixtures/OPF/all-images-in-spine.opf create mode 100644 Tests/StreamerTests/Fixtures/OPF/fallback-general.opf create mode 100644 Tests/StreamerTests/Fixtures/OPF/fallback-html-in-spine.opf create mode 100644 Tests/StreamerTests/Fixtures/OPF/fallback-image-html-mixed.opf create mode 100644 Tests/StreamerTests/Fixtures/OPF/fallback-image-in-spine.opf diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ac57cdd7b3..031cd49907 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,9 +7,9 @@ on: env: platform: ${{ 'iOS Simulator' }} - device: ${{ 'iPhone SE (3rd generation)' }} + device: ${{ 'iPhone 17' }} commit_sha: ${{ github.sha }} - DEVELOPER_DIR: /Applications/Xcode_16.3.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 01251861a5..92bb29ac5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,26 @@ All notable changes to this project will be documented in this file. Take a look #### Navigator * 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. #### Streamer * The `ImageParser` now extracts metadata from `ComicInfo.xml` files in CBZ archives. +* EPUB manifest item fallbacks are now exposed as `alternates` in the corresponding `Link`. +* EPUBs with only bitmap images in the spine are now treated as Divina publications with fixed layout. + * When an EPUB spine item is HTML with a bitmap image fallback (or vice versa), the image is preferred as the primary link. + +### Deprecated + +#### Streamer + +* The EPUB manifest item `id` attribute is no longer exposed in `Link.properties`. + +### Fixed + +#### Navigator + +* PDF documents are now opened off the main thread, preventing UI freezes with large files. ## [3.6.0] diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js index f693293842..6eb8689fac 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};var i=function(t,i){var l=null,o=null,a=null,r=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,u=document.getElementById("page");u.addEventListener("load",(function(){var t,e,n;l=null!==(t=null!==(e=function(){var t=u.contentWindow.document.querySelector("meta[name=viewport]");if(!t)return null;for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var l=Number.parseFloat(i.width),o=Number.parseFloat(i.height);return l&&o?{width:l,height:o}:null}())&&void 0!==e?e:(n=u.contentWindow.document.querySelector("img"))&&n.naturalWidth&&n.naturalHeight?{width:n.naturalWidth,height:n.naturalHeight}:null)&&void 0!==t?t:o,c()}));var d=u.closest(".viewport");function c(){if(l&&o&&a){u.style.width=l.width+"px",u.style.height=l.height+"px";var t,i=o.width/l.width,d=o.height/l.height;t=r===n.WIDTH?i:Math.min(i,d);var c=l.height*t,h=s===e.SINGLE||s===e.SPREAD_CENTER;if(r===n.WIDTH&&c>o.height)u.style.top=a.top+"px",u.style.transform=h?"translateX(-50%)":"none";else{var f=a.top-a.bottom;u.style.top="calc(50% + "+f+"px)",u.style.transform=h?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,u.addEventListener("load",(function i(){u.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,u.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),u.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,l=null,u.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return u.contentWindow.eval(t)},setViewport:function(t,e,i){o=t,a=e,Object.values(n).includes(i)&&(r=i),c()},show:function(){d.style.display="block"},hide:function(){d.style.display="none"}}}(0,e.SINGLE);t.g.spread={load:function(t){0!==t.length&&i.load(t[0],(function(){webkit.messageHandlers.spreadLoaded.postMessage({})}))},eval:function(t,e){var n;if("#"===t||""===t||(null===(n=i.link)||void 0===n?void 0:n.href)===t)return i.eval(e)},setViewport:function(t,e,n){i.setViewport(t,e,n)}}})(); +(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};var i=function(t,i){var l=null,o=null,a=null,r=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,u=document.getElementById("page");u.addEventListener("load",(function(){var t,e,n;l=null!==(t=null!==(e=function(){var t=u.contentWindow.document.querySelector("meta[name=viewport]");if(!t)return null;for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var l=Number.parseFloat(i.width),o=Number.parseFloat(i.height);return l&&o?{width:l,height:o}:null}())&&void 0!==e?e:(n=u.contentWindow.document.querySelector("img"))&&n.naturalWidth&&n.naturalHeight?{width:n.naturalWidth,height:n.naturalHeight}:null)&&void 0!==t?t:o,d()}));var c=u.closest(".viewport");function d(){if(l&&o&&a){u.style.width=l.width+"px",u.style.height=l.height+"px";var t,i=o.width/l.width,c=o.height/l.height;t=r===n.WIDTH?i:Math.min(i,c);var d=l.height*t,h=s===e.SINGLE||s===e.SPREAD_CENTER;if(r===n.WIDTH&&d>o.height)u.style.top=a.top+"px",u.style.transform=h?"translateX(-50%)":"none";else{var m=a.top-a.bottom;u.style.top="calc(50% + "+m+"px)",u.style.transform=h?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}function h(t){u.src.startsWith("blob:")&&URL.revokeObjectURL(u.src),u.src=t}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,u.addEventListener("load",(function i(){u.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,u.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)}));var i=function(t){if((e=t.link.type)&&e.startsWith("image/")&&!e.includes("svg")){let e=function(t,e){let n=document.implementation.createHTMLDocument(""),i=n.createElement("meta");i.name="viewport",i.content="width=device-width, height=device-height",n.head.appendChild(i);let l=n.createElement("style");l.textContent="body { margin: 0; }\nimg { display: block; width: 100%; height: 100%; object-fit: contain; }",n.head.appendChild(l);let o=n.createElement("img");return o.src=t,e&&(o.alt=e),n.body.appendChild(o),"\n"+n.documentElement.outerHTML}(t.url,t.link.title),n=new Blob([e],{type:"text/html"});return URL.createObjectURL(n)}return t.url;var e}(t);h(i)}else e&&e()},reset:function(){this.link&&(this.link=null,l=null,h("about:blank"))},eval:function(t){if(this.link&&!this.isLoading)return u.contentWindow.eval(t)},setViewport:function(t,e,i){o=t,a=e,Object.values(n).includes(i)&&(r=i),d()},show:function(){c.style.display="block"},hide:function(){c.style.display="none"}}}(0,e.SINGLE);t.g.spread={load:function(t){0!==t.length&&i.load(t[0],(function(){webkit.messageHandlers.spreadLoaded.postMessage({})}))},eval:function(t,e){var n;if("#"===t||""===t||(null===(n=i.link)||void 0===n?void 0:n.href)===t)return i.eval(e)},setViewport:function(t,e,n){i.setViewport(t,e,n)}}})(); //# sourceMappingURL=readium-fixed-wrapper-one.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js index 148f3816f1..244222298b 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};function i(t,i){var o=null,r=null,l=null,a=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,u=document.getElementById(t);u.addEventListener("load",(function(){var t,e,n;o=null!==(t=null!==(e=function(){var t=u.contentWindow.document.querySelector("meta[name=viewport]");if(!t)return null;for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var o=Number.parseFloat(i.width),r=Number.parseFloat(i.height);return o&&r?{width:o,height:r}:null}())&&void 0!==e?e:(n=u.contentWindow.document.querySelector("img"))&&n.naturalWidth&&n.naturalHeight?{width:n.naturalWidth,height:n.naturalHeight}:null)&&void 0!==t?t:r,h()}));var c=u.closest(".viewport");function h(){if(o&&r&&l){u.style.width=o.width+"px",u.style.height=o.height+"px";var t,i=r.width/o.width,c=r.height/o.height;t=a===n.WIDTH?i:Math.min(i,c);var h=o.height*t,d=s===e.SINGLE||s===e.SPREAD_CENTER;if(a===n.WIDTH&&h>r.height)u.style.top=l.top+"px",u.style.transform=d?"translateX(-50%)":"none";else{var f=l.top-l.bottom;u.style.top="calc(50% + "+f+"px)",u.style.transform=d?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,u.addEventListener("load",(function i(){u.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,u.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),u.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,o=null,u.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return u.contentWindow.eval(t)},setViewport:function(t,e,i){r=t,l=e,Object.values(n).includes(i)&&(a=i),h()},show:function(){c.style.display="block"},hide:function(){c.style.display="none"}}}var o={left:i("page-left",e.SPREAD_LEFT),right:i("page-right",e.SPREAD_RIGHT),center:i("page-center",e.SPREAD_CENTER)};function r(t){for(const e in o)t(o[e])}t.g.spread={load:function(t){function e(){o.left.isLoading||o.right.isLoading||o.center.isLoading||webkit.messageHandlers.spreadLoaded.postMessage({})}r((function(t){t.reset(),t.hide()}));for(const n in t){const i=t[n],r=o[i.page];r&&(r.show(),r.load(i,e))}},eval:function(t,e){if("#"===t||""===t)r((function(t){t.eval(e)}));else{var n=function(t){for(const i in o){var e,n=o[i];if((null===(e=n.link)||void 0===e?void 0:e.href)===t)return n}return null}(t);if(n)return n.eval(e)}},setViewport:function(t,e,n){var i={width:t.width/2,height:t.height};o.left.setViewport(i,{top:e.top,right:0,bottom:e.bottom,left:e.left},n),o.right.setViewport(i,{top:e.top,right:e.right,bottom:e.bottom,left:0},n),o.center.setViewport(t,{top:e.top,right:e.right,bottom:e.bottom,left:e.left},n)}}})(); +(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};function i(t,i){var o=null,l=null,r=null,a=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,c=document.getElementById(t);c.addEventListener("load",(function(){var t,e,n;o=null!==(t=null!==(e=function(){var t=c.contentWindow.document.querySelector("meta[name=viewport]");if(!t)return null;for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var o=Number.parseFloat(i.width),l=Number.parseFloat(i.height);return o&&l?{width:o,height:l}:null}())&&void 0!==e?e:(n=c.contentWindow.document.querySelector("img"))&&n.naturalWidth&&n.naturalHeight?{width:n.naturalWidth,height:n.naturalHeight}:null)&&void 0!==t?t:l,h()}));var u=c.closest(".viewport");function h(){if(o&&l&&r){c.style.width=o.width+"px",c.style.height=o.height+"px";var t,i=l.width/o.width,u=l.height/o.height;t=a===n.WIDTH?i:Math.min(i,u);var h=o.height*t,d=s===e.SINGLE||s===e.SPREAD_CENTER;if(a===n.WIDTH&&h>l.height)c.style.top=r.top+"px",c.style.transform=d?"translateX(-50%)":"none";else{var f=r.top-r.bottom;c.style.top="calc(50% + "+f+"px)",c.style.transform=d?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}function d(t){c.src.startsWith("blob:")&&URL.revokeObjectURL(c.src),c.src=t}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,c.addEventListener("load",(function i(){c.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,c.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)}));var i=function(t){if((e=t.link.type)&&e.startsWith("image/")&&!e.includes("svg")){let e=function(t,e){let n=document.implementation.createHTMLDocument(""),i=n.createElement("meta");i.name="viewport",i.content="width=device-width, height=device-height",n.head.appendChild(i);let o=n.createElement("style");o.textContent="body { margin: 0; }\nimg { display: block; width: 100%; height: 100%; object-fit: contain; }",n.head.appendChild(o);let l=n.createElement("img");return l.src=t,e&&(l.alt=e),n.body.appendChild(l),"\n"+n.documentElement.outerHTML}(t.url,t.link.title),n=new Blob([e],{type:"text/html"});return URL.createObjectURL(n)}return t.url;var e}(t);d(i)}else e&&e()},reset:function(){this.link&&(this.link=null,o=null,d("about:blank"))},eval:function(t){if(this.link&&!this.isLoading)return c.contentWindow.eval(t)},setViewport:function(t,e,i){l=t,r=e,Object.values(n).includes(i)&&(a=i),h()},show:function(){u.style.display="block"},hide:function(){u.style.display="none"}}}var o={left:i("page-left",e.SPREAD_LEFT),right:i("page-right",e.SPREAD_RIGHT),center:i("page-center",e.SPREAD_CENTER)};function l(t){for(const e in o)t(o[e])}t.g.spread={load:function(t){function e(){o.left.isLoading||o.right.isLoading||o.center.isLoading||webkit.messageHandlers.spreadLoaded.postMessage({})}l((function(t){t.reset(),t.hide()}));for(const n in t){const i=t[n],l=o[i.page];l&&(l.show(),l.load(i,e))}},eval:function(t,e){if("#"===t||""===t)l((function(t){t.eval(e)}));else{var n=function(t){for(const i in o){var e,n=o[i];if((null===(e=n.link)||void 0===e?void 0:e.href)===t)return n}return null}(t);if(n)return n.eval(e)}},setViewport:function(t,e,n){var i={width:t.width/2,height:t.height};o.left.setViewport(i,{top:e.top,right:0,bottom:e.bottom,left:e.left},n),o.right.setViewport(i,{top:e.top,right:e.right,bottom:e.bottom,left:0},n),o.center.setViewport(t,{top:e.top,right:e.right,bottom:e.bottom,left:e.left},n)}}})(); //# sourceMappingURL=readium-fixed-wrapper-two.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Scripts/src/fixed-page.js b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js index cd4f2a98a3..15baa4bcf4 100644 --- a/Sources/Navigator/EPUB/Scripts/src/fixed-page.js +++ b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js @@ -156,6 +156,15 @@ export function FixedPage(iframeId, pageType) { viewport.content = "initial-scale=" + scale + ", minimum-scale=" + scale; } + // Sets the iframe source URL. + function setIframeSrc(url) { + // Release the memory of a previously created blob URL, if needed. + if (_iframe.src.startsWith("blob:")) { + URL.revokeObjectURL(_iframe.src); + } + _iframe.src = url; + } + return { // Returns whether the page is currently loading its contents. isLoading: false, @@ -194,17 +203,20 @@ export function FixedPage(iframeId, pageType) { } _iframe.addEventListener("load", loaded); - _iframe.src = resource.url; + + var url = resourceUrl(resource); + setIframeSrc(url); }, - // Resets the page and empty its contents. + // Resets the page and empties its contents. reset: function () { if (!this.link) { return; } this.link = null; _pageSize = null; - _iframe.src = "about:blank"; + + setIframeSrc("about:blank"); }, // Evaluates a script in the context of the page. @@ -236,3 +248,46 @@ export function FixedPage(iframeId, pageType) { }, }; } + +// Returns the URL to load for the given resource. +// Bitmap images are wrapped in an HTML document with alt text for accessibility. +function resourceUrl(resource) { + if (isBitmapMediaType(resource.link.type)) { + let html = generateImageWrapper(resource.url, resource.link.title); + let blob = new Blob([html], { type: "text/html" }); + return URL.createObjectURL(blob); + } else { + return resource.url; + } +} + +// Helper to detect bitmap media types. +function isBitmapMediaType(type) { + if (!type) return false; + return type.startsWith("image/") && !type.includes("svg"); +} + +// Generate an HTML wrapper with alt text for the bitmap at `imageUrl`. +function generateImageWrapper(imageUrl, altText) { + let doc = document.implementation.createHTMLDocument(""); + + let meta = doc.createElement("meta"); + meta.name = "viewport"; + meta.content = "width=device-width, height=device-height"; + doc.head.appendChild(meta); + + let style = doc.createElement("style"); + style.textContent = + "body { margin: 0; }\n" + + "img { display: block; width: 100%; height: 100%; object-fit: contain; }"; + doc.head.appendChild(style); + + let img = doc.createElement("img"); + img.src = imageUrl; + if (altText) { + img.alt = altText; + } + doc.body.appendChild(img); + + return "\n" + doc.documentElement.outerHTML; +} diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 82af331bb5..2efab2deea 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -455,7 +455,7 @@ open class PDFNavigatorViewController: } if currentResourceIndex != index { - guard let document = PDFDocument(url: url.url) else { + guard let document = await makeDocument(at: url) else { log(.error, "Can't open PDF document at \(url)") return false } @@ -483,6 +483,13 @@ open class PDFNavigatorViewController: return true } + private func makeDocument(at url: AbsoluteURL) async -> PDFKit.PDFDocument? { + let task = Task.detached(priority: .userInitiated) { + PDFDocument(url: url.url) + } + return await task.value + } + /// Updates the scale factors to match the currently visible pages. /// /// - Parameter zoomToFit: When true, the document will be zoomed to fit the diff --git a/Sources/Shared/Publication/Manifest.swift b/Sources/Shared/Publication/Manifest.swift index cbe83b64bc..ad8ee2c6c5 100644 --- a/Sources/Shared/Publication/Manifest.swift +++ b/Sources/Shared/Publication/Manifest.swift @@ -120,7 +120,7 @@ public struct Manifest: JSONEquatable, Hashable, Sendable { case .epub: // EPUB needs to be explicitly indicated in `conformsTo`, otherwise // it could be a regular Web Publication. - return readingOrder.allAreHTML && metadata.conformsTo.contains(.epub) + return metadata.conformsTo.contains(.epub) case .pdf: return readingOrder.allMatchingMediaType(.pdf) default: diff --git a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift index 5d821ff63e..ddaca53740 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift @@ -21,11 +21,10 @@ final class EPUBManifestParser { // Extracts metadata and links from the OPF. let opfPackage = try await OPFParser(container: container, opfHREF: opfHREF, encryptions: encryptions).parsePublication() - let metadata = opfPackage.metadata let links = opfPackage.readingOrder + opfPackage.resources var manifest = await Manifest( - metadata: metadata, + metadata: opfPackage.metadata, readingOrder: opfPackage.readingOrder, resources: opfPackage.resources, subcollections: parseCollections(in: container, package: opfPackage, links: links) diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 9d28bf5e31..f39a31b05b 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -29,6 +29,13 @@ public enum OPFParserError: Error { /// EpubParser support class, able to parse the OPF package document. /// OPF: Open Packaging Format. final class OPFParser: Loggable { + /// Internal representation of a manifest item during parsing. + private struct ManifestItem { + let id: String + let link: Link + let fallbackId: String? + } + /// Relative path to the OPF in the EPUB container private let baseURL: RelativeURL @@ -88,13 +95,19 @@ final class OPFParser: Loggable { /// Parse the OPF file of the EPUB container and return a `Publication`. /// It also complete the informations stored in the container. func parsePublication() throws -> Package { - let links = parseLinks() - let (resources, readingOrder) = splitResourcesAndReadingOrderLinks(links) - let metadata = EPUBMetadataParser(document: document, displayOptions: displayOptions, metas: metas) + let manifestItems = parseManifestItems() + let (resources, readingOrder) = splitResourcesAndReadingOrderLinks(manifestItems) + var metadata = try EPUBMetadataParser(document: document, displayOptions: displayOptions, metas: metas).parse() + + // If all reading order items are bitmaps, we infer a Divina. + if readingOrder.allAreBitmap { + metadata.layout = .fixed + metadata.conformsTo.append(.divina) + } - return try Package( + return Package( version: parseEPUBVersion(), - metadata: metadata.parse(), + metadata: metadata, readingOrder: readingOrder, resources: resources, epub2Guide: parseEPUB2Guide() @@ -129,8 +142,8 @@ final class OPFParser: Loggable { } } - /// Parses XML elements of the in the package.opf file as a list of `Link`. - private func parseLinks() -> [Link] { + /// Parses XML elements of the in the package.opf file. + private func parseManifestItems() -> [ManifestItem] { // Read meta to see if any Link is referenced as the Cover. let coverId = metas["cover"].first?.content @@ -152,44 +165,21 @@ final class OPFParser: Loggable { let isCover = (id == coverId) - guard let link = makeLink(manifestItem: manifestItem, spineItem: spineItems[id], isCover: isCover) else { + guard let item = makeManifestItem(id: id, manifestItem: manifestItem, spineItem: spineItems[id], isCover: isCover) else { log(.warning, "Can't parse link with ID \(id)") return nil } - return link - } - } - - /// Parses XML elements of the in the package.opf file. - /// They are only composed of an `idref` referencing one of the previously parsed resource (XML: idref -> id). - /// - /// - Parameter manifestLinks: The `Link` parsed in the manifest items. - /// - Returns: The `Link` in `resources` and in `readingOrder`, taken from the `manifestLinks`. - private func splitResourcesAndReadingOrderLinks(_ manifestLinks: [Link]) -> (resources: [Link], readingOrder: [Link]) { - var resources = manifestLinks - var readingOrder: [Link] = [] - - let spineItems = document.xpath("/opf:package/opf:spine/opf:itemref") - for item in spineItems { - // Find the `Link` that `idref` is referencing to from the `manifestLinks`. - guard let idref = item.attr("idref"), - let index = resources.firstIndex(where: { $0.properties["id"] as? String == idref }), - // Only linear items are added to the readingOrder. - item.attr("linear")?.lowercased() != "no" - else { - continue - } - - readingOrder.append(resources[index]) - // `resources` should only contain the links that are not already in `readingOrder`. - resources.remove(at: index) + return item } - - return (resources, readingOrder) } - private func makeLink(manifestItem: ReadiumFuzi.XMLElement, spineItem: ReadiumFuzi.XMLElement?, isCover: Bool) -> Link? { + private func makeManifestItem( + id: String, + manifestItem: ReadiumFuzi.XMLElement, + spineItem: ReadiumFuzi.XMLElement?, + isCover: Bool + ) -> ManifestItem? { guard let relativeHref = manifestItem.attr("href").flatMap(RelativeURL.init(epubHREF:)), let href = baseURL.resolve(relativeHref)?.normalized @@ -216,18 +206,68 @@ final class OPFParser: Loggable { properties["encrypted"] = encryption } - let type = manifestItem.attr("media-type") - - if let id = manifestItem.attr("id") { - properties["id"] = id - } - - return Link( + let link = Link( href: href.string, - mediaType: type.flatMap { MediaType($0) }, + mediaType: manifestItem.attr("media-type").flatMap { MediaType($0) }, rels: rels, properties: Properties(properties) ) + + return ManifestItem( + id: id, + link: link, + fallbackId: manifestItem.attr("fallback") + ) + } + + /// Parses XML elements of the spine in the package.opf file. + /// + /// They are only composed of an `idref` referencing one of the previously + /// parsed resource (XML: idref -> id). + /// + /// Handles image spine items with HTML fallbacks (and vice versa) by + /// putting the image in the reading order and the HTML in `alternates`. + /// This is because we prefer treating it as a Divina to render it. + /// + /// - Parameter manifestItems: The items parsed from the manifest. + /// - Returns: The `Link` in `resources` and in `readingOrder`. + private func splitResourcesAndReadingOrderLinks(_ manifestItems: [ManifestItem]) -> (resources: [Link], readingOrder: [Link]) { + var items = manifestItems + var readingOrder: [Link] = [] + + let spineItems = document.xpath("/opf:package/opf:spine/opf:itemref") + for spineItem in spineItems { + // Find the item that `idref` is referencing. + guard + let idref = spineItem.attr("idref"), + let index = items.firstIndex(where: { $0.id == idref }), + // Only linear items are added to the readingOrder. + spineItem.attr("linear")?.lowercased() != "no" + else { + continue + } + + let item = items.remove(at: index) + var spineLink = item.link + + // Resolve fallback: prefer bitmaps as primary to treat image-based + // EPUBs as Divina + if + let fallbackId = item.fallbackId, + let fallbackIndex = items.firstIndex(where: { $0.id == fallbackId }) + { + let fallbackItem = items.remove(at: fallbackIndex) + spineLink = resolveFallbackChain( + spineLink: spineLink, + fallbackLink: fallbackItem.link + ) + } + + readingOrder.append(spineLink) + } + + let resources = items.map(\.link) + return (resources, readingOrder) } /// Parse string properties into an `otherProperties` dictionary. @@ -272,4 +312,25 @@ final class OPFParser: Loggable { return otherProperties } + + /// Resolves which link should be primary vs alternate when a fallback is + /// present. + /// + /// We prefer bitmaps as primary to treat image-based EPUBs as Divina. + private func resolveFallbackChain( + spineLink: Link, + fallbackLink: Link + ) -> Link { + var link = spineLink + // If fallback is a bitmap and spine is HTML, swap them. + if spineLink.mediaType?.isHTML == true, fallbackLink.mediaType?.isBitmap == true { + link = fallbackLink + // Transfer spine properties (like page spread) to the image + link.properties = spineLink.properties + link.alternates = [spineLink] + } else { + link.alternates = [fallbackLink] + } + return link + } } diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index 822688c5ee..e7f7fb0187 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -179,9 +179,6 @@ public final class ImageParser: PublicationParser { metadata.localizedTitle = .nonlocalized(fallbackTitle) } - // Display the first page on its own by default. - readingOrder[0].properties.page = .center - // Apply center page layout for double-page spreads if let pages = comicInfo?.pages { for pageInfo in pages where pageInfo.doublePage == true { diff --git a/Tests/StreamerTests/Fixtures/OPF/all-images-in-spine.opf b/Tests/StreamerTests/Fixtures/OPF/all-images-in-spine.opf new file mode 100644 index 0000000000..79c8b40746 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/all-images-in-spine.opf @@ -0,0 +1,18 @@ + + + + All Images in Spine Test + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/fallback-general.opf b/Tests/StreamerTests/Fixtures/OPF/fallback-general.opf new file mode 100644 index 0000000000..3e8bee182e --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/fallback-general.opf @@ -0,0 +1,16 @@ + + + + General Fallback Test + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/fallback-html-in-spine.opf b/Tests/StreamerTests/Fixtures/OPF/fallback-html-in-spine.opf new file mode 100644 index 0000000000..d023eae0b5 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/fallback-html-in-spine.opf @@ -0,0 +1,16 @@ + + + + HTML in Spine with Image Fallback Test + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/fallback-image-html-mixed.opf b/Tests/StreamerTests/Fixtures/OPF/fallback-image-html-mixed.opf new file mode 100644 index 0000000000..958da78c75 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/fallback-image-html-mixed.opf @@ -0,0 +1,15 @@ + + + + Image and XHTML in Spine Test + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/fallback-image-in-spine.opf b/Tests/StreamerTests/Fixtures/OPF/fallback-image-in-spine.opf new file mode 100644 index 0000000000..d01bb3427a --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/fallback-image-in-spine.opf @@ -0,0 +1,16 @@ + + + + Image in Spine Test + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift index 0405fa9888..fa0bfcea2e 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift @@ -68,17 +68,17 @@ class EPUBManifestParserTests: XCTestCase { ] ), readingOrder: [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml", mediaType: .xhtml), - link(id: "toc", href: "EPUB/toc.xhtml", mediaType: .xhtml), - link(id: "chapter01", href: "EPUB/chapter01.xhtml", mediaType: .xhtml), - link(id: "chapter02", href: "EPUB/chapter02.xhtml", mediaType: .xhtml), + link(href: "EPUB/titlepage.xhtml", mediaType: .xhtml), + link(href: "EPUB/toc.xhtml", mediaType: .xhtml), + link(href: "EPUB/chapter01.xhtml", mediaType: .xhtml), + link(href: "EPUB/chapter02.xhtml", mediaType: .xhtml), ], resources: [ - link(id: "font0", href: "EPUB/fonts/MinionPro.otf", mediaType: MediaType("application/vnd.ms-opentype")!), - link(id: "nav", href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), - link(id: "css", href: "EPUB/style.css", mediaType: .css), - link(id: "img01a", href: "EPUB/images/alice01a.gif", mediaType: .gif, rels: [.cover]), - link(id: "img02a", href: "EPUB/images/alice02a.gif", mediaType: .gif), + link(href: "EPUB/fonts/MinionPro.otf", mediaType: MediaType("application/vnd.ms-opentype")!), + link(href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), + link(href: "EPUB/style.css", mediaType: .css), + link(href: "EPUB/images/alice01a.gif", mediaType: .gif, rels: [.cover]), + link(href: "EPUB/images/alice02a.gif", mediaType: .gif), ] ) ) @@ -96,10 +96,10 @@ class EPUBManifestParserTests: XCTestCase { XCTAssertEqual( manifest.readingOrder, [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml", mediaType: .xhtml, rels: [.cover]), - link(id: "toc", href: "EPUB/toc.xhtml", mediaType: .xhtml, rels: [.contents]), - link(id: "chapter01", href: "EPUB/chapter01.xhtml", mediaType: .xhtml, rels: [.start]), - link(id: "chapter02", href: "EPUB/chapter02.xhtml", mediaType: .xhtml), + link(href: "EPUB/titlepage.xhtml", mediaType: .xhtml, rels: [.cover]), + link(href: "EPUB/toc.xhtml", mediaType: .xhtml, rels: [.contents]), + link(href: "EPUB/chapter01.xhtml", mediaType: .xhtml, rels: [.start]), + link(href: "EPUB/chapter02.xhtml", mediaType: .xhtml), ] ) } @@ -124,12 +124,14 @@ class EPUBManifestParserTests: XCTestCase { XCTAssertEqual( manifest.readingOrder, [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml", mediaType: .xhtml), - link(id: "beginpage", href: "EPUB/beginpage.xhtml", mediaType: .xhtml, rels: [.start]), + link(href: "EPUB/titlepage.xhtml", mediaType: .xhtml), + link(href: "EPUB/beginpage.xhtml", mediaType: .xhtml, rels: [.start]), ] ) } + // MARK: - Helpers + private func parser(files: [String: String]) -> EPUBManifestParser { EPUBManifestParser( container: FileContainer(files: files.reduce(into: [:]) { files, item in @@ -140,7 +142,6 @@ class EPUBManifestParserTests: XCTestCase { } private func link( - id: String? = nil, href: String, mediaType: MediaType? = nil, templated: Bool = false, @@ -149,10 +150,6 @@ class EPUBManifestParserTests: XCTestCase { properties: Properties = .init(), children: [Link] = [] ) -> Link { - var properties = properties.otherProperties - if let id = id { - properties["id"] = id - } - return Link(href: href, mediaType: mediaType, templated: templated, title: title, rels: rels, properties: Properties(properties), children: children) + Link(href: href, mediaType: mediaType, templated: templated, title: title, rels: rels, properties: properties, children: children) } } diff --git a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift index 5e4ca281d3..9a9d07f4db 100644 --- a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift @@ -21,7 +21,7 @@ class OPFParserTests: XCTestCase { layout: .reflowable ), readingOrder: [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml"), + link(href: "EPUB/titlepage.xhtml"), ] )) } @@ -46,19 +46,19 @@ class OPFParserTests: XCTestCase { XCTAssertEqual(sut.links, []) XCTAssertEqual(sut.readingOrder, [ - link(id: "titlepage", href: "titlepage.xhtml", mediaType: .xhtml), - link(id: "chapter01", href: "EPUB/chapter01.xhtml", mediaType: .xhtml), + link(href: "titlepage.xhtml", mediaType: .xhtml), + link(href: "EPUB/chapter01.xhtml", mediaType: .xhtml), ]) XCTAssertEqual(sut.resources, [ - link(id: "font0", href: "EPUB/fonts/MinionPro.otf", mediaType: MediaType("application/vnd.ms-opentype")!), - link(id: "nav", href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), - link(id: "css", href: "style.css", mediaType: .css), - link(id: "chapter02", href: "EPUB/chapter02.xhtml", mediaType: .xhtml), - link(id: "chapter01_smil", href: "EPUB/chapter01.smil", mediaType: .smil), - link(id: "chapter02_smil", href: "EPUB/chapter02.smil", mediaType: .smil), - link(id: "img01a", href: "EPUB/images/alice01a.png", mediaType: .png, rels: [.cover]), - link(id: "img02a", href: "EPUB/images/alice02a.gif", mediaType: .gif), - link(id: "nomediatype", href: "EPUB/nomediatype.txt"), + 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/chapter01.smil", mediaType: .smil), + link(href: "EPUB/chapter02.smil", mediaType: .smil), + link(href: "EPUB/images/alice01a.png", mediaType: .png, rels: [.cover]), + link(href: "EPUB/images/alice02a.gif", mediaType: .gif), + link(href: "EPUB/nomediatype.txt"), ]) } @@ -66,7 +66,7 @@ class OPFParserTests: XCTestCase { let sut = try parseManifest("links-spine", at: "EPUB/content.opf").manifest XCTAssertEqual(sut.readingOrder, [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml"), + link(href: "EPUB/titlepage.xhtml"), ]) } @@ -74,32 +74,32 @@ class OPFParserTests: XCTestCase { let sut = try parseManifest("links-properties", at: "EPUB/content.opf").manifest XCTAssertEqual(sut.readingOrder.count, 8) - XCTAssertEqual(sut.readingOrder[0], link(id: "chapter01", href: "EPUB/chapter01.xhtml", rels: [.contents], properties: Properties([ + XCTAssertEqual(sut.readingOrder[0], link(href: "EPUB/chapter01.xhtml", rels: [.contents], properties: Properties([ "contains": ["mathml"], "page": "right", ]))) - XCTAssertEqual(sut.readingOrder[1], link(id: "chapter02", href: "EPUB/chapter02.xhtml", properties: Properties([ + XCTAssertEqual(sut.readingOrder[1], link(href: "EPUB/chapter02.xhtml", properties: Properties([ "contains": ["remote-resources"], "page": "left", ]))) - XCTAssertEqual(sut.readingOrder[2], link(id: "chapter03", href: "EPUB/chapter03.xhtml", properties: Properties([ + XCTAssertEqual(sut.readingOrder[2], link(href: "EPUB/chapter03.xhtml", properties: Properties([ "contains": ["js", "svg"], "page": "center", ]))) - XCTAssertEqual(sut.readingOrder[3], link(id: "chapter04", href: "EPUB/chapter04.xhtml", properties: Properties([ + XCTAssertEqual(sut.readingOrder[3], link(href: "EPUB/chapter04.xhtml", properties: Properties([ "contains": ["onix", "xmp"], ]))) - XCTAssertEqual(sut.readingOrder[4], link(id: "chapter05", href: "EPUB/chapter05.xhtml", properties: Properties())) - XCTAssertEqual(sut.readingOrder[5], link(id: "chapter06", href: "EPUB/chapter06.xhtml", properties: Properties())) - XCTAssertEqual(sut.readingOrder[6], link(id: "chapter07", href: "EPUB/chapter07.xhtml", properties: Properties())) - XCTAssertEqual(sut.readingOrder[7], link(id: "chapter08", href: "EPUB/chapter08.xhtml", properties: Properties())) + 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(id: "my-cover", href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), + link(href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), ]) } @@ -107,11 +107,123 @@ class OPFParserTests: XCTestCase { let sut = try parseManifest("cover-epub3", at: "EPUB/content.opf").manifest XCTAssertEqual(sut.resources, [ - link(id: "my-cover", href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), + link(href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), ]) } - // MARK: - Toolkit + // 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) + + // 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), + ]) + + // 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), + ]) + + // HTML fallbacks should not be in resources + XCTAssertTrue(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. + 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) + } + + /// 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) + } + + // 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) + } + + /// 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) + + // Should only conform to EPUB, not Divina + XCTAssertTrue(sut.metadata.conformsTo.contains(.epub)) + XCTAssertFalse(sut.metadata.conformsTo.contains(.divina)) + } + + // MARK: - Helpers func parseManifest(_ name: String, at path: String = "EPUB/content.opf", displayOptions: String? = nil) throws -> (manifest: Manifest, version: String) { let parts = try OPFParser( @@ -128,11 +240,7 @@ class OPFParserTests: XCTestCase { ), parts.version) } - func link(id: String? = nil, href: String, mediaType: MediaType? = nil, templated: Bool = false, title: String? = nil, rels: [LinkRelation] = [], properties: Properties = .init(), children: [Link] = []) -> Link { - var properties = properties.otherProperties - if let id = id { - properties["id"] = id - } - return Link(href: href, mediaType: mediaType, templated: templated, title: title, rels: rels, properties: Properties(properties), children: children) + 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/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index 20794f8dfd..172c4780d2 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -167,10 +167,7 @@ class ImageParserTests: XCTestCase { func testDoublePageSpreadSetsCenterPage() async throws { let publication = try await parser.parse(asset: cbzWithComicInfoAsset, warnings: nil).get().build() - // Page 0 (cover) should have center page property (default behavior) - XCTAssertEqual(publication.readingOrder[0].properties.page, .center) - - // Page 1 should not have center page property + XCTAssertNil(publication.readingOrder[0].properties.page) XCTAssertNil(publication.readingOrder[1].properties.page) // Page 2 has DoublePage="True" in ComicInfo.xml, should have center page From 80b581ec1683e8a1c832bc024aad85b6f6fb1a22 Mon Sep 17 00:00:00 2001 From: Leonard Beus Date: Thu, 15 Jan 2026 17:37:26 +0100 Subject: [PATCH 20/55] Fix custom EPUB reading order --- CHANGELOG.md | 1 + Sources/Navigator/EPUB/EPUBNavigatorViewController.swift | 3 ++- Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift | 6 +++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92bb29ac5f..78fe836689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file. Take a look #### Navigator * PDF documents are now opened off the main thread, preventing UI freezes with large files. +* Fixed providing a custom reading order to the `EPUBNavigatorViewController` (contributed by [@lbeus](https://github.com/readium/swift-toolkit/pull/694)). ## [3.6.0] diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 60a9ccf8e2..382a080839 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -296,6 +296,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, let viewModel = try EPUBNavigatorViewModel( publication: publication, + readingOrder: readingOrder ?? publication.readingOrder, config: config, httpServer: httpServer ) @@ -303,7 +304,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, self.init( viewModel: viewModel, initialLocation: initialLocation, - readingOrder: readingOrder ?? publication.readingOrder, + readingOrder: viewModel.readingOrder, positionsByReadingOrder: // Positions and total progression only make sense in the context // of the publication's actual reading order. Therefore when diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 68440246af..c0db2b036c 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -38,10 +38,11 @@ final class EPUBNavigatorViewModel: Loggable { /// `httpServer`. This is used to serve custom font files, for example. @Atomic private var servedFiles: [FileURL: HTTPURL] = [:] - var readingOrder: ReadingOrder { publication.readingOrder } + let readingOrder: ReadingOrder convenience init( publication: Publication, + readingOrder: ReadingOrder, config: EPUBNavigatorViewController.Configuration, httpServer: HTTPServer ) throws { @@ -55,6 +56,7 @@ final class EPUBNavigatorViewModel: Loggable { try self.init( publication: publication, + readingOrder: readingOrder, config: config, httpServer: httpServer, publicationEndpoint: publicationEndpoint, @@ -89,6 +91,7 @@ final class EPUBNavigatorViewModel: Loggable { private init( publication: Publication, + readingOrder: ReadingOrder, config: EPUBNavigatorViewController.Configuration, httpServer: HTTPServer?, publicationEndpoint: HTTPServerEndpoint?, @@ -123,6 +126,7 @@ final class EPUBNavigatorViewModel: Loggable { } self.publication = publication + self.readingOrder = readingOrder self.config = config editingActions = EditingActionsController( actions: config.editingActions, From 8327f0bca4de7112909cd511d741615789b8a802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 19 Jan 2026 18:26:32 +0100 Subject: [PATCH 21/55] Add `offsetFirstPage` preference for fixed-layout EPUBs (#697) --- CHANGELOG.md | 1 + .../Navigator/EPUB/EPUBFixedSpreadView.swift | 4 +- .../EPUB/EPUBNavigatorViewController.swift | 19 +- .../EPUB/EPUBNavigatorViewModel.swift | 2 + .../EPUB/EPUBReflowableSpreadView.swift | 11 +- Sources/Navigator/EPUB/EPUBSpread.swift | 306 +++++++++++------- Sources/Navigator/EPUB/EPUBSpreadView.swift | 6 +- .../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 ++- 13 files changed, 291 insertions(+), 150 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fe836689..a35e8919ae 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/EPUBFixedSpreadView.swift b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift index afd0b37eaa..d88a938a4c 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 382a080839..a88c2e8908 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 @@ -604,7 +602,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 = { @@ -1256,7 +1255,7 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { } func spreadViewDidTerminate() { - reloadSpreads(force: true) + reloadSpreads() } } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index c0db2b036c..57e4c7e9de 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/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 1436541904..df308ca1fe 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 938eddda10..320af5c696 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -7,106 +7,76 @@ 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 +/// Common interface for spread types. +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 { + /// A spread displaying a single resource. + case single(EPUBSingleSpread) + /// A spread displaying two resources side by side (FXL only). + 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 + /// Range of reading order indices contained in this spread. + 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 + /// The leading resource in the reading progression. + 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 + private 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. @@ -115,29 +85,27 @@ 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) - : makeOnePageSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression) + ? makeTwoPagesSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression, offsetFirstPage: offsetFirstPage) + : 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) + )) } } @@ -145,62 +113,66 @@ struct EPUBSpread: Loggable { private static func makeTwoPagesSpreads( for publication: Publication, readingOrder: [Link], - readingProgression: ReadingProgression + readingProgression: ReadingProgression, + offsetFirstPage: Bool? ) -> [EPUBSpread] { var spreads: [EPUBSpread] = [] 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) + 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) -> Bool { - guard index > 0 || 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 @@ -212,3 +184,99 @@ private extension Publication { } } } + +/// 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, + "link": link.json, + "url": link.url(relativeTo: baseURL).string, + "page": page.rawValue, + ] + } +} + +/// 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 { + 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 + ), + ] + } +} + +/// 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. + 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 fbdd5abd5e..414fdaf88c 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() } diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index fabc233fef..a0e643e0ac 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 6fa73203c0..f06856714a 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 ae20df56ca..85f08a3a00 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 db9a57bae8..723565f582 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 3f249416a7..9124041194 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 fa5c045229..6121a49db9 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 4a4b7f91573a91d689e7b2556ed2243438ff35d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 20 Jan 2026 17:52:42 +0100 Subject: [PATCH 22/55] Remove title inference based on folder names for raw archives (#698) --- CHANGELOG.md | 1 + .../Streamer/Parser/Audio/AudioParser.swift | 2 +- .../Streamer/Parser/Image/ImageParser.swift | 10 +--- .../Toolkit/Extensions/Container.swift | 21 -------- TestApp/Sources/Library/LibraryService.swift | 21 ++++++-- .../Parser/Audio/AudioParserTests.swift | 5 -- .../Parser/Image/ImageParserTests.swift | 5 -- .../Toolkit/Extensions/ContainerTests.swift | 53 ------------------- 8 files changed, 20 insertions(+), 98 deletions(-) delete mode 100644 Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a35e8919ae..f8c2dc68b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file. Take a look #### Streamer * The EPUB manifest item `id` attribute is no longer exposed in `Link.properties`. +* Removed title inference based on folder names within image and audio archives. Use the archive's filename instead. ### Fixed diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index 0d9a461fcf..821415a608 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -76,7 +76,7 @@ public final class AudioParser: PublicationParser { await makeBuilder( container: asset.container, readingOrder: readingOrder, - title: asset.container.guessTitle(ignoring: ignores) + title: nil ) } } diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index e7f7fb0187..8d55922cf3 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -53,8 +53,7 @@ public final class ImageParser: PublicationParser { let container = SingleResourceContainer(publication: asset) return makeBuilder( container: container, - readingOrder: [(container.entry, asset.format)], - fallbackTitle: nil + readingOrder: [(container.entry, asset.format)] ) } @@ -68,14 +67,12 @@ public final class ImageParser: PublicationParser { // Parse ComicInfo.xml metadata if present let comicInfo = await parseComicInfo(from: asset.container, warnings: warnings) - let fallbackTitle = asset.container.guessTitle(ignoring: ignores) return await makeReadingOrder(for: asset.container) .flatMap { readingOrder in makeBuilder( container: asset.container, readingOrder: readingOrder, - fallbackTitle: fallbackTitle, comicInfo: comicInfo ) } @@ -131,7 +128,6 @@ public final class ImageParser: PublicationParser { private func makeBuilder( container: Container, readingOrder: [(AnyURL, Format)], - fallbackTitle: String?, comicInfo: ComicInfo? = nil ) -> Result { guard !readingOrder.isEmpty else { @@ -175,10 +171,6 @@ public final class ImageParser: PublicationParser { metadata.conformsTo = [.divina] metadata.layout = .fixed - if metadata.localizedTitle == nil, let fallbackTitle = fallbackTitle { - metadata.localizedTitle = .nonlocalized(fallbackTitle) - } - // Apply center page layout for double-page spreads if let pages = comicInfo?.pages { for pageInfo in pages where pageInfo.doublePage == true { diff --git a/Sources/Streamer/Toolkit/Extensions/Container.swift b/Sources/Streamer/Toolkit/Extensions/Container.swift index 87766875ab..9e69dce983 100644 --- a/Sources/Streamer/Toolkit/Extensions/Container.swift +++ b/Sources/Streamer/Toolkit/Extensions/Container.swift @@ -57,25 +57,4 @@ extension Container { return .success(entries) } - - /// Guesses a publication title from a list of resource HREFs. - /// - /// If the HREFs contain a single root directory, we assume it is the - /// title. This is often the case for example with CBZ files. - func guessTitle(ignoring: (AnyURL) -> Bool = { _ in false }) -> String? { - var title: String? - - for url in entries { - if ignoring(url) { - continue - } - let segments = url.pathSegments - guard segments.count > 1, title == nil || title == segments.first else { - return nil - } - title = segments.first - } - - return title - } } diff --git a/TestApp/Sources/Library/LibraryService.swift b/TestApp/Sources/Library/LibraryService.swift index b0307d6e3a..1d9e965ad8 100644 --- a/TestApp/Sources/Library/LibraryService.swift +++ b/TestApp/Sources/Library/LibraryService.swift @@ -116,17 +116,24 @@ final class LibraryService: Loggable { } let (pub, format) = try await openPublication(at: url, allowUserInteraction: false, sender: sender) + let title = pub.metadata.title ?? url.url.deletingPathExtension().lastPathComponent let coverPath = try await importCover(of: pub) if let file = url.fileURL { url = try moveToDocuments( from: file, - title: pub.metadata.title ?? file.lastPathSegment, + title: title, format: format ) } - return try await insertBook(at: url, publication: pub, mediaType: format.mediaType, coverPath: coverPath) + return try await insertBook( + at: url, + publication: pub, + mediaType: format.mediaType, + title: title, + coverPath: coverPath + ) } /// Fulfills the given `url` if it's a DRM license file. @@ -177,13 +184,19 @@ final class LibraryService: Loggable { } /// Inserts the given `book` in the bookshelf. - private func insertBook(at url: AbsoluteURL, publication: Publication, mediaType: MediaType?, coverPath: String?) async throws -> Book { + private func insertBook( + at url: AbsoluteURL, + publication: Publication, + mediaType: MediaType?, + title: String, + coverPath: String? + ) async throws -> Book { // Makes the URL relative to the Documents/ folder if possible. let url: AnyURL = Paths.documents.relativize(url)?.anyURL ?? url.anyURL let book = Book( identifier: publication.metadata.identifier, - title: publication.metadata.title ?? url.lastPathSegment ?? "Untitled", + title: title, authors: publication.metadata.authors .map(\.name) .joined(separator: ", "), diff --git a/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift b/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift index c1ec0eee02..2d7cda1116 100644 --- a/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift +++ b/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift @@ -77,11 +77,6 @@ class AudioParserTests: XCTestCase { XCTAssertNil(publication.linkWithRel(.cover)) } - func testComputeTitleFromArchiveRootDirectory() async throws { - let publication = try await parser.parse(asset: zabAsset, warnings: nil).get().build() - XCTAssertEqual(publication.metadata.title, "Test Audiobook") - } - func testHasNoPositions() async throws { let publication = try await parser.parse(asset: zabAsset, warnings: nil).get().build() let result = try await publication.positions().get() diff --git a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index 172c4780d2..fbb87e2349 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -86,11 +86,6 @@ class ImageParserTests: XCTestCase { XCTAssertEqual(publication.readingOrder.first, cover) } - func testComputeTitleFromArchiveRootDirectory() async throws { - let publication = try await parser.parse(asset: cbzAsset, warnings: nil).get().build() - XCTAssertEqual(publication.metadata.title, "Cory Doctorow's Futuristic Tales of the Here and Now") - } - func testPositions() async throws { let publication = try await parser.parse(asset: cbzAsset, warnings: nil).get().build() diff --git a/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift b/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift deleted file mode 100644 index a0dd77af00..0000000000 --- a/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift +++ /dev/null @@ -1,53 +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 -@testable import ReadiumStreamer -import XCTest - -class ContainerTests: XCTestCase { - func testGuessTitleWithoutDirectories() { - let container = TestContainer(hrefs: ["a.txt", "b.png"]) - XCTAssertNil(container.guessTitle()) - } - - func testGuessTitleWithOneRootDirectory() { - let container = TestContainer(hrefs: ["Root%20Directory/b.png", "Root%20Directory/dir/c.png"]) - XCTAssertEqual(container.guessTitle(), "Root Directory") - } - - func testGuessTitleWithOneRootDirectoryButRootFiles() { - let container = TestContainer(hrefs: ["a.txt", "Root%20Directory/b.png", "Root%20Directory/dir/c.png"]) - XCTAssertNil(container.guessTitle()) - } - - func testGuessTitleWithOneRootDirectoryIgnoringRootFile() { - let container = TestContainer(hrefs: [".hidden", "Root%20Directory/b.png", "Root%20Directory/dir/c.png"]) - XCTAssertEqual(container.guessTitle(ignoring: { url in url.lastPathSegment == ".hidden" }), "Root Directory") - } - - func testGuessTitleWithSeveralDirectories() { - let container = TestContainer(hrefs: ["a.txt", "dir1/b.png", "dir2/c.png"]) - XCTAssertNil(container.guessTitle()) - } - - func testGuessTitleIgnoresSingleFiles() { - let container = TestContainer(hrefs: ["single"]) - XCTAssertNil(container.guessTitle()) - } -} - -private struct TestContainer: Container { - init(hrefs: [String]) { - entries = Set(hrefs.map { AnyURL(string: $0)! }) - } - - let entries: Set - - let sourceURL: (any AbsoluteURL)? = nil - - subscript(url: any URLConvertible) -> (any Resource)? { nil } -} From d56f9405e5a13e65cfe6ec011a37955df75e3f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 21 Jan 2026 16:23:20 +0100 Subject: [PATCH 23/55] Add support for JXL (#703) --- .github/workflows/checks.yml | 4 +++- CHANGELOG.md | 4 ++++ Sources/Shared/Toolkit/Format/Format.swift | 1 + Sources/Shared/Toolkit/Format/MediaType.swift | 3 ++- .../Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift | 3 +++ .../Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift | 1 + Sources/Streamer/Parser/Image/ImageParser.swift | 1 + Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift | 5 +++++ Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift | 3 ++- 9 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 031cd49907..71ec1b1392 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,7 +7,7 @@ on: env: platform: ${{ 'iOS Simulator' }} - device: ${{ 'iPhone 17' }} + device: ${{ 'iPhone 16 Pro' }} commit_sha: ${{ github.sha }} DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer @@ -33,6 +33,7 @@ jobs: git diff --exit-code Support/Carthage/Readium.xcodeproj - name: Build run: | + xcrun simctl list set -eo pipefail xcodebuild build-for-testing -scheme "$scheme" -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi - name: Test @@ -55,6 +56,7 @@ jobs: run: | set -eo pipefail make navigator-ui-tests-project + xcrun simctl list xcodebuild test -project Tests/NavigatorTests/UITests/NavigatorUITests.xcodeproj -scheme NavigatorTestHost -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi lint: diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c2dc68b3..3ad15fcc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. Take a look ### Added +#### Shared + +* Added support for JXL (JPEG XL) bitmap images. JXL is decoded natively on iOS 17+. + #### Navigator * Support for displaying Divina (image-based publications like CBZ) in the fixed-layout EPUB navigator. diff --git a/Sources/Shared/Toolkit/Format/Format.swift b/Sources/Shared/Toolkit/Format/Format.swift index 2594abdf52..4f395e7035 100644 --- a/Sources/Shared/Toolkit/Format/Format.swift +++ b/Sources/Shared/Toolkit/Format/Format.swift @@ -164,6 +164,7 @@ public struct FormatSpecification: RawRepresentable, Hashable { public static let bmp = FormatSpecification(rawValue: "bmp") public static let gif = FormatSpecification(rawValue: "gif") public static let jpeg = FormatSpecification(rawValue: "jpeg") + public static let jxl = FormatSpecification(rawValue: "jxl") public static let png = FormatSpecification(rawValue: "png") public static let tiff = FormatSpecification(rawValue: "tiff") public static let webp = FormatSpecification(rawValue: "webp") diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index 7441861b57..45212bb798 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -188,7 +188,7 @@ public struct MediaType: Hashable, Loggable, Sendable { /// Returns whether this media type is of a bitmap image, so excluding vectorial formats. public var isBitmap: Bool { - matchesAny(.bmp, .gif, .jpeg, .png, .tiff, .webp) + matchesAny(.bmp, .gif, .jpeg, .jxl, .png, .tiff, .webp) } /// Returns whether this media type is of an audio clip. @@ -228,6 +228,7 @@ public struct MediaType: Hashable, Loggable, Sendable { public static let javascript = MediaType("text/javascript")! public static let jpeg = MediaType("image/jpeg")! public static let json = MediaType("application/json")! + public static let jxl = MediaType("image/jxl")! public static let lcpLicenseDocument = MediaType("application/vnd.readium.lcp.license.v1.0+json")! public static let lcpProtectedAudiobook = MediaType("application/audiobook+lcp")! public static let lcpProtectedPDF = MediaType("application/pdf+lcp")! diff --git a/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift index 310e5eb669..165e44a59f 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift @@ -23,6 +23,9 @@ public class BitmapFormatSniffer: FormatSniffer { if hints.hasFileExtension("jpg", "jpeg", "jpe", "jif", "jfif", "jfi") || hints.hasMediaType("image/jpeg") { return Format(specifications: .jpeg, mediaType: .jpeg, fileExtension: "jpg") } + if hints.hasFileExtension("jxl") || hints.hasMediaType("image/jxl") { + return Format(specifications: .jxl, mediaType: .jxl, fileExtension: "jxl") + } if hints.hasFileExtension("png") || hints.hasMediaType("image/png") { return Format(specifications: .png, mediaType: .png, fileExtension: "png") } diff --git a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift index bee791a34d..1972d49727 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift @@ -25,6 +25,7 @@ public struct ComicFormatSniffer: FormatSniffer { "jfif", "jpg", "jpeg", + "jxl", "png", "tif", "tiff", diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index 8d55922cf3..eea46d692f 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -25,6 +25,7 @@ public final class ImageParser: PublicationParser { .bmp, .gif, .jpeg, + .jxl, .png, .tiff, .webp, diff --git a/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift b/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift index 977003d166..c7e541d9ad 100644 --- a/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift +++ b/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift @@ -157,6 +157,11 @@ class FormatSniffersTests: XCTestCase { XCTAssertEqual(sut.sniffHints(fileExtension: "jfi"), jpeg) XCTAssertEqual(sut.sniffHints(mediaType: "image/jpeg"), jpeg) + // JXL + let jxl = Format(specifications: .jxl, mediaType: .jxl, fileExtension: "jxl") + XCTAssertEqual(sut.sniffHints(fileExtension: "jxl"), jxl) + XCTAssertEqual(sut.sniffHints(mediaType: "image/jxl"), jxl) + // PNG let png = Format(specifications: .png, mediaType: .png, fileExtension: "png") XCTAssertEqual(sut.sniffHints(fileExtension: "png"), png) diff --git a/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift b/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift index 614e70ee76..b655fa25ed 100644 --- a/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift +++ b/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift @@ -300,9 +300,10 @@ class MediaTypeTests: XCTestCase { XCTAssertTrue(MediaType("image/bmp")!.isBitmap) XCTAssertTrue(MediaType("image/gif")!.isBitmap) XCTAssertTrue(MediaType("image/jpeg")!.isBitmap) + XCTAssertTrue(MediaType("image/jxl")!.isBitmap) XCTAssertTrue(MediaType("image/png")!.isBitmap) XCTAssertTrue(MediaType("image/tiff")!.isBitmap) - XCTAssertTrue(MediaType("image/tiff")!.isBitmap) + XCTAssertTrue(MediaType("image/webp")!.isBitmap) XCTAssertTrue(MediaType("image/tiff;charset=utf-8")!.isBitmap) } From ba12d2fc5402ff5fcc73563dd149490e82eb3a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 27 Jan 2026 17:24:52 +0100 Subject: [PATCH 24/55] Fix potential race condition in the EPUB navigator (#704) --- .github/workflows/checks.yml | 44 ++++++++++++--------- Sources/Navigator/EPUB/EPUBSpreadView.swift | 13 ++++-- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 71ec1b1392..73e36c597f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -26,6 +26,8 @@ jobs: run: | brew update brew install xcodegen + # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + xcrun simctl list - name: Check Carthage project run: | # Check that the Carthage project is up to date. @@ -33,7 +35,6 @@ jobs: git diff --exit-code Support/Carthage/Readium.xcodeproj - name: Build run: | - xcrun simctl list set -eo pipefail xcodebuild build-for-testing -scheme "$scheme" -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi - name: Test @@ -41,23 +42,24 @@ jobs: set -eo pipefail xcodebuild test-without-building -scheme "$scheme" -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi - navigator-ui-tests: - name: Navigator UI Tests - runs-on: macos-15 - if: ${{ !github.event.pull_request.draft }} - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Install dependencies - run: | - brew update - brew install xcodegen - - name: Test - run: | - set -eo pipefail - make navigator-ui-tests-project - xcrun simctl list - xcodebuild test -project Tests/NavigatorTests/UITests/NavigatorUITests.xcodeproj -scheme NavigatorTestHost -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi + # navigator-ui-tests: + # name: Navigator UI Tests + # runs-on: macos-15 + # if: ${{ !github.event.pull_request.draft }} + # steps: + # - name: Checkout + # uses: actions/checkout@v3 + # - name: Install dependencies + # run: | + # brew update + # brew install xcodegen + # # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + # xcrun simctl list + # - name: Test + # run: | + # set -eo pipefail + # make navigator-ui-tests-project + # xcodebuild test -project Tests/NavigatorTests/UITests/NavigatorUITests.xcodeproj -scheme NavigatorTestHost -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi lint: name: Lint @@ -108,6 +110,8 @@ jobs: run: | brew update brew install xcodegen + # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + xcrun simctl list - name: Generate project run: make dev lcp=${{ secrets.LCP_URL_SPM }} - name: Build @@ -136,6 +140,8 @@ jobs: run: | brew update brew install xcodegen + # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + xcrun simctl list - name: Generate project run: make spm lcp=${{ secrets.LCP_URL_SPM }} commit=$commit_sha - name: Build @@ -164,6 +170,8 @@ jobs: run: | brew update brew install xcodegen + # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + xcrun simctl list - name: Generate project run: make carthage lcp=${{ secrets.LCP_URL_CARTHAGE }} commit=$commit_sha - name: Build diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 414fdaf88c..2ee8e8f861 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -60,6 +60,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { private var activityIndicatorStopWorkItem: DispatchWorkItem? private(set) var isSpreadLoaded = false + private var spreadLoadTask: Task? required init( viewModel: EPUBNavigatorViewModel, @@ -101,6 +102,11 @@ class EPUBSpreadView: UIView, Loggable, PageView { /// Called when the spread view is removed from the view hierarchy, to /// clear pending operations and retain cycles. func clear() { + webView.stopLoading() + + spreadLoadTask?.cancel() + spreadLoadTask = nil + // Disable JS messages to break WKUserContentController reference. disableJSMessages() } @@ -161,9 +167,9 @@ class EPUBSpreadView: UIView, Loggable, PageView { log(.trace, "Evaluate script: \(script)") return await withCheckedContinuation { continuation in - webView.evaluateJavaScript(script) { res, error in + webView.evaluateJavaScript(script) { [weak self] res, error in if let error = error { - self.log(.error, error) + self?.log(.error, error) continuation.resume(returning: .failure(error)) } else { continuation.resume(returning: .success(res ?? ())) @@ -277,7 +283,8 @@ class EPUBSpreadView: UIView, Loggable, PageView { /// Called by the javascript code when the spread contents is fully loaded. /// The JS message `spreadLoaded` needs to be emitted by a subclass script, EPUBSpreadView's scripts don't. private func spreadDidLoad(_ body: Any) { - Task { @MainActor in + spreadLoadTask?.cancel() + spreadLoadTask = Task { @MainActor in isSpreadLoaded = true applySettings() await spreadDidLoad() From 0da893965f367850dfae1900e90e4eee8d98ddd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 27 Jan 2026 18:05:49 +0100 Subject: [PATCH 25/55] Redesign LCP dialog with shared localized strings (#706) --- .gitignore | 3 + .../Scripts/convert-thorium-localizations.js | 385 ++++++++++++++++++ CHANGELOG.md | 10 + Makefile | 23 +- Package.swift | 2 +- .../Base.lproj/LCPDialogViewController.xib | 151 ------- Sources/LCP/Authentications/LCPDialog.swift | 71 +++- .../LCPDialogAuthentication.swift | 7 +- .../LCPDialogViewController.swift | 205 ++-------- .../Resources/en.lproj/Localizable.strings | 57 +-- .../Resources/fr.lproj/Localizable.strings | 2 + .../Toolkit/ReadiumLCPLocalizedString.swift | 2 +- Support/Carthage/.xcodegen | 8 +- .../Readium.xcodeproj/project.pbxproj | 15 +- docs/Migration Guide.md | 38 +- 15 files changed, 558 insertions(+), 421 deletions(-) create mode 100644 BuildTools/Scripts/convert-thorium-localizations.js delete mode 100644 Sources/LCP/Authentications/Base.lproj/LCPDialogViewController.xib create mode 100644 Sources/LCP/Resources/fr.lproj/Localizable.strings diff --git a/.gitignore b/.gitignore index 0c5fcb4e6e..36ed6959c8 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ playground.xcworkspace ## IntelliJ out/ +## Claude Code +.claude +CLAUDE.md diff --git a/BuildTools/Scripts/convert-thorium-localizations.js b/BuildTools/Scripts/convert-thorium-localizations.js new file mode 100644 index 0000000000..c2cd4dcfd3 --- /dev/null +++ b/BuildTools/Scripts/convert-thorium-localizations.js @@ -0,0 +1,385 @@ +/** + * 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. + * + * This script converts the localized files from https://github.com/edrlab/thorium-locales/ + * into Apple .strings format. + */ + +const fs = require('fs'); +const fsPromises = fs.promises; +const path = require('path'); + +/** + * Configuration for a thorium-locales project. + */ +class LocaleConfig { + /** + * @param {string} folder - The folder name in thorium-locales + * @param {string} stripPrefix - Prefix to strip from JSON keys + * @param {string} outputPrefix - Prefix to add to output keys + * @param {string} outputFolder - Output folder for .lproj directories + * @param {string[]|null} [includePrefixes] - Optional prefixes to include (if null, include all) + */ + constructor({ folder, stripPrefix, outputPrefix, outputFolder, includePrefixes = null }) { + if (!folder || !stripPrefix || !outputPrefix || !outputFolder) { + throw new Error('LocaleConfig requires folder, stripPrefix, outputPrefix, and outputFolder'); + } + this.folder = folder; + this.stripPrefix = stripPrefix; + this.outputPrefix = outputPrefix; + this.outputFolder = outputFolder; + this.includePrefixes = includePrefixes; + } +} + +/** + * Configuration for each thorium-locales project. + * Add new projects here as needed. + */ +const PROJECTS = { + lcp: new LocaleConfig({ + folder: 'lcp', + stripPrefix: 'lcp.', + outputPrefix: 'ReadiumLCP.', + outputFolder: 'Sources/LCP/Resources', + includePrefixes: ['lcp.dialog'] + }) +}; + +/** + * Languages to process from thorium-locales. + * Only these languages will be included in the output. + */ +const LANGUAGES = ['en', 'fr', 'it']; + +// Parse arguments +const args = process.argv.slice(2); +const [inputFolder, outputFormat, ...projectNames] = args; + +// If no projects specified, process all configured projects +const projectsToProcess = projectNames.length > 0 + ? projectNames + : Object.keys(PROJECTS); + +/** + * Ends the script with the given error message. + */ +function fail(message) { + console.error(`Error: ${message}`); + process.exit(1); +} + +async function processLocales() { + for (const projectName of projectsToProcess) { + const config = PROJECTS[projectName]; + if (!config) { + fail(`Unknown project: ${projectName}. Available: ${Object.keys(PROJECTS).join(', ')}`); + } + + console.log(`\nProcessing project: ${projectName}`); + + const languageKeys = await loadLanguageKeys(inputFolder, config.folder); + + // Filter keys if includePrefixes is specified + if (config.includePrefixes) { + for (const [lang, keys] of languageKeys) { + const filteredKeys = {}; + for (const [key, value] of Object.entries(keys)) { + // Check if key starts with any of the allowed prefixes + // Also handle plural keys by checking the base key (before @suffix) + const baseKey = getBaseKey(key); + if (config.includePrefixes.some(prefix => baseKey.startsWith(prefix))) { + filteredKeys[key] = value; + } + } + languageKeys.set(lang, filteredKeys); + } + } + + const placeholderMappings = buildPlaceholderMappings(languageKeys); + + const writeForProject = (relativePath, content) => { + writeFile(config.outputFolder, relativePath, content); + }; + + for (const [lang, keys] of languageKeys) { + convert(lang, keys, config, writeForProject, placeholderMappings); + } + } +} + +processLocales().catch(err => fail(err.message)); + +/** + * Converter for Apple localized strings. + */ +function convertApple(lang, keys, config, write, placeholderMappings) { + const lproj = `${lang}.lproj`; + // Store both original key (for mapping lookup) and prefixed key (for output) + // Strip the configured prefix since the output prefix already indicates the context + const allEntries = Object.entries(keys).map(([key, value]) => + [key, config.outputPrefix + stripKeyPrefix(key, config.stripPrefix), value] + ); + + // Generate Localizable.strings + write(path.join(lproj, 'Localizable.strings'), generateAppleStrings(lang, allEntries, placeholderMappings)); +} + +/** + * Generates an Apple .strings file content from a list of [originalKey, prefixedKey, value] entries. + */ +function generateAppleStrings(lang, entries, placeholderMappings) { + const disclaimer = `DO NOT EDIT. File generated automatically from the ${lang} JSON strings of https://github.com/edrlab/thorium-locales/.`; + let output = `// ${disclaimer}\n\n`; + for (const [originalKey, prefixedKey, value] of entries) { + // Use original key (without prefix) to look up placeholder mapping + const baseKey = getBaseKey(originalKey); + const mapping = placeholderMappings[originalKey] || placeholderMappings[baseKey] || {}; + const escapedValue = escapeForAppleStrings(value); + const convertedValue = convertPlaceholders(escapedValue, mapping); + output += `"${convertKebabToCamelCase(prefixedKey)}" = "${convertedValue}";\n`; + } + + return output; +} + +const converters = { + apple: convertApple +}; + +if (!inputFolder || !outputFormat) { + console.error('Usage: node convert-thorium-localizations.js [project...]'); + console.error(''); + console.error('Arguments:'); + console.error(' input-folder Path to the cloned thorium-locales repository'); + console.error(' output-format Output format (apple)'); + console.error(' project Optional project name(s) to process (default: all)'); + console.error(''); + console.error(`Available projects: ${Object.keys(PROJECTS).join(', ')}`); + process.exit(1); +} + +const convert = converters[outputFormat]; +if (!convert) { + fail(`unrecognized output format: ${outputFormat}, try: ${Object.keys(converters).join(', ')}.`); +} + +/** + * Loads all JSON locale files from the specified folder and returns a Map of language -> keys. + */ +async function loadLanguageKeys(inputFolder, localeFolder) { + const languageKeys = new Map(); + const folderPath = path.join(inputFolder, localeFolder); + + if (!fs.existsSync(folderPath)) { + fail(`the ${localeFolder} folder was not found at ${folderPath}`); + } + + console.log(`Processing folder: ${localeFolder}`); + + const files = await fsPromises.readdir(folderPath); + + for (const file of files) { + if (path.extname(file) !== '.json') continue; + + // Normalize locale to BCP 47 format (hyphens) to merge keys from + // files like pt_PT.json and pt-PT.json into a single locale entry. + const lang = path.basename(file, '.json').replace(/_/g, '-'); + + // Skip languages not in the allowed list + if (!LANGUAGES.includes(lang)) { + continue; + } + + const filePath = path.join(folderPath, file); + + try { + const data = await fsPromises.readFile(filePath, 'utf8'); + const jsonData = JSON.parse(data); + const keys = parseJsonKeys(jsonData); + + if (!languageKeys.has(lang)) { + languageKeys.set(lang, {}); + } + Object.assign(languageKeys.get(lang), keys); + } catch (err) { + fail(`processing ${file}: ${err.message}`); + } + } + + return languageKeys; +} + +/** + * Builds placeholder-to-position mappings from English keys. + * This ensures consistent argument ordering across all languages. + */ +function buildPlaceholderMappings(languageKeys) { + const placeholderMappings = {}; + const englishKeys = languageKeys.get('en'); + + if (englishKeys) { + for (const [key, value] of Object.entries(englishKeys)) { + const mapping = extractPlaceholderMapping(value); + if (Object.keys(mapping).length > 0) { + // For pluralized keys (ending with @one, @other, etc.), use the base key for mapping + const baseKey = getBaseKey(key); + // Only set if not already set (first plural form encountered sets the mapping) + if (!placeholderMappings[baseKey]) { + placeholderMappings[baseKey] = mapping; + } + } + } + } + + return placeholderMappings; +} + +/** + * Writes the given content to the file path relative to the specified base folder. + */ +function writeFile(baseFolder, relativePath, content) { + const outputPath = path.join(baseFolder, relativePath); + const outputDir = path.dirname(outputPath); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(outputPath, content, 'utf8'); + console.log(`Wrote ${outputPath}`); +} + +/** + * Strips the plural suffix from a key (e.g., @one, @other). + */ +function getBaseKey(key) { + return key.replace(/@(zero|one|two|few|many|other)$/, ''); +} + +/** + * Strips a prefix from a key. + */ +function stripKeyPrefix(key, prefix) { + return key.startsWith(prefix) ? key.slice(prefix.length) : key; +} + +/** + * Recursively collects the JSON translation keys using dot notation and special handling for pluralization patterns. + * Plural keys use flat format with underscore suffix (e.g., key_one, key_other) which are converted to @ suffix. + */ +function parseJsonKeys(obj, prefix = '') { + const keys = {}; + const pluralSuffixes = ['_zero', '_one', '_two', '_few', '_many', '_other']; + + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (typeof value === 'object' && value !== null) { + // Recursively process nested objects + Object.assign(keys, parseJsonKeys(value, fullKey)); + } else { + // Check for plural suffix and convert underscore to @ notation + const pluralSuffix = pluralSuffixes.find(suffix => fullKey.endsWith(suffix)); + if (pluralSuffix) { + const baseKey = fullKey.slice(0, -pluralSuffix.length); + const pluralForm = pluralSuffix.slice(1); // Remove leading underscore + keys[`${baseKey}@${pluralForm}`] = value; + } else { + // Simple key-value pair + keys[fullKey] = value; + } + } + } + + return keys; +} + +function convertKebabToCamelCase(string) { + return string + .split('-') + .map((word, index) => { + if (index === 0) { + return word; + } + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(''); +} + +/** + * Extracts placeholders from a string and builds a mapping from placeholder name to position index. + * Returns an object with placeholder names as keys and their positional index (1-based) as values. + * Placeholders are assigned positions based on their order of appearance. + */ +function extractPlaceholderMapping(value) { + const placeholderRegex = /\{\{\s*(\w+)\s*\}\}/g; + const placeholders = []; + let match; + + while ((match = placeholderRegex.exec(value)) !== null) { + const name = match[1]; + if (!placeholders.includes(name)) { + placeholders.push(name); + } + } + + if (placeholders.length === 0) { + return {}; + } + + const mapping = {}; + let position = 1; + for (const name of placeholders) { + mapping[name] = position++; + } + + return mapping; +} + +/** + * Escapes special characters for iOS .strings format. + * Must be called before placeholder conversion. + * + * - \ -> \\ + * - " -> \" + * - newlines -> \n + * - % -> %% + */ +function escapeForAppleStrings(value) { + return value + // Escape backslashes first (before other escapes add more backslashes) + .replace(/\\/g, '\\\\') + // Escape double quotes + .replace(/"/g, '\\"') + // Escape newlines + .replace(/\n/g, '\\n') + // Escape literal % characters + .replace(/%/g, '%%'); +} + +/** + * Converts Mustache-style placeholders to iOS format specifiers using the provided mapping. + * Placeholders become `%N$@` (string format) or `%N$d` (integer format) for `count`. + * Using %d for `count` allows the GenerateLocalizedUserString.swift script to identify the + * count offset to resolve the pluralization form. + */ +function convertPlaceholders(value, placeholderMap) { + if (Object.keys(placeholderMap).length === 0) { + return value; + } + + return value.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name) => { + const position = placeholderMap[name]; + if (position === undefined) { + // Placeholder not in mapping, leave unchanged + return match; + } + + // Use %d for `count` placeholder, %@ for everything else + const formatSpec = (name === 'count') ? 'd' : '@'; + return `%${position}$${formatSpec}`; + }); +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad15fcc21..896e5ce449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,16 @@ All notable changes to this project will be documented in this file. Take a look * EPUBs with only bitmap images in the spine are now treated as Divina publications with fixed layout. * When an EPUB spine item is HTML with a bitmap image fallback (or vice versa), the image is preferred as the primary link. +### Changed + +* The iOS minimum deployment target is now iOS 15.0. + +#### LCP + +* The LCP dialog used by `LCPDialogAuthentication` has been redesigned. + * **Breaking:** The LCP dialog localization string keys have been renamed. If you overrode these strings in your app, you must update them. [See the migration guide](docs/Migration%20Guide.md) for the key mapping. +* LCP localized strings are now sourced from the [thorium-locales](https://github.com/edrlab/thorium-locales/) repository. Contributions are welcome on [Weblate](https://hosted.weblate.org/projects/thorium-reader/readium-lcp/). + ### Deprecated #### Streamer diff --git a/Makefile b/Makefile index 4cdeb32af6..3365907a24 100644 --- a/Makefile +++ b/Makefile @@ -7,11 +7,12 @@ help: test\t\t\tRun unit tests\n\ lint-format\t\tVerify formatting\n\ format\t\tFormat sources\n\ - update-a11y-l10n\tUpdate the Accessibility Metadata Display Guide localization files\n\ + update-locales\tUpdate the localization files\n\ " .PHONY: carthage-project carthage-project: + rm -rf **/.DS_Store rm -rf $(SCRIPTS_PATH)/node_modules/ xcodegen -s Support/Carthage/project.yml --use-cache --cache-path Support/Carthage/.xcodegen @@ -45,11 +46,27 @@ f: format format: swift run --package-path BuildTools swiftformat . -.PHONY: update-a11y-l10n -update-a11y-l10n: +.PHONY: update-locales +update-locales: update-a11y-locales update-thorium-locales + +.PHONY: update-a11y-locales +update-a11y-locales: @which node >/dev/null 2>&1 || (echo "ERROR: node is required, please install it first"; exit 1) rm -rf publ-a11y-display-guide-localizations git clone https://github.com/w3c/publ-a11y-display-guide-localizations.git node BuildTools/Scripts/convert-a11y-display-guide-localizations.js publ-a11y-display-guide-localizations apple Sources/Shared readium.a11y. rm -rf publ-a11y-display-guide-localizations +BRANCH ?= main + +.PHONY: update-thorium-locales +update-thorium-locales: + @which node >/dev/null 2>&1 || (echo "ERROR: node is required, please install it first"; exit 1) +ifndef DIR + rm -rf thorium-locales + git clone -b $(BRANCH) --single-branch --depth 1 https://github.com/edrlab/thorium-locales.git +endif + node BuildTools/Scripts/convert-thorium-localizations.js thorium-locales apple +ifndef DIR + rm -rf thorium-locales +endif diff --git a/Package.swift b/Package.swift index 8ef42cf5d9..ba97b9dce4 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ import PackageDescription let package = Package( name: "Readium", defaultLocalization: "en", - platforms: [.iOS("13.4")], + platforms: [.iOS("15.0")], products: [ .library(name: "ReadiumShared", targets: ["ReadiumShared"]), .library(name: "ReadiumStreamer", targets: ["ReadiumStreamer"]), diff --git a/Sources/LCP/Authentications/Base.lproj/LCPDialogViewController.xib b/Sources/LCP/Authentications/Base.lproj/LCPDialogViewController.xib deleted file mode 100644 index 886be11452..0000000000 --- a/Sources/LCP/Authentications/Base.lproj/LCPDialogViewController.xib +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Sources/LCP/Authentications/LCPDialog.swift b/Sources/LCP/Authentications/LCPDialog.swift index f9d8e4a8d0..73a1a898c5 100644 --- a/Sources/LCP/Authentications/LCPDialog.swift +++ b/Sources/LCP/Authentications/LCPDialog.swift @@ -46,7 +46,6 @@ import SwiftUI /// } /// } /// ``` -@available(iOS 16.0, *) public struct LCPDialog: View { public enum ErrorMessage { case incorrectPassphrase @@ -54,7 +53,7 @@ public struct LCPDialog: View { var string: String { switch self { case .incorrectPassphrase: - ReadiumLCPLocalizedString("dialog.error.incorrectPassphrase") + ReadiumLCPLocalizedString("dialog.errors.incorrectPassphrase") } } } @@ -65,6 +64,7 @@ public struct LCPDialog: View { private let errorMessage: ErrorMessage? private let onSubmit: (String) -> Void private let onForgotPassphrase: (() -> Void)? + private let onCancel: (() -> Void)? private let openButtonId = "open" @@ -72,12 +72,14 @@ public struct LCPDialog: View { hint: String?, errorMessage: ErrorMessage?, onSubmit: @escaping (String) -> Void, - onForgotPassphrase: (() -> Void)? + onForgotPassphrase: (() -> Void)?, + onCancel: (() -> Void)? = nil ) { self.hint = hint self.errorMessage = errorMessage self.onSubmit = onSubmit self.onForgotPassphrase = onForgotPassphrase + self.onCancel = onCancel } public init( @@ -100,7 +102,7 @@ public struct LCPDialog: View { @State private var passphrase: String = "" public var body: some View { - NavigationStack { + NavigationView { ScrollViewReader { scrollProxy in Form { header @@ -120,17 +122,19 @@ public struct LCPDialog: View { } } } - .scrollDismissesKeyboard(.interactively) + .scrollDismissesKeyboardIfAvailable() .navigationTitle(ReadiumLCPLocalizedStringKey("dialog.title")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(ReadiumLCPLocalizedStringKey("dialog.cancel"), role: .cancel) { + Button(ReadiumLCPLocalizedStringKey("dialog.actions.cancel"), role: .cancel) { + onCancel?() dismiss() } } } } + .navigationViewStyle(.stack) } @ViewBuilder private var header: some View { @@ -142,27 +146,25 @@ public struct LCPDialog: View { .foregroundStyle(.blue) .font(.system(size: 70)) - Text(ReadiumLCPLocalizedStringKey("dialog.header")) + Text(ReadiumLCPLocalizedStringKey("dialog.message")) .multilineTextAlignment(.center) .padding(.bottom, 16) } Spacer() } - DisclosureGroup(ReadiumLCPLocalizedStringKey("dialog.details.title")) { + DisclosureGroup(ReadiumLCPLocalizedStringKey("dialog.info.title")) { VStack { - Text(ReadiumLCPLocalizedStringKey("dialog.details.body")) + Text(ReadiumLCPLocalizedStringKey("dialog.info.body")) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) - Text("[\(ReadiumLCPLocalizedString("dialog.details.more"))](https://www.edrlab.org/readium-lcp/)") + Text("[\(ReadiumLCPLocalizedString("dialog.info.more"))](https://www.edrlab.org/readium-lcp/)") .frame(maxWidth: .infinity, alignment: .leading) } } } - .alignmentGuide(.listRowSeparatorLeading) { _ in - 0 - } + .alignListRowSeparatorLeading() .font(.callout) } @@ -188,16 +190,20 @@ public struct LCPDialog: View { .font(.callout) } } + } footer: { + if let hint = hint { + Text(ReadiumLCPLocalizedStringKey("dialog.passphrase.hint", hint)) + } } .listRowSeparator(.hidden) } @ViewBuilder private var buttons: some View { Section { - Button(ReadiumLCPLocalizedStringKey("dialog.continue")) { + Button(ReadiumLCPLocalizedStringKey("dialog.actions.continue")) { submit() } - .bold() + .boldIfAvailable() .id(openButtonId) .disabled(passphrase.isEmpty) .frame(maxWidth: .infinity, alignment: .center) @@ -205,14 +211,10 @@ public struct LCPDialog: View { if let onForgotPassphrase = onForgotPassphrase { Section { - Button(ReadiumLCPLocalizedStringKey("dialog.forgotYourPassphrase"), role: .destructive) { + Button(ReadiumLCPLocalizedStringKey("dialog.actions.recoverPassphrase"), role: .destructive) { onForgotPassphrase() } .frame(maxWidth: .infinity, alignment: .center) - } footer: { - if let hint = hint { - Text(ReadiumLCPLocalizedStringKey("dialog.hint", hint)) - } } } } @@ -227,6 +229,35 @@ public struct LCPDialog: View { } } +private extension View { + @ViewBuilder + func scrollDismissesKeyboardIfAvailable() -> some View { + if #available(iOS 16.0, *) { + scrollDismissesKeyboard(.interactively) + } else { + self + } + } + + @ViewBuilder + func alignListRowSeparatorLeading() -> some View { + if #available(iOS 16.0, *) { + alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + } else { + self + } + } + + @ViewBuilder + func boldIfAvailable() -> some View { + if #available(iOS 16.0, *) { + bold() + } else { + self + } + } +} + #Preview { if #available(iOS 18.0, *) { Spacer().sheet(isPresented: .constant(true)) { diff --git a/Sources/LCP/Authentications/LCPDialogAuthentication.swift b/Sources/LCP/Authentications/LCPDialogAuthentication.swift index 56505b9724..43a1c2e7ac 100644 --- a/Sources/LCP/Authentications/LCPDialogAuthentication.swift +++ b/Sources/LCP/Authentications/LCPDialogAuthentication.swift @@ -42,11 +42,10 @@ public class LCPDialogAuthentication: LCPAuthenticating, Loggable { continuation.resume(returning: passphrase) } - let navController = UINavigationController(rootViewController: dialogViewController) - navController.modalPresentationStyle = modalPresentationStyle - navController.modalTransitionStyle = modalTransitionStyle + dialogViewController.modalPresentationStyle = modalPresentationStyle + dialogViewController.modalTransitionStyle = modalTransitionStyle - viewController.present(navController, animated: animated) + viewController.present(dialogViewController, animated: animated) } } } diff --git a/Sources/LCP/Authentications/LCPDialogViewController.swift b/Sources/LCP/Authentications/LCPDialogViewController.swift index 65239de8b0..ee32b59ef3 100644 --- a/Sources/LCP/Authentications/LCPDialogViewController.swift +++ b/Sources/LCP/Authentications/LCPDialogViewController.swift @@ -4,108 +4,56 @@ // available in the top-level LICENSE file of the project. // -import SafariServices +import SwiftUI import UIKit final class LCPDialogViewController: UIViewController { - @IBOutlet var scrollView: UIScrollView! - @IBOutlet var hintLabel: UILabel! - @IBOutlet var promptLabel: UILabel! - @IBOutlet var messageLabel: UILabel! - @IBOutlet var passphraseField: UITextField! - @IBOutlet var supportButton: UIButton! - @IBOutlet var forgotPassphraseButton: UIButton! - @IBOutlet var continueButton: UIButton! - private let license: LCPAuthenticatedLicense private let reason: LCPAuthenticationReason private let completion: (String?) -> Void - private let supportLinks: [(Link, URL)] init(license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, completion: @escaping (String?) -> Void) { self.license = license self.reason = reason self.completion = completion - supportLinks = license.supportLinks - .compactMap { link -> (Link, URL)? in - guard let url = URL(string: link.href), UIApplication.shared.canOpenURL(url) else { - return nil - } - return (link, url) - } + super.init(nibName: nil, bundle: nil) - super.init(nibName: nil, bundle: Bundle.module) - - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + isModalInPresentation = true } @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - deinit { - NotificationCenter.default.removeObserver(self) - } - override func viewDidLoad() { super.viewDidLoad() - if #available(iOS 13.0, *) { - // Prevents swipe down to dismiss the dialog on iOS 13+ - isModalInPresentation = true - } - - var provider = license.document.provider - if let providerHost = URL(string: provider)?.host { - provider = providerHost - } - - supportButton.isHidden = supportLinks.isEmpty - - let label = UILabel() - - switch reason { - case .passphraseNotFound: - label.text = ReadiumLCPLocalizedString("dialog.reason.passphraseNotFound") - case .invalidPassphrase: - label.text = ReadiumLCPLocalizedString("dialog.reason.invalidPassphrase") - passphraseField.layer.borderWidth = 1 - passphraseField.layer.borderColor = UIColor.red.cgColor - } - - label.sizeToFit() - if #available(iOS 13.0, *) { - label.textColor = .label - navigationController?.navigationBar.backgroundColor = .systemBackground - } - - let leftItem = UIBarButtonItem(customView: label) - navigationItem.leftBarButtonItem = leftItem - - promptLabel.text = ReadiumLCPLocalizedString("dialog.prompt.message1") - messageLabel.text = String(format: ReadiumLCPLocalizedString("dialog.prompt.message2"), provider) - forgotPassphraseButton.setTitle(ReadiumLCPLocalizedString("dialog.prompt.forgotPassphrase"), for: .normal) - supportButton.setTitle(ReadiumLCPLocalizedString("dialog.prompt.support"), for: .normal) - continueButton.setTitle(ReadiumLCPLocalizedString("dialog.prompt.continue"), for: .normal) - passphraseField.placeholder = ReadiumLCPLocalizedString("dialog.prompt.passphrase") - hintLabel.text = license.hint - - navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .cancel, - target: self, - action: #selector(LCPDialogViewController.cancel(_:)) + let dialog = LCPDialog( + hint: license.hint.orNilIfBlank(), + errorMessage: reason == .invalidPassphrase ? .incorrectPassphrase : nil, + onSubmit: { [weak self] passphrase in + self?.complete(with: passphrase) + }, + onForgotPassphrase: license.hintLink?.url().map { url in + { UIApplication.shared.open(url.url) } + }, + onCancel: { [weak self] in + self?.complete(with: nil) + } ) - } - - @IBAction func authenticate(_ sender: Any) { - let passphrase = passphraseField.text ?? "" - complete(with: passphrase) - } - @IBAction func cancel(_ sender: Any) { - complete(with: nil) + let hostingController = UIHostingController(rootView: dialog) + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + hostingController.didMove(toParent: self) } private var isCompleted = false @@ -118,103 +66,4 @@ final class LCPDialogViewController: UIViewController { completion(passphrase) dismiss(animated: true) } - - @IBAction func showSupportLink(_ sender: Any) { - guard !supportLinks.isEmpty else { - return - } - - func open(_ url: URL) { - UIApplication.shared.open(url) - } - - if let (_, url) = supportLinks.first, supportLinks.count == 1 { - open(url) - return - } - - let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - for (link, url) in supportLinks { - let title: String = { - if let title = link.title { - return title - } - if let scheme = url.scheme { - switch scheme { - case "http", "https": - return ReadiumLCPLocalizedString("dialog.support.website") - case "tel": - return ReadiumLCPLocalizedString("dialog.support.phone") - case "mailto": - return ReadiumLCPLocalizedString("dialog.support.mail") - default: - break - } - } - return ReadiumLCPLocalizedString("dialog.support") - }() - - let action = UIAlertAction(title: title, style: .default) { _ in - open(url) - } - alert.addAction(action) - } - alert.addAction(UIAlertAction(title: ReadiumLCPLocalizedString("dialog.cancel"), style: .cancel)) - - if let popover = alert.popoverPresentationController, let sender = sender as? UIView { - popover.sourceView = sender - var rect = sender.bounds - rect.origin.x = sender.center.x - 1 - rect.size.width = 2 - popover.sourceRect = rect - } - present(alert, animated: true) - } - - @IBAction func showHintLink(_ sender: Any) { - guard let href = license.hintLink?.href, let url = URL(string: href) else { - return - } - - let browser = SFSafariViewController(url: url) - browser.modalPresentationStyle = .currentContext - present(browser, animated: true) - } - - /// Makes sure the form contents is scrollable when the keyboard is visible. - @objc func keyboardWillChangeFrame(_ note: Notification) { - guard - let window = view.window, - let scrollView = scrollView, - let scrollViewSuperview = scrollView.superview, - let info = note.userInfo - else { - return - } - - var keyboardHeight: CGFloat = 0 - if note.name == UIResponder.keyboardWillChangeFrameNotification { - guard let keyboardFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { - return - } - keyboardHeight = keyboardFrame.height - } - - // Calculates the scroll view offsets in the coordinate space of of our window - let scrollViewFrame = scrollViewSuperview.convert(scrollView.frame, to: window) - - var contentInset = scrollView.contentInset - // Bottom inset is the part of keyboard that is covering the tableView - contentInset.bottom = keyboardHeight - (window.frame.height - scrollViewFrame.height - scrollViewFrame.origin.y) + 16 - - self.scrollView.contentInset = contentInset - self.scrollView.scrollIndicatorInsets = contentInset - } -} - -extension LCPDialogViewController: UITextFieldDelegate { - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - authenticate(textField) - return false - } } diff --git a/Sources/LCP/Resources/en.lproj/Localizable.strings b/Sources/LCP/Resources/en.lproj/Localizable.strings index 8e15d16ff9..e2ae1f828f 100644 --- a/Sources/LCP/Resources/en.lproj/Localizable.strings +++ b/Sources/LCP/Resources/en.lproj/Localizable.strings @@ -1,44 +1,13 @@ -/* - Copyright 2025 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. -*/ - -/* LCP Dialog Authentication */ - -"ReadiumLCP.dialog.title" = "Enter Password"; -"ReadiumLCP.dialog.cancel" = "Cancel"; -"ReadiumLCP.dialog.continue" = "Continue"; -"ReadiumLCP.dialog.forgotYourPassphrase" = "Forgot Your Password?"; -"ReadiumLCP.dialog.hint" = "**Hint:** %@"; -"ReadiumLCP.dialog.header" = "This publication requires an LCP password to open, which is provided by your library or bookstore. Enter it once, and you're all set to read on this device."; -"ReadiumLCP.dialog.details.title" = "What is LCP?"; -"ReadiumLCP.dialog.details.body" = "This publication is protected by LCP (Licensed Content Protection), a DRM technology that prevents unauthorized copying while keeping your reading experience simple. LCP is an open standard that balances user-friendliness with the needs of publishers."; -"ReadiumLCP.dialog.details.more" = "Learn more…"; -"ReadiumLCP.dialog.passphrase.placeholder" = "Password"; -"ReadiumLCP.dialog.error.incorrectPassphrase" = "Incorrect password."; - -// MARK: - Legacy strings (LCPDialogViewController) - -/* Prompt messages when asking for the passphrase */ -"ReadiumLCP.dialog.prompt.message1" = "This publication is protected by Readium LCP."; -"ReadiumLCP.dialog.prompt.message2" = "In order to open it, we need to know the passphrase required by:\n\n%@\n\nTo help you remember it, the following hint is available:"; -/* Reason to ask for the passphrase when it was not found */ -"ReadiumLCP.dialog.reason.passphraseNotFound" = "Passphrase Required"; -/* Reason to ask for the passphrase when the one entered was incorrect */ -"ReadiumLCP.dialog.reason.invalidPassphrase" = "Incorrect Passphrase"; -/* Forgot passphrase button */ -"ReadiumLCP.dialog.prompt.forgotPassphrase" = "Forgot your passphrase?"; -/* Support button */ -"ReadiumLCP.dialog.prompt.support" = "Need more help?"; -/* Continue button */ -"ReadiumLCP.dialog.prompt.continue" = "Continue"; -/* Passphrase placeholder */ -"ReadiumLCP.dialog.prompt.passphrase" = "Passphrase"; - -/* Button to contact the support when entering the passphrase */ -"ReadiumLCP.dialog.support" = "Support"; -"ReadiumLCP.dialog.support.website" = "Website"; -"ReadiumLCP.dialog.support.phone" = "Phone"; -"ReadiumLCP.dialog.support.mail" = "Mail"; - +// DO NOT EDIT. File generated automatically from the en JSON strings of https://github.com/edrlab/thorium-locales/. + +"readium.lcp.dialog.actions.cancel" = "Cancel"; +"readium.lcp.dialog.actions.continue" = "Continue"; +"readium.lcp.dialog.actions.recoverPassphrase" = "Forgot Your Password?"; +"readium.lcp.dialog.errors.incorrectPassphrase" = "Incorrect password."; +"readium.lcp.dialog.info.body" = "This publication is protected by LCP (Licensed Content Protection), a DRM technology that prevents unauthorized copying while keeping your reading experience simple. LCP is an open standard that balances user-friendliness with the needs of publishers."; +"readium.lcp.dialog.info.more" = "Learn more…"; +"readium.lcp.dialog.info.title" = "What is LCP?"; +"readium.lcp.dialog.message" = "This publication requires an LCP password to open, which is provided by your library or bookstore. Enter it once, and you're all set to read on this device."; +"readium.lcp.dialog.passphrase.hint" = "**Hint:** %1$@"; +"readium.lcp.dialog.passphrase.placeholder" = "Password"; +"readium.lcp.dialog.title" = "Enter Password"; diff --git a/Sources/LCP/Resources/fr.lproj/Localizable.strings b/Sources/LCP/Resources/fr.lproj/Localizable.strings new file mode 100644 index 0000000000..2538448017 --- /dev/null +++ b/Sources/LCP/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,2 @@ +// DO NOT EDIT. File generated automatically from the fr JSON strings of https://github.com/edrlab/thorium-locales/. + diff --git a/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift b/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift index 2c6ada6d42..b8894b94dd 100644 --- a/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift +++ b/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift @@ -13,7 +13,7 @@ func ReadiumLCPLocalizedString(_ key: String, _ values: CVarArg...) -> String { } func ReadiumLCPLocalizedString(_ key: String, _ values: [CVarArg]) -> String { - ReadiumLocalizedString("ReadiumLCP.\(key)", in: Bundle.module, values) + ReadiumLocalizedString("readium.lcp.\(key)", in: Bundle.module, values) } func ReadiumLCPLocalizedStringKey(_ key: String, _ values: CVarArg...) -> LocalizedStringKey { diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 925b48de9f..3c5e460dfd 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -331,8 +331,6 @@ ../../Sources/Internal/UTI.swift ../../Sources/LCP ../../Sources/LCP/Authentications -../../Sources/LCP/Authentications/Base.lproj -../../Sources/LCP/Authentications/Base.lproj/LCPDialogViewController.xib ../../Sources/LCP/Authentications/LCPAuthenticating.swift ../../Sources/LCP/Authentications/LCPDialog.swift ../../Sources/LCP/Authentications/LCPDialogAuthentication.swift @@ -379,6 +377,8 @@ ../../Sources/LCP/Resources ../../Sources/LCP/Resources/en.lproj ../../Sources/LCP/Resources/en.lproj/Localizable.strings +../../Sources/LCP/Resources/fr.lproj +../../Sources/LCP/Resources/fr.lproj/Localizable.strings ../../Sources/LCP/Resources/prod-license.lcpl ../../Sources/LCP/Services ../../Sources/LCP/Services/CRLService.swift @@ -391,7 +391,6 @@ ../../Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift ../../Sources/LCP/Toolkit/Streamable.swift ../../Sources/Navigator -../../Sources/Navigator/.DS_Store ../../Sources/Navigator/Audiobook ../../Sources/Navigator/Audiobook/AudioNavigator.swift ../../Sources/Navigator/Audiobook/Preferences @@ -412,14 +411,12 @@ ../../Sources/Navigator/EPUB/Assets/fxl-spread-one.html ../../Sources/Navigator/EPUB/Assets/fxl-spread-two.html ../../Sources/Navigator/EPUB/Assets/Static -../../Sources/Navigator/EPUB/Assets/Static/.DS_Store ../../Sources/Navigator/EPUB/Assets/Static/fonts ../../Sources/Navigator/EPUB/Assets/Static/fonts/OpenDyslexic-Bold.otf ../../Sources/Navigator/EPUB/Assets/Static/fonts/OpenDyslexic-BoldItalic.otf ../../Sources/Navigator/EPUB/Assets/Static/fonts/OpenDyslexic-Italic.otf ../../Sources/Navigator/EPUB/Assets/Static/fonts/OpenDyslexic-Regular.otf ../../Sources/Navigator/EPUB/Assets/Static/readium-css -../../Sources/Navigator/EPUB/Assets/Static/readium-css/.DS_Store ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css @@ -539,7 +536,6 @@ ../../Sources/Navigator/Input/Pointer/PointerEvent.swift ../../Sources/Navigator/Navigator.swift ../../Sources/Navigator/PDF -../../Sources/Navigator/PDF/.DS_Store ../../Sources/Navigator/PDF/PDFDocumentHolder.swift ../../Sources/Navigator/PDF/PDFDocumentView.swift ../../Sources/Navigator/PDF/PDFNavigatorViewController.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 15bc6bad1d..97a805b45e 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -375,7 +375,6 @@ ED67A0EFAE830F72846BF9C0 /* Range.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231F989F7D7E560DD5364B9 /* Range.swift */; }; EDDAB394E312B7A7AE5BB758 /* Publication+OPDS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB5D42EEF0083D833E2A572 /* Publication+OPDS.swift */; }; EE16BE486539BC9B3C3C6896 /* ReadiumInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42FD63C2720614E558522675 /* ReadiumInternal.framework */; }; - EE951A131E38E316BF7A1129 /* LCPDialogViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = ED5C6546C24E5E619E4CC9D1 /* LCPDialogViewController.xib */; }; EF15E9163EBC82672B22F6E0 /* ImageParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37087C0D0B36FE7F20F1C891 /* ImageParser.swift */; }; EF26968E6A2087142F5334AF /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C94659A8749299DBE3628D /* HTTPClient.swift */; }; EF2BE6AFC79525FD9760CD9B /* OPDSPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C22408FE1FA81400DE8D5F7 /* OPDSPrice.swift */; }; @@ -508,6 +507,7 @@ 06C4BDFF128C774BCD660419 /* ReadiumCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumCSS.swift; sourceTree = ""; }; 07B5469E40752E598C070E5B /* OPDSParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSParser.swift; sourceTree = ""; }; 0812058DB4FBFDF0A862E57E /* KeyModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyModifiers.swift; sourceTree = ""; }; + 0885992D0F70AD0B493985CE /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 0918DA360AAB646144E435D5 /* TransformingContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransformingContainer.swift; sourceTree = ""; }; 093629E752DE17264B97C598 /* LCPLicense.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPLicense.swift; sourceTree = ""; }; 0977FA3A6BDEDE2F91A7C444 /* BitmapFormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitmapFormatSniffer.swift; sourceTree = ""; }; @@ -658,7 +658,6 @@ 72922E22040CEFB3B7BBCDAF /* LoggerStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerStub.swift; sourceTree = ""; }; 739566E777BA37891BCECB95 /* Streamable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Streamable.swift; sourceTree = ""; }; 74F646B746EB27124F9456F8 /* ReadingProgression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgression.swift; sourceTree = ""; }; - 75DFA22C741A09C81E23D084 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LCPDialogViewController.xib; sourceTree = ""; }; 761D7DFCF307078B7283A14E /* TextTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextTokenizer.swift; sourceTree = ""; }; 76638D3D1220E4C2620B9A80 /* Properties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Properties.swift; sourceTree = ""; }; 76E46B10FD5B26A2F41718E0 /* EPUBMetadataParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBMetadataParser.swift; sourceTree = ""; }; @@ -1575,7 +1574,6 @@ A02248F01B66C1151614EF15 /* LCPDialog.swift */, 77392C999C0EFF83C8F2A47F /* LCPDialogAuthentication.swift */, 0BB64178365BFA9ED75C7078 /* LCPDialogViewController.swift */, - ED5C6546C24E5E619E4CC9D1 /* LCPDialogViewController.xib */, 791CEAC3DA5ED971DAE984CB /* LCPObservableAuthentication.swift */, 1D5053C2151DDDE4E8F06513 /* LCPPassphraseAuthentication.swift */, ); @@ -2307,6 +2305,7 @@ Base, en, "en-US", + fr, ); mainGroup = 2C63ECC3CC1230CCA416F55F; minimizedProjectReferenceProxies = 1; @@ -2331,7 +2330,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - EE951A131E38E316BF7A1129 /* LCPDialogViewController.xib in Resources */, D7FB0CC13190A17DAB7D7DB1 /* Localizable.strings in Resources */, F96C29471F3EF0CEE568AA53 /* prod-license.lcpl in Resources */, ); @@ -2859,18 +2857,11 @@ isa = PBXVariantGroup; children = ( B7C9D54352714641A87F64A0 /* en */, + 0885992D0F70AD0B493985CE /* fr */, ); name = Localizable.strings; sourceTree = ""; }; - ED5C6546C24E5E619E4CC9D1 /* LCPDialogViewController.xib */ = { - isa = PBXVariantGroup; - children = ( - 75DFA22C741A09C81E23D084 /* Base */, - ); - name = LCPDialogViewController.xib; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index 1ee37a540a..41473cfb59 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -2,7 +2,43 @@ All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file. - +## Unreleased + +### LCP Dialog Localization Keys + +The LCP dialog localization string keys have been renamed to align with the [thorium-locales](https://github.com/edrlab/thorium-locales/) repository. Contributions are welcome on [Weblate](https://hosted.weblate.org/projects/thorium-reader/readium-lcp/). + +If you overrode any of these strings in your app's `Localizable.strings`, you must update them to use the new keys: + +| Old Key | New Key | +|-----------------------------------------------|-------------------------------------------------| +| `ReadiumLCP.dialog.cancel` | `readium.lcp.dialog.actions.cancel` | +| `ReadiumLCP.dialog.continue` | `readium.lcp.dialog.actions.continue` | +| `ReadiumLCP.dialog.forgotYourPassphrase` | `readium.lcp.dialog.actions.recoverPassphrase` | +| `ReadiumLCP.dialog.hint` | `readium.lcp.dialog.passphrase.hint` | +| `ReadiumLCP.dialog.header` | `readium.lcp.dialog.message` | +| `ReadiumLCP.dialog.details.title` | `readium.lcp.dialog.info.title` | +| `ReadiumLCP.dialog.details.body` | `readium.lcp.dialog.info.body` | +| `ReadiumLCP.dialog.details.more` | `readium.lcp.dialog.info.more` | +| `ReadiumLCP.dialog.error.incorrectPassphrase` | `readium.lcp.dialog.errors.incorrectPassphrase` | +| `ReadiumLCP.dialog.title` | `readium.lcp.dialog.title` | +| `ReadiumLCP.dialog.passphrase.placeholder` | `readium.lcp.dialog.passphrase.placeholder` | + +The following legacy strings from the old UIKit-based dialog have been removed entirely: + +* `ReadiumLCP.dialog.prompt.message1` +* `ReadiumLCP.dialog.prompt.message2` +* `ReadiumLCP.dialog.reason.passphraseNotFound` +* `ReadiumLCP.dialog.reason.invalidPassphrase` +* `ReadiumLCP.dialog.prompt.forgotPassphrase` +* `ReadiumLCP.dialog.prompt.support` +* `ReadiumLCP.dialog.prompt.continue` +* `ReadiumLCP.dialog.prompt.passphrase` +* `ReadiumLCP.dialog.support` +* `ReadiumLCP.dialog.support.website` +* `ReadiumLCP.dialog.support.phone` +* `ReadiumLCP.dialog.support.mail` + ## 3.3.0 From 1e423c98609c7fb7da5cd4b2454f2c7dc77d21d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 27 Jan 2026 18:45:52 +0100 Subject: [PATCH 26/55] Fix build warnings (#707) --- Sources/Internal/UTI.swift | 67 ++++++++++++------- Sources/Shared/Toolkit/DocumentTypes.swift | 4 +- .../Toolkit/File/DirectoryContainer.swift | 2 +- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/Sources/Internal/UTI.swift b/Sources/Internal/UTI.swift index b72fb381da..c6bc72be70 100644 --- a/Sources/Internal/UTI.swift +++ b/Sources/Internal/UTI.swift @@ -4,56 +4,77 @@ // available in the top-level LICENSE file of the project. // -import CoreServices import Foundation +import UniformTypeIdentifiers /// Uniform Type Identifier. -public struct UTI: ExpressibleByStringLiteral { - /// Type tag class, eg. kUTTagClassMIMEType. +public struct UTI { + /// Type tag class, eg. UTTagClass.mimeType. public enum TagClass { case mediaType, fileExtension + } - var rawString: CFString { - switch self { - case .mediaType: - return kUTTagClassMIMEType - case .fileExtension: - return kUTTagClassFilenameExtension - } - } + public let type: UTType + + public init(type: UTType) { + self.type = type } - public let string: String + public init?(_ identifier: String) { + guard let type = UTType(identifier) else { + return nil + } + self.init(type: type) + } - public init(stringLiteral value: StringLiteralType) { - string = value + public init?(mediaType: String) { + guard let type = UTType(mimeType: mediaType) else { + return nil + } + self.init(type: type) } - public var name: String? { - UTTypeCopyDescription(string as CFString)?.takeRetainedValue() as String? + public init?(fileExtension: String) { + guard let type = UTType(filenameExtension: fileExtension) else { + return nil + } + self.init(type: type) } + public var name: String? { type.localizedDescription } + + public var string: String { type.identifier } + /// Returns the preferred tag for this `UTI`, with the given type `tagClass`. public func preferredTag(withClass tagClass: TagClass) -> String? { - UTTypeCopyPreferredTagWithClass(string as CFString, tagClass.rawString)?.takeRetainedValue() as String? + switch tagClass { + case .mediaType: + return type.preferredMIMEType + case .fileExtension: + return type.preferredFilenameExtension + } } /// Returns all tags for this `UTI`, with the given type `tagClass`. public func tags(withClass tagClass: TagClass) -> [String] { - UTTypeCopyAllTagsWithClass(string as CFString, tagClass.rawString)?.takeRetainedValue() as? [String] - ?? [] + switch tagClass { + case .mediaType: + return type.tags[.mimeType] ?? [] + case .fileExtension: + return type.tags[.filenameExtension] ?? [] + } } /// Finds the first `UTI` recognizing any of the given `mediaTypes` or `fileExtensions`. public static func findFrom(mediaTypes: [String], fileExtensions: [String]) -> UTI? { for mediaType in mediaTypes { - if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mediaType as CFString, nil)?.takeUnretainedValue() { - return UTI(stringLiteral: uti as String) + if let uti = UTI(mediaType: mediaType) { + return uti } } for fileExtension in fileExtensions { - if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension as CFString, nil)?.takeUnretainedValue() { - return UTI(stringLiteral: uti as String) + if let uti = UTI(fileExtension: fileExtension) { + return uti } } return nil diff --git a/Sources/Shared/Toolkit/DocumentTypes.swift b/Sources/Shared/Toolkit/DocumentTypes.swift index 0096d65cb7..b90ad6d32c 100644 --- a/Sources/Shared/Toolkit/DocumentTypes.swift +++ b/Sources/Shared/Toolkit/DocumentTypes.swift @@ -51,7 +51,7 @@ public struct DocumentTypes { .flatMap(\.utis) .removingDuplicates() - let utis = supportedUTIs.map(UTI.init(stringLiteral:)) + let utis = supportedUTIs.compactMap { UTI($0) } let utisMediaTypes = utis .flatMap { $0.tags(withClass: .mediaType) } @@ -127,7 +127,7 @@ public struct DocumentType: Equatable, Loggable { self.name = name self.utis = (dictionary["LSItemContentTypes"] as? [String] ?? []) - let utis = utis.map(UTI.init(stringLiteral:)) + let utis = utis.compactMap { UTI($0) } let fileExtensions = utis.flatMap { $0.tags(withClass: .fileExtension) } + diff --git a/Sources/Shared/Toolkit/File/DirectoryContainer.swift b/Sources/Shared/Toolkit/File/DirectoryContainer.swift index d8c428f0b0..ad5e3c93cc 100644 --- a/Sources/Shared/Toolkit/File/DirectoryContainer.swift +++ b/Sources/Shared/Toolkit/File/DirectoryContainer.swift @@ -42,7 +42,7 @@ public struct DirectoryContainer: Container, Loggable { includingPropertiesForKeys: [.isRegularFileKey], options: options ) { - for case let url as URL in enumerator { + while let url = enumerator.nextObject() as? URL { do { let fileAttributes = try url.resourceValues(forKeys: [.isRegularFileKey]) if fileAttributes.isRegularFile == true, let entry = directory.relativize(url.anyURL) { From 4ce8051ee823026c12f32cdab9ecbe97f5671547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 28 Jan 2026 13:46:34 +0100 Subject: [PATCH 27/55] Generate `AccessibilityDisplayString` from `thorium-locales` (#708) --- ...onvert-a11y-display-guide-localizations.js | 177 ------ .../Scripts/convert-thorium-localizations.js | 597 +++++++++--------- .../Scripts/generate-a11y-extensions.js | 68 ++ CHANGELOG.md | 4 + Makefile | 18 +- ...AccessibilityDisplayString+Generated.swift | 115 ++-- .../AccessibilityMetadataDisplayGuide.swift | 25 +- ...CAccessibilityMetadataDisplayGuide.strings | 240 +++---- ...CAccessibilityMetadataDisplayGuide.strings | 129 ++++ ...CAccessibilityMetadataDisplayGuide.strings | 129 ++++ Support/Carthage/.xcodegen | 8 +- .../Readium.xcodeproj/project.pbxproj | 22 +- ...cessibilityMetadataDisplayGuideTests.swift | 34 +- 13 files changed, 876 insertions(+), 690 deletions(-) delete mode 100644 BuildTools/Scripts/convert-a11y-display-guide-localizations.js create mode 100644 BuildTools/Scripts/generate-a11y-extensions.js rename Sources/Shared/Resources/{en-US.lproj => en.lproj}/W3CAccessibilityMetadataDisplayGuide.strings (52%) create mode 100644 Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings create mode 100644 Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings diff --git a/BuildTools/Scripts/convert-a11y-display-guide-localizations.js b/BuildTools/Scripts/convert-a11y-display-guide-localizations.js deleted file mode 100644 index 9628a342dc..0000000000 --- a/BuildTools/Scripts/convert-a11y-display-guide-localizations.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Copyright 2025 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. - * - * This script can be used to convert the localized files from https://github.com/w3c/publ-a11y-display-guide-localizations - * into other output formats for various platforms. - */ - -const fs = require('fs'); -const path = require('path'); -const [inputFolder, outputFormat, outputFolder, keyPrefix = ''] = process.argv.slice(2); - -/** - * Ends the script with the given error message. - */ -function fail(message) { - console.error(`Error: ${message}`); - process.exit(1); -} - -/** - * Converter for Apple localized strings. - */ -function convertApple(lang, version, keys, keyPrefix, write) { - let disclaimer = `DO NOT EDIT. File generated automatically from v${version} of the ${lang} JSON strings.`; - - let stringsOutput = `// ${disclaimer}\n\n`; - for (const [key, value] of Object.entries(keys)) { - stringsOutput += `"${keyPrefix}${key}" = "${value}";\n`; - } - let stringsFile = path.join(`Resources/${lang}.lproj`, 'W3CAccessibilityMetadataDisplayGuide.strings'); - write(stringsFile, stringsOutput); - - // Using the "base" language, we will generate a static list of string keys to validate them at compile time. - if (lang == 'en-US') { - writeSwiftExtensions(disclaimer, keys, keyPrefix, write); - } -} - -/** - * Generates a static list of string keys to validate them at compile time. - */ -function writeSwiftExtensions(disclaimer, keys, keyPrefix, write) { - let keysOutput = `// -// Copyright 2025 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. -// - -// ${disclaimer}\n\npublic extension AccessibilityDisplayString {\n` - let keysList = Object.keys(keys) - .filter((k) => !k.endsWith("-descriptive")) - .map((k) => removeSuffix(k, "-compact")); - for (const key of keysList) { - keysOutput += ` static let ${convertKebabToCamelCase(key)}: Self = "${keyPrefix}${key}"\n`; - } - keysOutput += "}\n" - write("Publication/Accessibility/AccessibilityDisplayString+Generated.swift", keysOutput); -} - -const converters = { - apple: convertApple -}; - -if (!inputFolder || !outputFormat || !outputFolder) { - console.error('Usage: node convert.js [key-prefix]'); - process.exit(1); -} - -const langFolder = path.join(inputFolder, 'lang'); -if (!fs.existsSync(langFolder)) { - fail(`the specified input folder does not contain a 'lang' directory`); -} - -const convert = converters[outputFormat]; -if (!convert) { - fail(`unrecognized output format: ${outputFormat}, try: ${Object.keys(converters).join(', ')}.`); -} - -fs.readdir(langFolder, (err, langDirs) => { - if (err) { - fail(`reading directory: ${err.message}`); - } - - langDirs.forEach(langDir => { - const langDirPath = path.join(langFolder, langDir); - - fs.readdir(langDirPath, (err, files) => { - if (err) { - fail(`reading language directory ${langDir}: ${err.message}`); - } - - files.forEach(file => { - const filePath = path.join(langDirPath, file); - if (path.extname(file) === '.json') { - fs.readFile(filePath, 'utf8', (err, data) => { - if (err) { - console.error(`Error reading file ${file}: ${err.message}`); - return; - } - - try { - const jsonData = JSON.parse(data); - const version = jsonData["metadata"]["version"]; - convert(langDir, version, parseJsonKeys(jsonData), keyPrefix, write); - } catch (err) { - fail(`parsing JSON from file ${file}: ${err.message}`); - } - }); - } - }); - }); - }); -}); - -/** - * Writes the given content to the file path relative to the outputFolder provided in the CLI arguments. - */ -function write(relativePath, content) { - const outputPath = path.join(outputFolder, relativePath); - const outputDir = path.dirname(outputPath); - - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - fs.writeFile(outputPath, content, 'utf8', err => { - if (err) { - fail(`writing file ${outputPath}: ${err.message}`); - } else { - console.log(`Wrote ${outputPath}`); - } - }); -} - -/** - * Collects the JSON translation keys. - */ -function parseJsonKeys(obj) { - const keys = {}; - for (const key in obj) { - if (key === 'metadata') continue; // Ignore the metadata key - if (typeof obj[key] === 'object') { - for (const subKey in obj[key]) { - if (typeof obj[key][subKey] === 'object') { - for (const innerKey in obj[key][subKey]) { - const fullKey = `${subKey}-${innerKey}`; - keys[fullKey] = obj[key][subKey][innerKey]; - } - } else { - keys[subKey] = obj[key][subKey]; - } - } - } - } - return keys; -} - -function convertKebabToCamelCase(string) { - return string - .split('-') - .map((word, index) => { - if (index === 0) { - return word; - } - return word.charAt(0).toUpperCase() + word.slice(1); - }) - .join(''); -} - -function removeSuffix(str, suffix) { - if (str.endsWith(suffix)) { - return str.slice(0, -suffix.length); - } - return str; -} \ No newline at end of file diff --git a/BuildTools/Scripts/convert-thorium-localizations.js b/BuildTools/Scripts/convert-thorium-localizations.js index c2cd4dcfd3..3e05ec6241 100644 --- a/BuildTools/Scripts/convert-thorium-localizations.js +++ b/BuildTools/Scripts/convert-thorium-localizations.js @@ -10,167 +10,282 @@ const fs = require('fs'); const fsPromises = fs.promises; const path = require('path'); +const { generateAccessibilityDisplayStringExtensions } = require('./generate-a11y-extensions'); + +/** + * Plural suffixes used in thorium-locales JSON files (underscore format). + */ +const PLURAL_SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other']; + +/** + * Languages to process from thorium-locales. + */ +const LANGUAGES = ['en', 'fr', 'it']; + +/** + * Represents a localization key with support for plural forms. + */ +class LocalizationKey { + constructor(base, pluralForm = null) { + this.base = base; + this.pluralForm = pluralForm; + } + + stripPrefix(prefix) { + if (!prefix || !this.base.startsWith(prefix)) { + return this; + } + return new LocalizationKey(this.base.slice(prefix.length), this.pluralForm); + } + + toCamelCase() { + const camel = this.base + .split('-') + .map((word, index) => (index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1))) + .join(''); + return new LocalizationKey(camel, this.pluralForm); + } + + matchesAnyPrefix(prefixes) { + return prefixes.some(prefix => this.base.startsWith(prefix)); + } + + toString() { + return this.base; + } +} + +/** + * Represents a localization entry (key-value pair). + */ +class LocalizationEntry { + constructor(key, value, sourceKey = null) { + this.key = key; + this.value = value; + this.sourceKey = sourceKey; + this._placeholders = null; + } + + get placeholders() { + if (this._placeholders === null) { + const regex = /\{\{\s*(\w+)\s*\}\}/g; + const found = []; + let match; + while ((match = regex.exec(this.value)) !== null) { + if (!found.includes(match[1])) { + found.push(match[1]); + } + } + this._placeholders = found; + } + return this._placeholders; + } + + get hasPlaceholders() { + return this.placeholders.length > 0; + } +} /** * Configuration for a thorium-locales project. */ class LocaleConfig { - /** - * @param {string} folder - The folder name in thorium-locales - * @param {string} stripPrefix - Prefix to strip from JSON keys - * @param {string} outputPrefix - Prefix to add to output keys - * @param {string} outputFolder - Output folder for .lproj directories - * @param {string[]|null} [includePrefixes] - Optional prefixes to include (if null, include all) - */ - constructor({ folder, stripPrefix, outputPrefix, outputFolder, includePrefixes = null }) { - if (!folder || !stripPrefix || !outputPrefix || !outputFolder) { - throw new Error('LocaleConfig requires folder, stripPrefix, outputPrefix, and outputFolder'); + constructor({ + folder, + stripPrefix = '', + outputPrefix = '', + outputFolder, + includePrefixes = null, + tableName = 'Localizable', + keyTransform = null, + postProcess = null, + convertKeysToCamelCase = true + }) { + if (!folder || !outputFolder) { + throw new Error('LocaleConfig requires folder and outputFolder'); } this.folder = folder; this.stripPrefix = stripPrefix; this.outputPrefix = outputPrefix; this.outputFolder = outputFolder; this.includePrefixes = includePrefixes; + this.tableName = tableName; + this.keyTransform = keyTransform; + this.postProcess = postProcess; + this.convertKeysToCamelCase = convertKeysToCamelCase; } -} -/** - * Configuration for each thorium-locales project. - * Add new projects here as needed. - */ -const PROJECTS = { - lcp: new LocaleConfig({ - folder: 'lcp', - stripPrefix: 'lcp.', - outputPrefix: 'ReadiumLCP.', - outputFolder: 'Sources/LCP/Resources', - includePrefixes: ['lcp.dialog'] - }) -}; + transformEntry(entry) { + const sourceKey = entry.key; + let base = sourceKey.base; -/** - * Languages to process from thorium-locales. - * Only these languages will be included in the output. - */ -const LANGUAGES = ['en', 'fr', 'it']; + if (this.stripPrefix && base.startsWith(this.stripPrefix)) { + base = base.slice(this.stripPrefix.length); + } + if (this.keyTransform) { + base = this.keyTransform(base); + } + if (this.outputPrefix) { + base = this.outputPrefix + base; + } -// Parse arguments -const args = process.argv.slice(2); -const [inputFolder, outputFormat, ...projectNames] = args; + const newKey = new LocalizationKey(base, sourceKey.pluralForm); + return new LocalizationEntry(newKey, entry.value, sourceKey); + } -// If no projects specified, process all configured projects -const projectsToProcess = projectNames.length > 0 - ? projectNames - : Object.keys(PROJECTS); + shouldInclude(entry) { + if (!this.includePrefixes) { + return true; + } + return entry.key.matchesAnyPrefix(this.includePrefixes); + } +} + +// ============================================================================ +// Apple Strings Converter +// ============================================================================ /** - * Ends the script with the given error message. + * Converts localization entries to Apple .strings format. */ -function fail(message) { - console.error(`Error: ${message}`); - process.exit(1); -} +class AppleStringsConverter { + constructor(referenceEntries) { + this._placeholderMappings = this._buildPlaceholderMappings(referenceEntries); + } -async function processLocales() { - for (const projectName of projectsToProcess) { - const config = PROJECTS[projectName]; - if (!config) { - fail(`Unknown project: ${projectName}. Available: ${Object.keys(PROJECTS).join(', ')}`); - } + /** + * Generates .strings file content for the given entries. + */ + generate(lang, entries, config) { + const outputEntries = entries.map(entry => config.transformEntry(entry)); - console.log(`\nProcessing project: ${projectName}`); + const disclaimer = `DO NOT EDIT. File generated automatically from the ${lang} JSON strings of https://github.com/edrlab/thorium-locales/.`; + let content = `// ${disclaimer}\n\n`; - const languageKeys = await loadLanguageKeys(inputFolder, config.folder); - - // Filter keys if includePrefixes is specified - if (config.includePrefixes) { - for (const [lang, keys] of languageKeys) { - const filteredKeys = {}; - for (const [key, value] of Object.entries(keys)) { - // Check if key starts with any of the allowed prefixes - // Also handle plural keys by checking the base key (before @suffix) - const baseKey = getBaseKey(key); - if (config.includePrefixes.some(prefix => baseKey.startsWith(prefix))) { - filteredKeys[key] = value; - } - } - languageKeys.set(lang, filteredKeys); - } + for (const entry of outputEntries) { + content += this._formatEntry(entry, config.convertKeysToCamelCase) + '\n'; } - const placeholderMappings = buildPlaceholderMappings(languageKeys); + // Extract output keys for postProcess + const outputKeys = outputEntries.map(entry => + this._formatKey(entry.key.stripPrefix(config.outputPrefix)) + ); - const writeForProject = (relativePath, content) => { - writeFile(config.outputFolder, relativePath, content); - }; + return { content, outputKeys }; + } - for (const [lang, keys] of languageKeys) { - convert(lang, keys, config, writeForProject, placeholderMappings); + _buildPlaceholderMappings(entries) { + const mappings = new Map(); + for (const entry of entries) { + if (!entry.hasPlaceholders) continue; + const baseKey = entry.key.base; + if (mappings.has(baseKey)) continue; + + const mapping = {}; + entry.placeholders.forEach((name, index) => { + mapping[name] = index + 1; + }); + mappings.set(baseKey, mapping); } + return mappings; } -} -processLocales().catch(err => fail(err.message)); + _getPlaceholderMapping(key) { + return this._placeholderMappings.get(key.base) || {}; + } -/** - * Converter for Apple localized strings. - */ -function convertApple(lang, keys, config, write, placeholderMappings) { - const lproj = `${lang}.lproj`; - // Store both original key (for mapping lookup) and prefixed key (for output) - // Strip the configured prefix since the output prefix already indicates the context - const allEntries = Object.entries(keys).map(([key, value]) => - [key, config.outputPrefix + stripKeyPrefix(key, config.stripPrefix), value] - ); - - // Generate Localizable.strings - write(path.join(lproj, 'Localizable.strings'), generateAppleStrings(lang, allEntries, placeholderMappings)); -} + _formatEntry(entry, convertToCamelCase) { + const transformedKey = convertToCamelCase ? entry.key.toCamelCase() : entry.key; + const outputKey = this._formatKey(transformedKey); + const lookupKey = entry.sourceKey || entry.key; + const mapping = this._getPlaceholderMapping(lookupKey); + const escapedValue = this._escape(entry.value); + const convertedValue = this._convertPlaceholders(escapedValue, mapping); + return `"${outputKey}" = "${convertedValue}";`; + } -/** - * Generates an Apple .strings file content from a list of [originalKey, prefixedKey, value] entries. - */ -function generateAppleStrings(lang, entries, placeholderMappings) { - const disclaimer = `DO NOT EDIT. File generated automatically from the ${lang} JSON strings of https://github.com/edrlab/thorium-locales/.`; - let output = `// ${disclaimer}\n\n`; - for (const [originalKey, prefixedKey, value] of entries) { - // Use original key (without prefix) to look up placeholder mapping - const baseKey = getBaseKey(originalKey); - const mapping = placeholderMappings[originalKey] || placeholderMappings[baseKey] || {}; - const escapedValue = escapeForAppleStrings(value); - const convertedValue = convertPlaceholders(escapedValue, mapping); - output += `"${convertKebabToCamelCase(prefixedKey)}" = "${convertedValue}";\n`; + _formatKey(key) { + if (key.pluralForm) { + return `${key.base}@${key.pluralForm}`; + } + return key.base; } - return output; + _escape(value) { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/%/g, '%%'); + } + + _convertPlaceholders(value, mapping) { + if (Object.keys(mapping).length === 0) { + return value; + } + return value.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name) => { + const position = mapping[name]; + if (position === undefined) { + return match; + } + const formatSpec = name === 'count' ? 'd' : '@'; + return `%${position}$${formatSpec}`; + }); + } } -const converters = { - apple: convertApple -}; +// ============================================================================ +// Utility Functions +// ============================================================================ -if (!inputFolder || !outputFormat) { - console.error('Usage: node convert-thorium-localizations.js [project...]'); - console.error(''); - console.error('Arguments:'); - console.error(' input-folder Path to the cloned thorium-locales repository'); - console.error(' output-format Output format (apple)'); - console.error(' project Optional project name(s) to process (default: all)'); - console.error(''); - console.error(`Available projects: ${Object.keys(PROJECTS).join(', ')}`); +function fail(message) { + console.error(`Error: ${message}`); process.exit(1); } -const convert = converters[outputFormat]; -if (!convert) { - fail(`unrecognized output format: ${outputFormat}, try: ${Object.keys(converters).join(', ')}.`); +function writeFile(relativePath, content) { + const outputDir = path.dirname(relativePath); + + try { + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(relativePath, content, 'utf8'); + console.log(`Wrote ${relativePath}`); + } catch (err) { + fail(`Failed to write ${relativePath}: ${err.message}`); + } } -/** - * Loads all JSON locale files from the specified folder and returns a Map of language -> keys. - */ -async function loadLanguageKeys(inputFolder, localeFolder) { - const languageKeys = new Map(); +// ============================================================================ +// JSON Parsing +// ============================================================================ + +function parseJsonEntries(obj, prefix = '') { + const entries = []; + + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (typeof value === 'string') { + const pluralSuffix = PLURAL_SUFFIXES.find(suffix => fullKey.endsWith(suffix)); + if (pluralSuffix) { + const baseKey = fullKey.slice(0, -pluralSuffix.length); + const pluralForm = pluralSuffix.slice(1); + entries.push(new LocalizationEntry(new LocalizationKey(baseKey, pluralForm), value)); + } else { + entries.push(new LocalizationEntry(new LocalizationKey(fullKey), value)); + } + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + entries.push(...parseJsonEntries(value, fullKey)); + } else { + console.warn(`Warning: Skipping unexpected value type for key "${fullKey}": ${typeof value}`); + } + } + + return entries; +} + +async function loadLanguageEntries(inputFolder, localeFolder) { + const languageEntries = new Map(); const folderPath = path.join(inputFolder, localeFolder); if (!fs.existsSync(folderPath)) { @@ -184,202 +299,114 @@ async function loadLanguageKeys(inputFolder, localeFolder) { for (const file of files) { if (path.extname(file) !== '.json') continue; - // Normalize locale to BCP 47 format (hyphens) to merge keys from - // files like pt_PT.json and pt-PT.json into a single locale entry. const lang = path.basename(file, '.json').replace(/_/g, '-'); - - // Skip languages not in the allowed list - if (!LANGUAGES.includes(lang)) { - continue; - } + if (!LANGUAGES.includes(lang)) continue; const filePath = path.join(folderPath, file); try { const data = await fsPromises.readFile(filePath, 'utf8'); const jsonData = JSON.parse(data); - const keys = parseJsonKeys(jsonData); + const entries = parseJsonEntries(jsonData); - if (!languageKeys.has(lang)) { - languageKeys.set(lang, {}); + if (!languageEntries.has(lang)) { + languageEntries.set(lang, []); } - Object.assign(languageKeys.get(lang), keys); + languageEntries.get(lang).push(...entries); } catch (err) { fail(`processing ${file}: ${err.message}`); } } - return languageKeys; + return languageEntries; } -/** - * Builds placeholder-to-position mappings from English keys. - * This ensures consistent argument ordering across all languages. - */ -function buildPlaceholderMappings(languageKeys) { - const placeholderMappings = {}; - const englishKeys = languageKeys.get('en'); - - if (englishKeys) { - for (const [key, value] of Object.entries(englishKeys)) { - const mapping = extractPlaceholderMapping(value); - if (Object.keys(mapping).length > 0) { - // For pluralized keys (ending with @one, @other, etc.), use the base key for mapping - const baseKey = getBaseKey(key); - // Only set if not already set (first plural form encountered sets the mapping) - if (!placeholderMappings[baseKey]) { - placeholderMappings[baseKey] = mapping; - } +// ============================================================================ +// Entry Point +// ============================================================================ + +const PROJECTS = { + lcp: new LocaleConfig({ + folder: 'lcp', + outputPrefix: 'readium.', + outputFolder: 'Sources/LCP/Resources', + includePrefixes: ['lcp.dialog'] + }), + + a11y: new LocaleConfig({ + folder: 'publication-metadata', + stripPrefix: 'publication.metadata.accessibility.display-guide.', + outputPrefix: 'readium.a11y.', + outputFolder: 'Sources/Shared/Resources', + includePrefixes: ['publication.metadata.accessibility.display-guide'], + tableName: 'W3CAccessibilityMetadataDisplayGuide', + keyTransform: key => key.replace(/\./g, '-'), + convertKeysToCamelCase: false, + postProcess: (lang, keys, config) => { + if (lang === 'en') { + generateAccessibilityDisplayStringExtensions( + keys, + 'Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift', + config.outputPrefix, + writeFile + ); } } - } - - return placeholderMappings; -} - -/** - * Writes the given content to the file path relative to the specified base folder. - */ -function writeFile(baseFolder, relativePath, content) { - const outputPath = path.join(baseFolder, relativePath); - const outputDir = path.dirname(outputPath); - - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - fs.writeFileSync(outputPath, content, 'utf8'); - console.log(`Wrote ${outputPath}`); -} + }) +}; -/** - * Strips the plural suffix from a key (e.g., @one, @other). - */ -function getBaseKey(key) { - return key.replace(/@(zero|one|two|few|many|other)$/, ''); -} +const args = process.argv.slice(2); +const [inputFolder, ...projectNames] = args; -/** - * Strips a prefix from a key. - */ -function stripKeyPrefix(key, prefix) { - return key.startsWith(prefix) ? key.slice(prefix.length) : key; +if (!inputFolder) { + console.error('Usage: node convert-thorium-localizations.js [project...]'); + console.error(''); + console.error('Arguments:'); + console.error(' input-folder Path to the cloned thorium-locales repository'); + console.error(' project Optional project name(s) to process (default: all)'); + console.error(''); + console.error(`Available projects: ${Object.keys(PROJECTS).join(', ')}`); + process.exit(1); } -/** - * Recursively collects the JSON translation keys using dot notation and special handling for pluralization patterns. - * Plural keys use flat format with underscore suffix (e.g., key_one, key_other) which are converted to @ suffix. - */ -function parseJsonKeys(obj, prefix = '') { - const keys = {}; - const pluralSuffixes = ['_zero', '_one', '_two', '_few', '_many', '_other']; - - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key; +const projectsToProcess = projectNames.length > 0 + ? projectNames + : Object.keys(PROJECTS); - if (typeof value === 'object' && value !== null) { - // Recursively process nested objects - Object.assign(keys, parseJsonKeys(value, fullKey)); - } else { - // Check for plural suffix and convert underscore to @ notation - const pluralSuffix = pluralSuffixes.find(suffix => fullKey.endsWith(suffix)); - if (pluralSuffix) { - const baseKey = fullKey.slice(0, -pluralSuffix.length); - const pluralForm = pluralSuffix.slice(1); // Remove leading underscore - keys[`${baseKey}@${pluralForm}`] = value; - } else { - // Simple key-value pair - keys[fullKey] = value; - } +async function processLocales(inputFolder, projectsToProcess) { + for (const projectName of projectsToProcess) { + const config = PROJECTS[projectName]; + if (!config) { + fail(`Unknown project: ${projectName}. Available: ${Object.keys(PROJECTS).join(', ')}`); } - } - return keys; -} + console.log(`\nProcessing project: ${projectName}`); -function convertKebabToCamelCase(string) { - return string - .split('-') - .map((word, index) => { - if (index === 0) { - return word; - } - return word.charAt(0).toUpperCase() + word.slice(1); - }) - .join(''); -} + const languageEntries = await loadLanguageEntries(inputFolder, config.folder); -/** - * Extracts placeholders from a string and builds a mapping from placeholder name to position index. - * Returns an object with placeholder names as keys and their positional index (1-based) as values. - * Placeholders are assigned positions based on their order of appearance. - */ -function extractPlaceholderMapping(value) { - const placeholderRegex = /\{\{\s*(\w+)\s*\}\}/g; - const placeholders = []; - let match; - - while ((match = placeholderRegex.exec(value)) !== null) { - const name = match[1]; - if (!placeholders.includes(name)) { - placeholders.push(name); + // Filter entries + for (const [lang, entries] of languageEntries) { + const filtered = entries.filter(entry => config.shouldInclude(entry)); + languageEntries.set(lang, filtered); } - } - - if (placeholders.length === 0) { - return {}; - } - - const mapping = {}; - let position = 1; - for (const name of placeholders) { - mapping[name] = position++; - } - return mapping; -} + // Create converter with English entries as reference + const englishEntries = languageEntries.get('en') || []; + const converter = new AppleStringsConverter(englishEntries); -/** - * Escapes special characters for iOS .strings format. - * Must be called before placeholder conversion. - * - * - \ -> \\ - * - " -> \" - * - newlines -> \n - * - % -> %% - */ -function escapeForAppleStrings(value) { - return value - // Escape backslashes first (before other escapes add more backslashes) - .replace(/\\/g, '\\\\') - // Escape double quotes - .replace(/"/g, '\\"') - // Escape newlines - .replace(/\n/g, '\\n') - // Escape literal % characters - .replace(/%/g, '%%'); -} + for (const [lang, entries] of languageEntries) { + const { content, outputKeys } = converter.generate(lang, entries, config); -/** - * Converts Mustache-style placeholders to iOS format specifiers using the provided mapping. - * Placeholders become `%N$@` (string format) or `%N$d` (integer format) for `count`. - * Using %d for `count` allows the GenerateLocalizedUserString.swift script to identify the - * count offset to resolve the pluralization form. - */ -function convertPlaceholders(value, placeholderMap) { - if (Object.keys(placeholderMap).length === 0) { - return value; - } + // Write .strings file + const outputPath = path.join(config.outputFolder, `${lang}.lproj`, `${config.tableName}.strings`); + writeFile(outputPath, content); - return value.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name) => { - const position = placeholderMap[name]; - if (position === undefined) { - // Placeholder not in mapping, leave unchanged - return match; + // Run postProcess hook + if (config.postProcess) { + config.postProcess(lang, outputKeys, config); + } } - - // Use %d for `count` placeholder, %@ for everything else - const formatSpec = (name === 'count') ? 'd' : '@'; - return `%${position}$${formatSpec}`; - }); + } } + +processLocales(inputFolder, projectsToProcess).catch(err => fail(err.message)); diff --git a/BuildTools/Scripts/generate-a11y-extensions.js b/BuildTools/Scripts/generate-a11y-extensions.js new file mode 100644 index 0000000000..8392f5bcce --- /dev/null +++ b/BuildTools/Scripts/generate-a11y-extensions.js @@ -0,0 +1,68 @@ +/** + * 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. + * + * This module generates Swift extensions for AccessibilityDisplayString + * from localization keys. + */ + +/** + * Generates AccessibilityDisplayString Swift extension from localization keys. + * + * @param {string[]} keys - Array of localization keys (without prefix) + * @param {string} outputPath - Path to write the generated Swift file + * @param {string} keyPrefix - Prefix used in localization keys (e.g., "readium.a11y.") + * @param {function} write - Write function (relativePath, content) => void + */ +function generateAccessibilityDisplayStringExtensions(keys, outputPath, keyPrefix, write) { + const disclaimer = 'DO NOT EDIT. File generated automatically from https://github.com/edrlab/thorium-locales/.'; + + // Filter out -descriptive keys (keep base keys only) and remove -compact suffix + const filteredKeys = keys + .filter(k => !k.endsWith('-descriptive')) + .map(k => removeSuffix(k, '-compact')); + + // Remove duplicates (since we removed -compact suffix, some keys may now be the same) + const uniqueKeys = [...new Set(filteredKeys)]; + + let output = `// ${disclaimer} +public extension AccessibilityDisplayString { +`; + + for (const key of uniqueKeys) { + const swiftName = convertKebabToCamelCase(key); + output += ` static let ${swiftName}: Self = "${keyPrefix}${key}"\n`; + } + + output += '}\n'; + + write(outputPath, output); +} + +/** + * Converts a kebab-case string to camelCase. + */ +function convertKebabToCamelCase(string) { + return string + .split('-') + .map((word, index) => { + if (index === 0) { + return word; + } + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(''); +} + +/** + * Removes a suffix from a string if present. + */ +function removeSuffix(str, suffix) { + if (str.endsWith(suffix)) { + return str.slice(0, -suffix.length); + } + return str; +} + +module.exports = { generateAccessibilityDisplayStringExtensions }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 896e5ce449..8ae38e5b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ All notable changes to this project will be documented in this file. Take a look * The iOS minimum deployment target is now iOS 15.0. +#### Shared + +* Accessibility display strings are now sourced from the [thorium-locales](https://github.com/edrlab/thorium-locales/) repository (instead of W3C's repository). Contributions are welcome on [Weblate](https://hosted.weblate.org/projects/thorium-reader/publication-metadata/). + #### LCP * The LCP dialog used by `LCPDialogAuthentication` has been redesigned. diff --git a/Makefile b/Makefile index 3365907a24..052ed745f3 100644 --- a/Makefile +++ b/Makefile @@ -46,27 +46,17 @@ f: format format: swift run --package-path BuildTools swiftformat . -.PHONY: update-locales -update-locales: update-a11y-locales update-thorium-locales - -.PHONY: update-a11y-locales -update-a11y-locales: - @which node >/dev/null 2>&1 || (echo "ERROR: node is required, please install it first"; exit 1) - rm -rf publ-a11y-display-guide-localizations - git clone https://github.com/w3c/publ-a11y-display-guide-localizations.git - node BuildTools/Scripts/convert-a11y-display-guide-localizations.js publ-a11y-display-guide-localizations apple Sources/Shared readium.a11y. - rm -rf publ-a11y-display-guide-localizations - BRANCH ?= main -.PHONY: update-thorium-locales -update-thorium-locales: +.PHONY: update-locales +update-locales: @which node >/dev/null 2>&1 || (echo "ERROR: node is required, please install it first"; exit 1) ifndef DIR rm -rf thorium-locales git clone -b $(BRANCH) --single-branch --depth 1 https://github.com/edrlab/thorium-locales.git endif - node BuildTools/Scripts/convert-thorium-localizations.js thorium-locales apple + node BuildTools/Scripts/convert-thorium-localizations.js thorium-locales ifndef DIR rm -rf thorium-locales endif + make format diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift b/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift index 95ee4466f8..164ebcfed4 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift @@ -4,24 +4,30 @@ // available in the top-level LICENSE file of the project. // -// DO NOT EDIT. File generated automatically from v2.0.c of the en-US JSON strings. - +// DO NOT EDIT. File generated automatically from https://github.com/edrlab/thorium-locales/. public extension AccessibilityDisplayString { - static let waysOfReadingTitle: Self = "readium.a11y.ways-of-reading-title" - static let waysOfReadingNonvisualReadingAltText: Self = "readium.a11y.ways-of-reading-nonvisual-reading-alt-text" - static let waysOfReadingNonvisualReadingNoMetadata: Self = "readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" - static let waysOfReadingNonvisualReadingNone: Self = "readium.a11y.ways-of-reading-nonvisual-reading-none" - static let waysOfReadingNonvisualReadingNotFully: Self = "readium.a11y.ways-of-reading-nonvisual-reading-not-fully" - static let waysOfReadingNonvisualReadingReadable: Self = "readium.a11y.ways-of-reading-nonvisual-reading-readable" - static let waysOfReadingPrerecordedAudioComplementary: Self = "readium.a11y.ways-of-reading-prerecorded-audio-complementary" - static let waysOfReadingPrerecordedAudioNoMetadata: Self = "readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" - static let waysOfReadingPrerecordedAudioOnly: Self = "readium.a11y.ways-of-reading-prerecorded-audio-only" - static let waysOfReadingPrerecordedAudioSynchronized: Self = "readium.a11y.ways-of-reading-prerecorded-audio-synchronized" - static let waysOfReadingVisualAdjustmentsModifiable: Self = "readium.a11y.ways-of-reading-visual-adjustments-modifiable" - static let waysOfReadingVisualAdjustmentsUnknown: Self = "readium.a11y.ways-of-reading-visual-adjustments-unknown" - static let waysOfReadingVisualAdjustmentsUnmodifiable: Self = "readium.a11y.ways-of-reading-visual-adjustments-unmodifiable" - static let conformanceTitle: Self = "readium.a11y.conformance-title" - static let conformanceDetailsTitle: Self = "readium.a11y.conformance-details-title" + static let accessibilitySummaryNoMetadata: Self = "readium.a11y.accessibility-summary-no-metadata" + static let accessibilitySummaryPublisherContact: Self = "readium.a11y.accessibility-summary-publisher-contact" + static let accessibilitySummaryTitle: Self = "readium.a11y.accessibility-summary-title" + static let additionalAccessibilityInformationAria: Self = "readium.a11y.additional-accessibility-information-aria" + static let additionalAccessibilityInformationAudioDescriptions: Self = "readium.a11y.additional-accessibility-information-audio-descriptions" + static let additionalAccessibilityInformationBraille: Self = "readium.a11y.additional-accessibility-information-braille" + static let additionalAccessibilityInformationColorNotSoleMeansOfConveyingInformation: Self = "readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" + static let additionalAccessibilityInformationDyslexiaReadability: Self = "readium.a11y.additional-accessibility-information-dyslexia-readability" + static let additionalAccessibilityInformationFullRubyAnnotations: Self = "readium.a11y.additional-accessibility-information-full-ruby-annotations" + static let additionalAccessibilityInformationHighContrastBetweenForegroundAndBackgroundAudio: Self = "readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" + static let additionalAccessibilityInformationHighContrastBetweenTextAndBackground: Self = "readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" + static let additionalAccessibilityInformationLargePrint: Self = "readium.a11y.additional-accessibility-information-large-print" + static let additionalAccessibilityInformationPageBreaks: Self = "readium.a11y.additional-accessibility-information-page-breaks" + static let additionalAccessibilityInformationRubyAnnotations: Self = "readium.a11y.additional-accessibility-information-ruby-annotations" + static let additionalAccessibilityInformationSignLanguage: Self = "readium.a11y.additional-accessibility-information-sign-language" + static let additionalAccessibilityInformationTactileGraphics: Self = "readium.a11y.additional-accessibility-information-tactile-graphics" + static let additionalAccessibilityInformationTactileObjects: Self = "readium.a11y.additional-accessibility-information-tactile-objects" + static let additionalAccessibilityInformationTextToSpeechHinting: Self = "readium.a11y.additional-accessibility-information-text-to-speech-hinting" + static let additionalAccessibilityInformationTitle: Self = "readium.a11y.additional-accessibility-information-title" + static let additionalAccessibilityInformationUltraHighContrastBetweenTextAndBackground: Self = "readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" + static let additionalAccessibilityInformationVisiblePageNumbering: Self = "readium.a11y.additional-accessibility-information-visible-page-numbering" + static let additionalAccessibilityInformationWithoutBackgroundSounds: Self = "readium.a11y.additional-accessibility-information-without-background-sounds" static let conformanceA: Self = "readium.a11y.conformance-a" static let conformanceAa: Self = "readium.a11y.conformance-aa" static let conformanceAaa: Self = "readium.a11y.conformance-aaa" @@ -38,26 +44,10 @@ public extension AccessibilityDisplayString { static let conformanceDetailsWcag20: Self = "readium.a11y.conformance-details-wcag-2-0" static let conformanceDetailsWcag21: Self = "readium.a11y.conformance-details-wcag-2-1" static let conformanceDetailsWcag22: Self = "readium.a11y.conformance-details-wcag-2-2" + static let conformanceDetailsTitle: Self = "readium.a11y.conformance-details-title" static let conformanceNo: Self = "readium.a11y.conformance-no" + static let conformanceTitle: Self = "readium.a11y.conformance-title" static let conformanceUnknownStandard: Self = "readium.a11y.conformance-unknown-standard" - static let navigationTitle: Self = "readium.a11y.navigation-title" - static let navigationIndex: Self = "readium.a11y.navigation-index" - static let navigationNoMetadata: Self = "readium.a11y.navigation-no-metadata" - static let navigationPageNavigation: Self = "readium.a11y.navigation-page-navigation" - static let navigationStructural: Self = "readium.a11y.navigation-structural" - static let navigationToc: Self = "readium.a11y.navigation-toc" - static let richContentTitle: Self = "readium.a11y.rich-content-title" - static let richContentAccessibleChemistryAsLatex: Self = "readium.a11y.rich-content-accessible-chemistry-as-latex" - static let richContentAccessibleChemistryAsMathml: Self = "readium.a11y.rich-content-accessible-chemistry-as-mathml" - static let richContentAccessibleMathAsLatex: Self = "readium.a11y.rich-content-accessible-math-as-latex" - static let richContentAccessibleMathAsMathml: Self = "readium.a11y.rich-content-accessible-math-as-mathml" - static let richContentAccessibleMathDescribed: Self = "readium.a11y.rich-content-accessible-math-described" - static let richContentClosedCaptions: Self = "readium.a11y.rich-content-closed-captions" - static let richContentExtended: Self = "readium.a11y.rich-content-extended" - static let richContentOpenCaptions: Self = "readium.a11y.rich-content-open-captions" - static let richContentTranscript: Self = "readium.a11y.rich-content-transcript" - static let richContentUnknown: Self = "readium.a11y.rich-content-unknown" - static let hazardsTitle: Self = "readium.a11y.hazards-title" static let hazardsFlashing: Self = "readium.a11y.hazards-flashing" static let hazardsFlashingNone: Self = "readium.a11y.hazards-flashing-none" static let hazardsFlashingUnknown: Self = "readium.a11y.hazards-flashing-unknown" @@ -69,30 +59,39 @@ public extension AccessibilityDisplayString { static let hazardsSound: Self = "readium.a11y.hazards-sound" static let hazardsSoundNone: Self = "readium.a11y.hazards-sound-none" static let hazardsSoundUnknown: Self = "readium.a11y.hazards-sound-unknown" + static let hazardsTitle: Self = "readium.a11y.hazards-title" static let hazardsUnknown: Self = "readium.a11y.hazards-unknown" - static let accessibilitySummaryTitle: Self = "readium.a11y.accessibility-summary-title" - static let accessibilitySummaryNoMetadata: Self = "readium.a11y.accessibility-summary-no-metadata" - static let accessibilitySummaryPublisherContact: Self = "readium.a11y.accessibility-summary-publisher-contact" - static let legalConsiderationsTitle: Self = "readium.a11y.legal-considerations-title" static let legalConsiderationsExempt: Self = "readium.a11y.legal-considerations-exempt" static let legalConsiderationsNoMetadata: Self = "readium.a11y.legal-considerations-no-metadata" - static let additionalAccessibilityInformationTitle: Self = "readium.a11y.additional-accessibility-information-title" - static let additionalAccessibilityInformationAria: Self = "readium.a11y.additional-accessibility-information-aria" - static let additionalAccessibilityInformationAudioDescriptions: Self = "readium.a11y.additional-accessibility-information-audio-descriptions" - static let additionalAccessibilityInformationBraille: Self = "readium.a11y.additional-accessibility-information-braille" - static let additionalAccessibilityInformationColorNotSoleMeansOfConveyingInformation: Self = "readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" - static let additionalAccessibilityInformationDyslexiaReadability: Self = "readium.a11y.additional-accessibility-information-dyslexia-readability" - static let additionalAccessibilityInformationFullRubyAnnotations: Self = "readium.a11y.additional-accessibility-information-full-ruby-annotations" - static let additionalAccessibilityInformationHighContrastBetweenForegroundAndBackgroundAudio: Self = "readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" - static let additionalAccessibilityInformationHighContrastBetweenTextAndBackground: Self = "readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" - static let additionalAccessibilityInformationLargePrint: Self = "readium.a11y.additional-accessibility-information-large-print" - static let additionalAccessibilityInformationPageBreaks: Self = "readium.a11y.additional-accessibility-information-page-breaks" - static let additionalAccessibilityInformationRubyAnnotations: Self = "readium.a11y.additional-accessibility-information-ruby-annotations" - static let additionalAccessibilityInformationSignLanguage: Self = "readium.a11y.additional-accessibility-information-sign-language" - static let additionalAccessibilityInformationTactileGraphics: Self = "readium.a11y.additional-accessibility-information-tactile-graphics" - static let additionalAccessibilityInformationTactileObjects: Self = "readium.a11y.additional-accessibility-information-tactile-objects" - static let additionalAccessibilityInformationTextToSpeechHinting: Self = "readium.a11y.additional-accessibility-information-text-to-speech-hinting" - static let additionalAccessibilityInformationUltraHighContrastBetweenTextAndBackground: Self = "readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" - static let additionalAccessibilityInformationVisiblePageNumbering: Self = "readium.a11y.additional-accessibility-information-visible-page-numbering" - static let additionalAccessibilityInformationWithoutBackgroundSounds: Self = "readium.a11y.additional-accessibility-information-without-background-sounds" + static let legalConsiderationsTitle: Self = "readium.a11y.legal-considerations-title" + static let navigationIndex: Self = "readium.a11y.navigation-index" + static let navigationNoMetadata: Self = "readium.a11y.navigation-no-metadata" + static let navigationPageNavigation: Self = "readium.a11y.navigation-page-navigation" + static let navigationStructural: Self = "readium.a11y.navigation-structural" + static let navigationTitle: Self = "readium.a11y.navigation-title" + static let navigationToc: Self = "readium.a11y.navigation-toc" + static let richContentAccessibleChemistryAsLatex: Self = "readium.a11y.rich-content-accessible-chemistry-as-latex" + static let richContentAccessibleChemistryAsMathml: Self = "readium.a11y.rich-content-accessible-chemistry-as-mathml" + static let richContentAccessibleMathAsLatex: Self = "readium.a11y.rich-content-accessible-math-as-latex" + static let richContentAccessibleMathDescribed: Self = "readium.a11y.rich-content-accessible-math-described" + static let richContentClosedCaptions: Self = "readium.a11y.rich-content-closed-captions" + static let richContentExtendedDescriptions: Self = "readium.a11y.rich-content-extended-descriptions" + static let richContentMathAsMathml: Self = "readium.a11y.rich-content-math-as-mathml" + static let richContentOpenCaptions: Self = "readium.a11y.rich-content-open-captions" + static let richContentTitle: Self = "readium.a11y.rich-content-title" + static let richContentTranscript: Self = "readium.a11y.rich-content-transcript" + static let richContentUnknown: Self = "readium.a11y.rich-content-unknown" + static let waysOfReadingNonvisualReadingAltText: Self = "readium.a11y.ways-of-reading-nonvisual-reading-alt-text" + static let waysOfReadingNonvisualReadingNoMetadata: Self = "readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" + static let waysOfReadingNonvisualReadingNone: Self = "readium.a11y.ways-of-reading-nonvisual-reading-none" + static let waysOfReadingNonvisualReadingNotFully: Self = "readium.a11y.ways-of-reading-nonvisual-reading-not-fully" + static let waysOfReadingNonvisualReadingReadable: Self = "readium.a11y.ways-of-reading-nonvisual-reading-readable" + static let waysOfReadingPrerecordedAudioComplementary: Self = "readium.a11y.ways-of-reading-prerecorded-audio-complementary" + static let waysOfReadingPrerecordedAudioNoMetadata: Self = "readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" + static let waysOfReadingPrerecordedAudioOnly: Self = "readium.a11y.ways-of-reading-prerecorded-audio-only" + static let waysOfReadingPrerecordedAudioSynchronized: Self = "readium.a11y.ways-of-reading-prerecorded-audio-synchronized" + static let waysOfReadingTitle: Self = "readium.a11y.ways-of-reading-title" + static let waysOfReadingVisualAdjustmentsModifiable: Self = "readium.a11y.ways-of-reading-visual-adjustments-modifiable" + static let waysOfReadingVisualAdjustmentsUnknown: Self = "readium.a11y.ways-of-reading-visual-adjustments-unknown" + static let waysOfReadingVisualAdjustmentsUnmodifiable: Self = "readium.a11y.ways-of-reading-visual-adjustments-unmodifiable" } diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift index 3845e61e19..4e05332cf1 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift @@ -384,13 +384,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public var statements: [AccessibilityDisplayStatement] { Array { if extendedAltTextDescriptions { - $0.append(.richContentExtended) + $0.append(.richContentExtendedDescriptions) } if mathFormula { $0.append(.richContentAccessibleMathDescribed) } if mathFormulaAsMathML { - $0.append(.richContentAccessibleMathAsMathml) + $0.append(.richContentMathAsMathml) } if mathFormulaAsLaTeX { $0.append(.richContentAccessibleMathAsLatex) @@ -997,9 +997,14 @@ public struct AccessibilityDisplayString: RawRepresentable, ExpressibleByStringL /// - Parameter descriptive: When true, will return the long descriptive /// statement. public func localized(descriptive: Bool) -> NSAttributedString { - NSAttributedString(string: bundleString("\(rawValue)-\(descriptive ? "descriptive" : "compact")") - .trimmingCharacters(in: .whitespacesAndNewlines) - ) + // Try the suffixed key first, then fall back to the unsuffixed key + // for strings where compact and descriptive variants are identical. + let suffixedKey = "\(rawValue)-\(descriptive ? "descriptive" : "compact")" + var string = bundleString(suffixedKey) + if string == suffixedKey { + string = bundleString(rawValue) + } + return NSAttributedString(string: string.trimmingCharacters(in: .whitespacesAndNewlines)) } private func bundleString(_ key: String, _ values: CVarArg...) -> String { @@ -1024,3 +1029,13 @@ private extension Array where Element == AccessibilityDisplayStatement { append(AccessibilityDisplayStatement(string: string)) } } + +// MARK: - Deprecated Aliases + +public extension AccessibilityDisplayString { + @available(*, deprecated, renamed: "richContentExtendedDescriptions") + static var richContentExtended: Self { richContentExtendedDescriptions } + + @available(*, deprecated, renamed: "richContentMathAsMathml") + static var richContentAccessibleMathAsMathml: Self { richContentMathAsMathml } +} diff --git a/Sources/Shared/Resources/en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings b/Sources/Shared/Resources/en.lproj/W3CAccessibilityMetadataDisplayGuide.strings similarity index 52% rename from Sources/Shared/Resources/en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings rename to Sources/Shared/Resources/en.lproj/W3CAccessibilityMetadataDisplayGuide.strings index 1afeade2f1..b5b399fad2 100644 --- a/Sources/Shared/Resources/en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings +++ b/Sources/Shared/Resources/en.lproj/W3CAccessibilityMetadataDisplayGuide.strings @@ -1,101 +1,56 @@ -// DO NOT EDIT. File generated automatically from v2.0.c of the en-US JSON strings. +// DO NOT EDIT. File generated automatically from the en JSON strings of https://github.com/edrlab/thorium-locales/. -"readium.a11y.ways-of-reading-title" = "Ways of reading"; -"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-compact" = "Has alternative text"; -"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-descriptive" = "Has alternative text descriptions for images"; -"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata-compact" = "No information about nonvisual reading is available"; -"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata-descriptive" = "No information about nonvisual reading is available"; -"readium.a11y.ways-of-reading-nonvisual-reading-none-compact" = "Not readable in read aloud or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-none-descriptive" = "The content is not readable as read aloud speech or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-compact" = "Not fully readable in read aloud or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-descriptive" = "Not all of the content will be readable as read aloud speech or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-readable-compact" = "Readable in read aloud or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-readable-descriptive" = "All content can be read as read aloud speech or dynamic braille"; -"readium.a11y.ways-of-reading-prerecorded-audio-complementary-compact" = "Prerecorded audio clips"; -"readium.a11y.ways-of-reading-prerecorded-audio-complementary-descriptive" = "Prerecorded audio clips are embedded in the content"; -"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata-compact" = "No information about prerecorded audio is available"; -"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata-descriptive" = "No information about prerecorded audio is available"; -"readium.a11y.ways-of-reading-prerecorded-audio-only-compact" = "Prerecorded audio only"; -"readium.a11y.ways-of-reading-prerecorded-audio-only-descriptive" = "Audiobook with no text alternative"; -"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-compact" = "Prerecorded audio synchronized with text"; -"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-descriptive" = "All the content is available as prerecorded audio synchronized with text"; -"readium.a11y.ways-of-reading-visual-adjustments-modifiable-compact" = "Appearance can be modified"; -"readium.a11y.ways-of-reading-visual-adjustments-modifiable-descriptive" = "Appearance of the text and page layout can be modified according to the capabilities of the reading system (font family and font size, spaces between paragraphs, sentences, words, and letters, as well as color of background and text)"; -"readium.a11y.ways-of-reading-visual-adjustments-unknown-compact" = "No information about appearance modifiability is available"; -"readium.a11y.ways-of-reading-visual-adjustments-unknown-descriptive" = "No information about appearance modifiability is available"; -"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-compact" = "Appearance cannot be modified"; -"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-descriptive" = "Text and page layout cannot be modified as the reading experience is close to a print version, but reading systems can still provide zooming options"; -"readium.a11y.conformance-title" = "Conformance"; -"readium.a11y.conformance-details-title" = "Detailed conformance information"; +"readium.a11y.accessibility-summary-no-metadata" = "No information is available"; +"readium.a11y.accessibility-summary-publisher-contact" = "For more information about the accessibility of this product, please contact the publisher: "; +"readium.a11y.accessibility-summary-title" = "Accessibility summary"; +"readium.a11y.additional-accessibility-information-aria-compact" = "ARIA roles included"; +"readium.a11y.additional-accessibility-information-aria-descriptive" = "Content is enhanced with ARIA roles to optimize organization and facilitate navigation"; +"readium.a11y.additional-accessibility-information-audio-descriptions" = "Audio descriptions"; +"readium.a11y.additional-accessibility-information-braille" = "Braille"; +"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" = "Color is not the sole means of conveying information"; +"readium.a11y.additional-accessibility-information-dyslexia-readability" = "Dyslexia readability"; +"readium.a11y.additional-accessibility-information-full-ruby-annotations" = "Full ruby annotations"; +"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" = "High contrast between foreground and background audio"; +"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" = "High contrast between foreground text and background"; +"readium.a11y.additional-accessibility-information-large-print" = "Large print"; +"readium.a11y.additional-accessibility-information-page-breaks-compact" = "Page breaks included"; +"readium.a11y.additional-accessibility-information-page-breaks-descriptive" = "Page breaks included from the original print source"; +"readium.a11y.additional-accessibility-information-ruby-annotations" = "Some Ruby annotations"; +"readium.a11y.additional-accessibility-information-sign-language" = "Sign language"; +"readium.a11y.additional-accessibility-information-tactile-graphics-compact" = "Tactile graphics included"; +"readium.a11y.additional-accessibility-information-tactile-graphics-descriptive" = "Tactile graphics have been integrated to facilitate access to visual elements for blind people"; +"readium.a11y.additional-accessibility-information-tactile-objects" = "Tactile 3D objects"; +"readium.a11y.additional-accessibility-information-text-to-speech-hinting" = "Text-to-speech hinting provided"; +"readium.a11y.additional-accessibility-information-title" = "Additional accessibility information"; +"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" = "Ultra high contrast between text and background"; +"readium.a11y.additional-accessibility-information-visible-page-numbering" = "Visible page numbering"; +"readium.a11y.additional-accessibility-information-without-background-sounds" = "Without background sounds"; "readium.a11y.conformance-a-compact" = "This publication meets minimum accessibility standards"; "readium.a11y.conformance-a-descriptive" = "The publication contains a conformance statement that it meets the EPUB Accessibility and WCAG 2 Level A standard"; "readium.a11y.conformance-aa-compact" = "This publication meets accepted accessibility standards"; "readium.a11y.conformance-aa-descriptive" = "The publication contains a conformance statement that it meets the EPUB Accessibility and WCAG 2 Level AA standard"; "readium.a11y.conformance-aaa-compact" = "This publication exceeds accepted accessibility standards"; "readium.a11y.conformance-aaa-descriptive" = "The publication contains a conformance statement that it meets the EPUB Accessibility and WCAG 2 Level AAA standard"; -"readium.a11y.conformance-certifier-compact" = "The publication was certified by "; -"readium.a11y.conformance-certifier-descriptive" = "The publication was certified by "; -"readium.a11y.conformance-certifier-credentials-compact" = "The certifier's credential is "; -"readium.a11y.conformance-certifier-credentials-descriptive" = "The certifier's credential is "; -"readium.a11y.conformance-details-certification-info-compact" = "The publication was certified on "; -"readium.a11y.conformance-details-certification-info-descriptive" = "The publication was certified on "; -"readium.a11y.conformance-details-certifier-report-compact" = "For more information refer to the certifier's report"; -"readium.a11y.conformance-details-certifier-report-descriptive" = "For more information refer to the certifier's report"; -"readium.a11y.conformance-details-claim-compact" = "This publication claims to meet"; -"readium.a11y.conformance-details-claim-descriptive" = "This publication claims to meet"; -"readium.a11y.conformance-details-epub-accessibility-1-0-compact" = " EPUB Accessibility 1.0"; -"readium.a11y.conformance-details-epub-accessibility-1-0-descriptive" = " EPUB Accessibility 1.0"; -"readium.a11y.conformance-details-epub-accessibility-1-1-compact" = " EPUB Accessibility 1.1"; -"readium.a11y.conformance-details-epub-accessibility-1-1-descriptive" = " EPUB Accessibility 1.1"; -"readium.a11y.conformance-details-level-a-compact" = " Level A"; -"readium.a11y.conformance-details-level-a-descriptive" = " Level A"; -"readium.a11y.conformance-details-level-aa-compact" = " Level AA"; -"readium.a11y.conformance-details-level-aa-descriptive" = " Level AA"; -"readium.a11y.conformance-details-level-aaa-compact" = " Level AAA"; -"readium.a11y.conformance-details-level-aaa-descriptive" = " Level AAA"; -"readium.a11y.conformance-details-wcag-2-0-compact" = " WCAG 2.0"; -"readium.a11y.conformance-details-wcag-2-0-descriptive" = " Web Content Accessibility Guidelines (WCAG) 2.0"; -"readium.a11y.conformance-details-wcag-2-1-compact" = " WCAG 2.1"; -"readium.a11y.conformance-details-wcag-2-1-descriptive" = " Web Content Accessibility Guidelines (WCAG) 2.1"; -"readium.a11y.conformance-details-wcag-2-2-compact" = " WCAG 2.2"; -"readium.a11y.conformance-details-wcag-2-2-descriptive" = " Web Content Accessibility Guidelines (WCAG) 2.2"; -"readium.a11y.conformance-no-compact" = "No information is available"; -"readium.a11y.conformance-no-descriptive" = "No information is available"; -"readium.a11y.conformance-unknown-standard-compact" = "Conformance to accepted standards for accessibility of this publication cannot be determined"; -"readium.a11y.conformance-unknown-standard-descriptive" = "Conformance to accepted standards for accessibility of this publication cannot be determined"; -"readium.a11y.navigation-title" = "Navigation"; -"readium.a11y.navigation-index-compact" = "Index"; -"readium.a11y.navigation-index-descriptive" = "Index with links to referenced entries"; -"readium.a11y.navigation-no-metadata-compact" = "No information is available"; -"readium.a11y.navigation-no-metadata-descriptive" = "No information is available"; -"readium.a11y.navigation-page-navigation-compact" = "Go to page"; -"readium.a11y.navigation-page-navigation-descriptive" = "Page list to go to pages from the print source version"; -"readium.a11y.navigation-structural-compact" = "Headings"; -"readium.a11y.navigation-structural-descriptive" = "Elements such as headings, tables, etc for structured navigation"; -"readium.a11y.navigation-toc-compact" = "Table of contents"; -"readium.a11y.navigation-toc-descriptive" = "Table of contents to all chapters of the text via links"; -"readium.a11y.rich-content-title" = "Rich content"; -"readium.a11y.rich-content-accessible-chemistry-as-latex-compact" = "Chemical formulas in LaTeX"; -"readium.a11y.rich-content-accessible-chemistry-as-latex-descriptive" = "Chemical formulas in accessible format (LaTeX)"; -"readium.a11y.rich-content-accessible-chemistry-as-mathml-compact" = "Chemical formulas in MathML"; -"readium.a11y.rich-content-accessible-chemistry-as-mathml-descriptive" = "Chemical formulas in accessible format (MathML)"; -"readium.a11y.rich-content-accessible-math-as-latex-compact" = "Math as LaTeX"; -"readium.a11y.rich-content-accessible-math-as-latex-descriptive" = "Math formulas in accessible format (LaTeX)"; -"readium.a11y.rich-content-accessible-math-as-mathml-compact" = "Math as MathML"; -"readium.a11y.rich-content-accessible-math-as-mathml-descriptive" = "Math formulas in accessible format (MathML)"; -"readium.a11y.rich-content-accessible-math-described-compact" = "Text descriptions of math are provided"; -"readium.a11y.rich-content-accessible-math-described-descriptive" = "Text descriptions of math are provided"; -"readium.a11y.rich-content-closed-captions-compact" = "Videos have closed captions"; -"readium.a11y.rich-content-closed-captions-descriptive" = "Videos included in publications have closed captions"; -"readium.a11y.rich-content-extended-compact" = "Information-rich images are described by extended descriptions"; -"readium.a11y.rich-content-extended-descriptive" = "Information-rich images are described by extended descriptions"; -"readium.a11y.rich-content-open-captions-compact" = "Videos have open captions"; -"readium.a11y.rich-content-open-captions-descriptive" = "Videos included in publications have open captions"; -"readium.a11y.rich-content-transcript-compact" = "Transcript(s) provided"; -"readium.a11y.rich-content-transcript-descriptive" = "Transcript(s) provided"; -"readium.a11y.rich-content-unknown-compact" = "No information is available"; -"readium.a11y.rich-content-unknown-descriptive" = "No information is available"; -"readium.a11y.hazards-title" = "Hazards"; +"readium.a11y.conformance-certifier" = "The publication was certified by "; +"readium.a11y.conformance-certifier-credentials" = "The certifier's credential is "; +"readium.a11y.conformance-details-certification-info" = "The publication was certified on "; +"readium.a11y.conformance-details-certifier-report" = "For more information refer to the certifier's report"; +"readium.a11y.conformance-details-claim" = "This publication claims to meet"; +"readium.a11y.conformance-details-epub-accessibility-1-0" = "EPUB Accessibility 1.0"; +"readium.a11y.conformance-details-epub-accessibility-1-1" = "EPUB Accessibility 1.1"; +"readium.a11y.conformance-details-level-a" = "Level A"; +"readium.a11y.conformance-details-level-aa" = "Level AA"; +"readium.a11y.conformance-details-level-aaa" = "Level AAA"; +"readium.a11y.conformance-details-wcag-2-0-compact" = "WCAG 2.0"; +"readium.a11y.conformance-details-wcag-2-0-descriptive" = "Web Content Accessibility Guidelines (WCAG) 2.0"; +"readium.a11y.conformance-details-wcag-2-1-compact" = "WCAG 2.1"; +"readium.a11y.conformance-details-wcag-2-1-descriptive" = "Web Content Accessibility Guidelines (WCAG) 2.1"; +"readium.a11y.conformance-details-wcag-2-2-compact" = "WCAG 2.2"; +"readium.a11y.conformance-details-wcag-2-2-descriptive" = "Web Content Accessibility Guidelines (WCAG) 2.2"; +"readium.a11y.conformance-details-title" = "Detailed conformance information"; +"readium.a11y.conformance-no" = "No information is available"; +"readium.a11y.conformance-title" = "Conformance"; +"readium.a11y.conformance-unknown-standard" = "Conformance to accepted standards for accessibility of this publication cannot be determined"; "readium.a11y.hazards-flashing-compact" = "Flashing content"; "readium.a11y.hazards-flashing-descriptive" = "The publication contains flashing content that can cause photosensitive seizures"; "readium.a11y.hazards-flashing-none-compact" = "No flashing hazards"; @@ -108,8 +63,7 @@ "readium.a11y.hazards-motion-none-descriptive" = "The publication does not contain motion simulations that can cause motion sickness"; "readium.a11y.hazards-motion-unknown-compact" = "Motion simulation hazards not known"; "readium.a11y.hazards-motion-unknown-descriptive" = "The presence of motion simulations that can cause motion sickness could not be determined"; -"readium.a11y.hazards-no-metadata-compact" = "No information is available"; -"readium.a11y.hazards-no-metadata-descriptive" = "No information is available"; +"readium.a11y.hazards-no-metadata" = "No information is available"; "readium.a11y.hazards-none-compact" = "No hazards"; "readium.a11y.hazards-none-descriptive" = "The publication contains no hazards"; "readium.a11y.hazards-sound-compact" = "Sounds"; @@ -118,52 +72,58 @@ "readium.a11y.hazards-sound-none-descriptive" = "The publication does not contain sounds that can cause sensitivity issues"; "readium.a11y.hazards-sound-unknown-compact" = "Sound hazards not known"; "readium.a11y.hazards-sound-unknown-descriptive" = "The presence of sounds that can cause sensitivity issues could not be determined"; -"readium.a11y.hazards-unknown-compact" = "The presence of hazards is unknown"; -"readium.a11y.hazards-unknown-descriptive" = "The presence of hazards is unknown"; -"readium.a11y.accessibility-summary-title" = "Accessibility summary"; -"readium.a11y.accessibility-summary-no-metadata-compact" = "No information is available"; -"readium.a11y.accessibility-summary-no-metadata-descriptive" = "No information is available"; -"readium.a11y.accessibility-summary-publisher-contact-compact" = "For more information about the accessibility of this product, please contact the publisher: "; -"readium.a11y.accessibility-summary-publisher-contact-descriptive" = "For more information about the accessibility of this product, please contact the publisher: "; -"readium.a11y.legal-considerations-title" = "Legal considerations"; +"readium.a11y.hazards-title" = "Hazards"; +"readium.a11y.hazards-unknown" = "The presence of hazards is unknown"; "readium.a11y.legal-considerations-exempt-compact" = "Claims an accessibility exemption in some jurisdictions"; "readium.a11y.legal-considerations-exempt-descriptive" = "This publication claims an accessibility exemption in some jurisdictions"; -"readium.a11y.legal-considerations-no-metadata-compact" = "No information is available"; -"readium.a11y.legal-considerations-no-metadata-descriptive" = "No information is available"; -"readium.a11y.additional-accessibility-information-title" = "Additional accessibility information"; -"readium.a11y.additional-accessibility-information-aria-compact" = "ARIA roles included"; -"readium.a11y.additional-accessibility-information-aria-descriptive" = "Content is enhanced with ARIA roles to optimize organization and facilitate navigation"; -"readium.a11y.additional-accessibility-information-audio-descriptions-compact" = "Audio descriptions"; -"readium.a11y.additional-accessibility-information-audio-descriptions-descriptive" = "Audio descriptions"; -"readium.a11y.additional-accessibility-information-braille-compact" = "Braille"; -"readium.a11y.additional-accessibility-information-braille-descriptive" = "Braille"; -"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information-compact" = "Color is not the sole means of conveying information"; -"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information-descriptive" = "Color is not the sole means of conveying information"; -"readium.a11y.additional-accessibility-information-dyslexia-readability-compact" = "Dyslexia readability"; -"readium.a11y.additional-accessibility-information-dyslexia-readability-descriptive" = "Dyslexia readability"; -"readium.a11y.additional-accessibility-information-full-ruby-annotations-compact" = "Full ruby annotations"; -"readium.a11y.additional-accessibility-information-full-ruby-annotations-descriptive" = "Full ruby annotations"; -"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio-compact" = "High contrast between foreground and background audio"; -"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio-descriptive" = "High contrast between foreground and background audio"; -"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background-compact" = "High contrast between foreground text and background"; -"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background-descriptive" = "High contrast between foreground text and background"; -"readium.a11y.additional-accessibility-information-large-print-compact" = "Large print"; -"readium.a11y.additional-accessibility-information-large-print-descriptive" = "Large print"; -"readium.a11y.additional-accessibility-information-page-breaks-compact" = "Page breaks included"; -"readium.a11y.additional-accessibility-information-page-breaks-descriptive" = "Page breaks included from the original print source"; -"readium.a11y.additional-accessibility-information-ruby-annotations-compact" = "Some Ruby annotations"; -"readium.a11y.additional-accessibility-information-ruby-annotations-descriptive" = "Some Ruby annotations"; -"readium.a11y.additional-accessibility-information-sign-language-compact" = "Sign language"; -"readium.a11y.additional-accessibility-information-sign-language-descriptive" = "Sign language"; -"readium.a11y.additional-accessibility-information-tactile-graphics-compact" = "Tactile graphics included"; -"readium.a11y.additional-accessibility-information-tactile-graphics-descriptive" = "Tactile graphics have been integrated to facilitate access to visual elements for blind people"; -"readium.a11y.additional-accessibility-information-tactile-objects-compact" = "Tactile 3D objects"; -"readium.a11y.additional-accessibility-information-tactile-objects-descriptive" = "Tactile 3D objects"; -"readium.a11y.additional-accessibility-information-text-to-speech-hinting-compact" = "Text-to-speech hinting provided"; -"readium.a11y.additional-accessibility-information-text-to-speech-hinting-descriptive" = "Text-to-speech hinting provided"; -"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background-compact" = "Ultra high contrast between text and background"; -"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background-descriptive" = "Ultra high contrast between text and background"; -"readium.a11y.additional-accessibility-information-visible-page-numbering-compact" = "Visible page numbering"; -"readium.a11y.additional-accessibility-information-visible-page-numbering-descriptive" = "Visible page numbering"; -"readium.a11y.additional-accessibility-information-without-background-sounds-compact" = "Without background sounds"; -"readium.a11y.additional-accessibility-information-without-background-sounds-descriptive" = "Without background sounds"; +"readium.a11y.legal-considerations-no-metadata" = "No information is available"; +"readium.a11y.legal-considerations-title" = "Legal considerations"; +"readium.a11y.navigation-index-compact" = "Index"; +"readium.a11y.navigation-index-descriptive" = "Index with links to referenced entries"; +"readium.a11y.navigation-no-metadata" = "No information is available"; +"readium.a11y.navigation-page-navigation-compact" = "Go to page"; +"readium.a11y.navigation-page-navigation-descriptive" = "Page list to go to pages from the print source version"; +"readium.a11y.navigation-structural-compact" = "Headings"; +"readium.a11y.navigation-structural-descriptive" = "Elements such as headings, tables, etc for structured navigation"; +"readium.a11y.navigation-title" = "Navigation"; +"readium.a11y.navigation-toc-compact" = "Table of contents"; +"readium.a11y.navigation-toc-descriptive" = "Table of contents to all chapters of the text via links"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-compact" = "Chemical formulas in LaTeX"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-descriptive" = "Chemical formulas in accessible format (LaTeX)"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-compact" = "Chemical formulas in MathML"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-descriptive" = "Chemical formulas in accessible format (MathML)"; +"readium.a11y.rich-content-accessible-math-as-latex-compact" = "Math as LaTeX"; +"readium.a11y.rich-content-accessible-math-as-latex-descriptive" = "Math formulas in accessible format (LaTeX)"; +"readium.a11y.rich-content-accessible-math-described" = "Text descriptions of math are provided"; +"readium.a11y.rich-content-closed-captions-compact" = "Videos have closed captions"; +"readium.a11y.rich-content-closed-captions-descriptive" = "Videos included in publications have closed captions"; +"readium.a11y.rich-content-extended-descriptions" = "Information-rich images are described by extended descriptions"; +"readium.a11y.rich-content-math-as-mathml-compact" = "Math as MathML"; +"readium.a11y.rich-content-math-as-mathml-descriptive" = "Math formulas in accessible format (MathML)"; +"readium.a11y.rich-content-open-captions-compact" = "Videos have open captions"; +"readium.a11y.rich-content-open-captions-descriptive" = "Videos included in publications have open captions"; +"readium.a11y.rich-content-title" = "Rich content"; +"readium.a11y.rich-content-transcript" = "Transcript(s) provided"; +"readium.a11y.rich-content-unknown" = "No information is available"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-compact" = "Has alternative text"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-descriptive" = "Has alternative text descriptions for images"; +"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" = "No information about nonvisual reading is available"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-compact" = "Not readable in read aloud or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-descriptive" = "The content is not readable as read aloud speech or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-compact" = "Not fully readable in read aloud or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-descriptive" = "Not all of the content will be readable as read aloud speech or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-compact" = "Readable in read aloud or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-descriptive" = "All content can be read as read aloud speech or dynamic braille"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-compact" = "Prerecorded audio clips"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-descriptive" = "Prerecorded audio clips are embedded in the content"; +"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" = "No information about prerecorded audio is available"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-compact" = "Prerecorded audio only"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-descriptive" = "Audiobook with no text alternative"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-compact" = "Prerecorded audio synchronized with text"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-descriptive" = "All the content is available as prerecorded audio synchronized with text"; +"readium.a11y.ways-of-reading-title" = "Ways of reading"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-compact" = "Appearance can be modified"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-descriptive" = "Appearance of the text and page layout can be modified according to the capabilities of the reading system (font family and font size, spaces between paragraphs, sentences, words, and letters, as well as color of background and text)"; +"readium.a11y.ways-of-reading-visual-adjustments-unknown" = "No information about appearance modifiability is available"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-compact" = "Appearance cannot be modified"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-descriptive" = "Text and page layout cannot be modified as the reading experience is close to a print version, but reading systems can still provide zooming options"; diff --git a/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings b/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings new file mode 100644 index 0000000000..44f03cd8e9 --- /dev/null +++ b/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings @@ -0,0 +1,129 @@ +// DO NOT EDIT. File generated automatically from the fr JSON strings of https://github.com/edrlab/thorium-locales/. + +"readium.a11y.accessibility-summary-no-metadata" = "Aucune information disponible"; +"readium.a11y.accessibility-summary-publisher-contact" = "Pour plus d'information à propos de l'accessibilité de cette publication, veuillez contacter l'éditeur : "; +"readium.a11y.accessibility-summary-title" = "Informations d'accessibilité supplémentaires fournies par l'éditeur"; +"readium.a11y.additional-accessibility-information-aria-compact" = "Information enrichie pour les technologies d'assistances"; +"readium.a11y.additional-accessibility-information-aria-descriptive" = "La structure est enrichi de rôles ARIA afin d'optimiser l'organisation et de faciliter la navigation via les technologies d'assistances"; +"readium.a11y.additional-accessibility-information-audio-descriptions" = "Description audio"; +"readium.a11y.additional-accessibility-information-braille" = "Braille"; +"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" = "La couleur n'est pas la seule manière de communiquer de l'information"; +"readium.a11y.additional-accessibility-information-dyslexia-readability" = "Lisibilité adapté aux publics dys"; +"readium.a11y.additional-accessibility-information-full-ruby-annotations" = "Annotations complètes au format ruby (langues asiatiques)"; +"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" = "Contraste sonore amélioré entre les différents plans"; +"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" = "Contraste élevé entre le texte et l'arrière-plan"; +"readium.a11y.additional-accessibility-information-large-print" = "Grands caractères"; +"readium.a11y.additional-accessibility-information-page-breaks-compact" = "Pagination identique à l'imprimé"; +"readium.a11y.additional-accessibility-information-page-breaks-descriptive" = "Contient une pagination identique à la version imprimée"; +"readium.a11y.additional-accessibility-information-ruby-annotations" = "Annotations partielles au format ruby (langues asiatiques)"; +"readium.a11y.additional-accessibility-information-sign-language" = "Langue des signes"; +"readium.a11y.additional-accessibility-information-tactile-graphics-compact" = "Graphiques tactiles"; +"readium.a11y.additional-accessibility-information-tactile-graphics-descriptive" = "Des graphiques tactiles ont été intégrés pour faciliter l'accès des personnes aveugles aux éléments visuels"; +"readium.a11y.additional-accessibility-information-tactile-objects" = "Objets 3D ou tactiles"; +"readium.a11y.additional-accessibility-information-text-to-speech-hinting" = "Prononciation améliorée pour la synthèse vocale"; +"readium.a11y.additional-accessibility-information-title" = "Informations complémentaires sur l'accessibilité"; +"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" = "Contraste très élevé entre le texte et l'arrière-plan"; +"readium.a11y.additional-accessibility-information-visible-page-numbering" = "Numérotation de page visible"; +"readium.a11y.additional-accessibility-information-without-background-sounds" = "Aucun bruit de fond"; +"readium.a11y.conformance-a-compact" = "Cette publication répond aux règles minimales d'accessibilité"; +"readium.a11y.conformance-a-descriptive" = "La publication indique qu'elle respecte les règles d'accessibilité EPUB et WCAG 2 niveau A"; +"readium.a11y.conformance-aa-compact" = "Cette publication répond aux règles d'accessibilité reconnues"; +"readium.a11y.conformance-aa-descriptive" = "La publication indique qu'elle respecte les règles d'accessibilité EPUB et WCAG 2 niveau AA"; +"readium.a11y.conformance-aaa-compact" = "Cette publication dépasse les règles d'accessibilité reconnues"; +"readium.a11y.conformance-aaa-descriptive" = "La publication indique qu'elle respecte les règles d'accessibilité EPUB et WCAG 2 niveau AAA"; +"readium.a11y.conformance-certifier" = "Accessibilité évaluée par "; +"readium.a11y.conformance-certifier-credentials" = "L'évaluateur est accrédité par "; +"readium.a11y.conformance-details-certification-info" = "Cette publication a été certifié le"; +"readium.a11y.conformance-details-certifier-report" = "Pour plus d'information, veuillez consulter le rapport de certification"; +"readium.a11y.conformance-details-claim" = "Cette publication indique respecter"; +"readium.a11y.conformance-details-epub-accessibility-1-0" = "EPUB Accessibilité 1.0"; +"readium.a11y.conformance-details-epub-accessibility-1-1" = "EPUB Accessibilité 1.1"; +"readium.a11y.conformance-details-level-a" = "Niveau A"; +"readium.a11y.conformance-details-level-aa" = "Niveau AA"; +"readium.a11y.conformance-details-level-aaa" = "Niveau AAA"; +"readium.a11y.conformance-details-wcag-2-0-compact" = "WCAG 2.0"; +"readium.a11y.conformance-details-wcag-2-0-descriptive" = "Règles pour l’accessibilité des contenus Web (WCAG) 2.0"; +"readium.a11y.conformance-details-wcag-2-1-compact" = "WCAG 2.1"; +"readium.a11y.conformance-details-wcag-2-1-descriptive" = "Règles pour l’accessibilité des contenus Web (WCAG) 2.1"; +"readium.a11y.conformance-details-wcag-2-2-compact" = "WCAG 2.2"; +"readium.a11y.conformance-details-wcag-2-2-descriptive" = "Règles pour l’accessibilité des contenus Web (WCAG) 2.2"; +"readium.a11y.conformance-details-title" = "Information détaillée"; +"readium.a11y.conformance-no" = "Aucune information disponible"; +"readium.a11y.conformance-title" = "Règles d'accessibilité"; +"readium.a11y.conformance-unknown-standard" = "Aucune indication concernant les normes d'accessibilité"; +"readium.a11y.hazards-flashing-compact" = "Flashs lumineux"; +"readium.a11y.hazards-flashing-descriptive" = "La publication contient des flashs lumineux qui peuvent provoquer des crises d’épilepsie"; +"readium.a11y.hazards-flashing-none-compact" = "Pas de flashs lumineux"; +"readium.a11y.hazards-flashing-none-descriptive" = "La publication ne contient pas de flashs lumineux susceptibles de provoquer des crises d’épilepsie"; +"readium.a11y.hazards-flashing-unknown-compact" = "Pas d'information concernant la présence de flashs lumineux"; +"readium.a11y.hazards-flashing-unknown-descriptive" = "La présence de flashs lumineux susceptibles de provoquer des crises d’épilepsie n'a pas pu être déterminée"; +"readium.a11y.hazards-motion-compact" = "Sensations de mouvement"; +"readium.a11y.hazards-motion-descriptive" = "La publication contient des images en mouvement qui peuvent provoquer des nausées, des vertiges et des maux de tête"; +"readium.a11y.hazards-motion-none-compact" = "Pas de sensations de mouvement"; +"readium.a11y.hazards-motion-none-descriptive" = "La publication ne contient pas d'images en mouvement qui pourraient provoquer des nausées, des vertiges et des maux de tête"; +"readium.a11y.hazards-motion-unknown-compact" = "Pas d'information concernant la présence d'images en mouvement"; +"readium.a11y.hazards-motion-unknown-descriptive" = "La présence d'images en mouvement susceptibles de provoquer des nausées, des vertiges et des maux de tête n'a pas pu être déterminée"; +"readium.a11y.hazards-no-metadata" = "Aucune information disponible"; +"readium.a11y.hazards-none-compact" = "Aucun points d'attention"; +"readium.a11y.hazards-none-descriptive" = "La publication ne présente aucun risque lié à la présence de flashs lumineux, de sensations de mouvement ou de sons"; +"readium.a11y.hazards-sound-compact" = "Sons"; +"readium.a11y.hazards-sound-descriptive" = "La publication contient des sons qui peuvent causer des troubles de la sensibilité"; +"readium.a11y.hazards-sound-none-compact" = "Pas de risques sonores"; +"readium.a11y.hazards-sound-none-descriptive" = "La publication ne contient pas de sons susceptibles de provoquer des troubles de la sensibilité"; +"readium.a11y.hazards-sound-unknown-compact" = "Pas d'information concernant la présence de sons"; +"readium.a11y.hazards-sound-unknown-descriptive" = "La présence de sons susceptibles de causer des troubles de sensibilité n'a pas pu être déterminée"; +"readium.a11y.hazards-title" = "Points d'attention"; +"readium.a11y.hazards-unknown" = "La présence de risques est inconnue"; +"readium.a11y.legal-considerations-exempt-compact" = "Déclare être sous le coup d'une exemption dans certaines juridictions"; +"readium.a11y.legal-considerations-exempt-descriptive" = "Cette publication dééclare être sous le coup d'une exemption dans certaines juridictions"; +"readium.a11y.legal-considerations-no-metadata" = "Aucune information disponible"; +"readium.a11y.legal-considerations-title" = "Considérations légales"; +"readium.a11y.navigation-index-compact" = "Index"; +"readium.a11y.navigation-index-descriptive" = "Index comportant des liens vers les entrées référencées"; +"readium.a11y.navigation-no-metadata" = "Aucune information disponible"; +"readium.a11y.navigation-page-navigation-compact" = "Aller à la page"; +"readium.a11y.navigation-page-navigation-descriptive" = "Permet d'accéder aux pages de la version source imprimée"; +"readium.a11y.navigation-structural-compact" = "Titres"; +"readium.a11y.navigation-structural-descriptive" = "Contient des titres pour une navigation structurée"; +"readium.a11y.navigation-title" = "Points de repère"; +"readium.a11y.navigation-toc-compact" = "Table des matières"; +"readium.a11y.navigation-toc-descriptive" = "Table des matières"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-compact" = "Formules chimiques en LaTeX"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-descriptive" = "Formules chimiques en format accessible (LaTeX)"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-compact" = "Formules chimiques en MathML"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-descriptive" = "Formules chimiques en format accessible (MathML)"; +"readium.a11y.rich-content-accessible-math-as-latex-compact" = "Mathématiques en LaTeX"; +"readium.a11y.rich-content-accessible-math-as-latex-descriptive" = "Formules mathématiques en format accessible (LaTeX)"; +"readium.a11y.rich-content-accessible-math-described" = "Des descriptions textuelles des formules mathématiques sont fournies"; +"readium.a11y.rich-content-closed-captions-compact" = "Sous-titres disponibles pour les vidéos"; +"readium.a11y.rich-content-closed-captions-descriptive" = "Des sous titres sont disponibles pour les vidéos"; +"readium.a11y.rich-content-extended-descriptions" = "Les images porteuses d'informations complexes sont décrites par des descriptions longues"; +"readium.a11y.rich-content-math-as-mathml-compact" = "Mathématiques en MathML"; +"readium.a11y.rich-content-math-as-mathml-descriptive" = "Formules mathématiques en format accessible (MathML)"; +"readium.a11y.rich-content-open-captions-compact" = "Sous-titres incrustés"; +"readium.a11y.rich-content-open-captions-descriptive" = "Des sous titres sont incrustés pour les vidéos"; +"readium.a11y.rich-content-title" = "Contenus spécifiques"; +"readium.a11y.rich-content-transcript" = "Transcriptions fournies"; +"readium.a11y.rich-content-unknown" = "Aucune information disponible"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-compact" = "Images décrites"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-descriptive" = "Les images sont décrites par un texte"; +"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" = "Aucune information pour la lecture en voix de synthèse ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-compact" = "Non lisible en voix de synthèse ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-descriptive" = "Le contenu n'est pas lisible en voix de synthèse ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-compact" = "Pas entièrement lisible en voix de synthèse ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-descriptive" = "Tous les contenus ne pourront pas être lus à haute voix ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-compact" = "Entièrement lisible en voix de synthèse ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-descriptive" = "Tous les contenus peuvent être lus en voix de synthèse ou en braille"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-compact" = "Clips audio préenregistrés"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-descriptive" = "Des clips audio préenregistrés sont intégrés au contenu"; +"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" = "Aucune information sur les enregistrements audio"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-compact" = "Audio préenregistré uniquement"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-descriptive" = "Livre audio sans texte alternatif"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-compact" = "Audio préenregistré synchronisé avec du texte"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-descriptive" = "Tous les contenus sont disponibles comme audio préenregistrés synchronisés avec le texte"; +"readium.a11y.ways-of-reading-title" = "Lisibilité"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-compact" = "L'affichage peut être adapté"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-descriptive" = "L'apparence du texte et la mise en page peuvent être modifiées en fonction des capacités du système de lecture (famille et taille des polices, espaces entre les paragraphes, les phrases, les mots et les lettres, ainsi que la couleur de l'arrière-plan et du texte)"; +"readium.a11y.ways-of-reading-visual-adjustments-unknown" = "Aucune information sur les possibilités d'adaptation de l'affichage"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-compact" = "L'affichage ne peut pas être adapté"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-descriptive" = "Le texte et la mise en page ne peuvent pas être adaptés étant donné que l'expérience de lecture est proche de celle de la version imprimée, mais l'application de lecture peut tout de même proposer la capacité de zoomer"; diff --git a/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings b/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings new file mode 100644 index 0000000000..76133ad4fc --- /dev/null +++ b/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings @@ -0,0 +1,129 @@ +// DO NOT EDIT. File generated automatically from the it JSON strings of https://github.com/edrlab/thorium-locales/. + +"readium.a11y.accessibility-summary-no-metadata" = "Nessuna informazione disponibile"; +"readium.a11y.accessibility-summary-publisher-contact" = "Per ulteriori informazioni sull'accessibilità di questa risorsa, contattare l'editore: "; +"readium.a11y.accessibility-summary-title" = "Informazioni aggiuntive sull'accessibilità fornite dall'editore"; +"readium.a11y.additional-accessibility-information-aria-compact" = "Ruoli ARIA inclusi"; +"readium.a11y.additional-accessibility-information-aria-descriptive" = "Il contenuto è semanticamente arricchito con ruoli ARIA per ottimizzare l'organizzazione e facilitare la navigazione"; +"readium.a11y.additional-accessibility-information-audio-descriptions" = "Descrizioni audio"; +"readium.a11y.additional-accessibility-information-braille" = "Braille"; +"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" = "Il colore non è l'unico mezzo per trasmettere informazioni"; +"readium.a11y.additional-accessibility-information-dyslexia-readability" = "Leggibilità adatta alla dislessia"; +"readium.a11y.additional-accessibility-information-full-ruby-annotations" = "Annotazioni complete in Ruby"; +"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" = "Elevato contrasto tra audio principale e sottofondo"; +"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" = "Contrasto elevato tra testo in primo piano e sfondo"; +"readium.a11y.additional-accessibility-information-large-print" = "Stampa a caratteri ingranditi"; +"readium.a11y.additional-accessibility-information-page-breaks-compact" = "Interruzioni di pagina incluse"; +"readium.a11y.additional-accessibility-information-page-breaks-descriptive" = "Interruzioni di pagina identiche alla versione originale a stampa"; +"readium.a11y.additional-accessibility-information-ruby-annotations" = "Alcune annotazioni in Ruby"; +"readium.a11y.additional-accessibility-information-sign-language" = "Lingua dei segni"; +"readium.a11y.additional-accessibility-information-tactile-graphics-compact" = "Grafica tattile inclusa"; +"readium.a11y.additional-accessibility-information-tactile-graphics-descriptive" = "La grafica tattile è stata integrata per facilitare l'accesso agli elementi visivi alle persone non vedenti"; +"readium.a11y.additional-accessibility-information-tactile-objects" = "Oggetti 3D tattili"; +"readium.a11y.additional-accessibility-information-text-to-speech-hinting" = "Pronuncia migliorata per la sintesi vocale"; +"readium.a11y.additional-accessibility-information-title" = "Ulteriori informazioni sull'accessibilità"; +"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" = "Contrasto molto elevato tra testo e sfondo"; +"readium.a11y.additional-accessibility-information-visible-page-numbering" = "Numerazione delle pagine visibile"; +"readium.a11y.additional-accessibility-information-without-background-sounds" = "Nessun suono in sottofondo"; +"readium.a11y.conformance-a-compact" = "Questa pubblicazione soddisfa gli standard minimi di accessibilità"; +"readium.a11y.conformance-a-descriptive" = "La pubblicazione contiene una dichiarazione di conformità che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello A"; +"readium.a11y.conformance-aa-compact" = "Questa pubblicazione soddisfa gli standard di accessibilità accettati"; +"readium.a11y.conformance-aa-descriptive" = "La pubblicazione contiene una dichiarazione di conformità che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello AAA"; +"readium.a11y.conformance-aaa-compact" = "Questa pubblicazione supera gli standard di accessibilità"; +"readium.a11y.conformance-aaa-descriptive" = "La pubblicazione contiene una dichiarazione di conformità che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello AAA"; +"readium.a11y.conformance-certifier" = "La pubblicazione è stata certificata da "; +"readium.a11y.conformance-certifier-credentials" = "Le credenziali del certificatore sono "; +"readium.a11y.conformance-details-certification-info" = "La pubblicazione è stata certificata il "; +"readium.a11y.conformance-details-certifier-report" = "Per ulteriori informazioni, consultare il report di accessibilità del certificatore"; +"readium.a11y.conformance-details-claim" = "Questa pubblicazione è conforme ai requisiti di"; +"readium.a11y.conformance-details-epub-accessibility-1-0" = "EPUB Accessibility 1.0"; +"readium.a11y.conformance-details-epub-accessibility-1-1" = "EPUB Accessibility 1.1"; +"readium.a11y.conformance-details-level-a" = "Livello A"; +"readium.a11y.conformance-details-level-aa" = "Livello AA"; +"readium.a11y.conformance-details-level-aaa" = "Livello AAA"; +"readium.a11y.conformance-details-wcag-2-0-compact" = "WCAG 2.0"; +"readium.a11y.conformance-details-wcag-2-0-descriptive" = "Linee guida per l'accessibilità dei contenuti web (WCAG) 2.0"; +"readium.a11y.conformance-details-wcag-2-1-compact" = "WCAG 2.1"; +"readium.a11y.conformance-details-wcag-2-1-descriptive" = "Linee guida per l'accessibilità dei contenuti web (WCAG) 2.1"; +"readium.a11y.conformance-details-wcag-2-2-compact" = "WCAG 2.2"; +"readium.a11y.conformance-details-wcag-2-2-descriptive" = "Linee guida per l'accessibilità dei contenuti web (WCAG) 2.2"; +"readium.a11y.conformance-details-title" = "Informazioni dettagliate sulla conformità"; +"readium.a11y.conformance-no" = "Nessuna informazione disponibile"; +"readium.a11y.conformance-title" = "Conformità"; +"readium.a11y.conformance-unknown-standard" = "Nessuna indicazione sugli standard d'accessibilità"; +"readium.a11y.hazards-flashing-compact" = "Contenuto lampeggiante"; +"readium.a11y.hazards-flashing-descriptive" = "La pubblicazione contiene contenuti lampeggianti che possono causare crisi d'epilessia fotosensibile"; +"readium.a11y.hazards-flashing-none-compact" = "Nessun contenuto lampeggiante"; +"readium.a11y.hazards-flashing-none-descriptive" = "La pubblicazione non presenta contenuti lampeggianti che possono causare crisi d'epilessia fotosensibile"; +"readium.a11y.hazards-flashing-unknown-compact" = "Nessuna informazione sulla presenza di contenuti lampeggianti"; +"readium.a11y.hazards-flashing-unknown-descriptive" = "Non è stato possibile determinare la presenza di contenuti lampeggianti che possono causare crisi d'epilessia fotosensibile"; +"readium.a11y.hazards-motion-compact" = "Simulazione del movimento"; +"readium.a11y.hazards-motion-descriptive" = "La pubblicazione contiene simulazioni di movimento che possono provocare cinetosi"; +"readium.a11y.hazards-motion-none-compact" = "Nessun rischio di simulazione del movimento"; +"readium.a11y.hazards-motion-none-descriptive" = "La pubblicazione non contiene simulazioni di movimento che possono causare la malattia di movimento"; +"readium.a11y.hazards-motion-unknown-compact" = "Nessuna informazione relativa alla presenza di simulazioni di movimento"; +"readium.a11y.hazards-motion-unknown-descriptive" = "Non è stato possibile determinare la presenza di contenuti che possono provocare cinetosi"; +"readium.a11y.hazards-no-metadata" = "Nessuna informazione disponibile"; +"readium.a11y.hazards-none-compact" = "Nessuna problematica"; +"readium.a11y.hazards-none-descriptive" = "La pubblicazione non presenta contenuti a rischio di simulazione di movimento, di suoni, o di contenuti lampeggianti"; +"readium.a11y.hazards-sound-compact" = "Suoni"; +"readium.a11y.hazards-sound-descriptive" = "La pubblicazione contiene suoni che possono causare problemi di sensibilità"; +"readium.a11y.hazards-sound-none-compact" = "Nessun rischio acustico"; +"readium.a11y.hazards-sound-none-descriptive" = "La pubblicazione non contiene suoni che possono causare problemi di sensibilità"; +"readium.a11y.hazards-sound-unknown-compact" = "Nessuna informazione sulla presenza di suoni"; +"readium.a11y.hazards-sound-unknown-descriptive" = "Non è stato possibile determinare la presenza di suoni che potrebbero causare problemi di sensibilità"; +"readium.a11y.hazards-title" = "Problematiche"; +"readium.a11y.hazards-unknown" = "La presenza di rischi è sconosciuta"; +"readium.a11y.legal-considerations-exempt-compact" = "Dichiara di godere dell'esenzione d'accessibilità in alcune giurisdizioni"; +"readium.a11y.legal-considerations-exempt-descriptive" = "Questa risorsa gode dell'esenzione d'accessibilità in alcune giurisdizioni"; +"readium.a11y.legal-considerations-no-metadata" = "Nessuna informazione disponibile"; +"readium.a11y.legal-considerations-title" = "Note legali"; +"readium.a11y.navigation-index-compact" = "Indice analitico interattivo"; +"readium.a11y.navigation-index-descriptive" = "Indice analitico con link alle voci di riferimento"; +"readium.a11y.navigation-no-metadata" = "Nessuna informazione disponibile"; +"readium.a11y.navigation-page-navigation-compact" = "Vai alla pagina"; +"readium.a11y.navigation-page-navigation-descriptive" = "Sono presenti i riferimenti ai numeri di pagina della versione a stampa corrispondente"; +"readium.a11y.navigation-structural-compact" = "Intestazioni"; +"readium.a11y.navigation-structural-descriptive" = "Contiene elementi come titoli, elenchi e tabelle per permettere una navigazione strutturata"; +"readium.a11y.navigation-title" = "Navigazione"; +"readium.a11y.navigation-toc-compact" = "Indice interattivo"; +"readium.a11y.navigation-toc-descriptive" = "L’indice permette l’accesso diretto a tutti i capitoli tramite link"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-compact" = "Formule chimiche in LaTeX"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-descriptive" = "Formule chimiche in formato accessibile (LaTeX)"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-compact" = "Formule chimiche in MathML"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-descriptive" = "Formule chimiche in formato accessibile (MathML)"; +"readium.a11y.rich-content-accessible-math-as-latex-compact" = "Matematica in LaTeX"; +"readium.a11y.rich-content-accessible-math-as-latex-descriptive" = "Formule matematiche in formato accessibile (LaTeX)"; +"readium.a11y.rich-content-accessible-math-described" = "Sono disponibili descrizioni testuali per le formule matematiche"; +"readium.a11y.rich-content-closed-captions-compact" = "Sottotitoli disponibili per i video"; +"readium.a11y.rich-content-closed-captions-descriptive" = "Per i video sono disponibili dei sottotitoli"; +"readium.a11y.rich-content-extended-descriptions" = "Le immagini complesse presentano descrizioni estese"; +"readium.a11y.rich-content-math-as-mathml-compact" = "Matematica in MathML"; +"readium.a11y.rich-content-math-as-mathml-descriptive" = "Formule matematiche in formato accessibile (MathML)"; +"readium.a11y.rich-content-open-captions-compact" = "I video hanno i sottotitoli"; +"readium.a11y.rich-content-open-captions-descriptive" = "I video inclusi nella pubblicazione hanno i sottotitoli"; +"readium.a11y.rich-content-title" = "Contenuti arricchiti"; +"readium.a11y.rich-content-transcript" = "Trascrizioni fornite"; +"readium.a11y.rich-content-unknown" = "Nessuna informazione disponibile"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-compact" = "Immagini descritte"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-descriptive" = "Le immagini sono descritte da un testo"; +"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" = "Nessuna informazione sulla lettura non visiva"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-compact" = "Non leggibile con lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-descriptive" = "Il contenuto non è leggibile con la lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-compact" = "Non è interamente leggibile con lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-descriptive" = "Non tutti i contenuti potranno essere letti con lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-compact" = "Interamente leggibile con lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-descriptive" = "Tutti i contenuti possono essere letti con la lettura ad alta voce o con il display braille"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-compact" = "Clip audio preregistrate"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-descriptive" = "Le clip audio preregistrate sono integrate nel contenuto"; +"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" = "Non sono disponibili informazioni sull'audio preregistrato"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-compact" = "Solo audio preregistrato"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-descriptive" = "Audiolibro senza testi alternativi"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-compact" = "Audio preregistrato sincronizzato con il testo"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-descriptive" = "Tutti i contenuti sono disponibili come audio preregistrato sincronizzato con il testo"; +"readium.a11y.ways-of-reading-title" = "Leggibilità"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-compact" = "La formattazione del testo e il layout della pagina possono essere modificati"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-descriptive" = "La formattazione del testo e il layout della pagina possono essere modificati in base alle funzionalità presenti nella soluzione di lettura (ingrandimento dei caratteri del testo, modifica dei colori e dei contrasti per il testo e lo sfondo, modifica degli spazi tra lettere, parole, frasi e paragrafi)"; +"readium.a11y.ways-of-reading-visual-adjustments-unknown" = "Non sono disponibili informazioni sulla possibilità di formattare il testo"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-compact" = "La formattazione del testo e il display della pagina non possono essere modificati"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-descriptive" = "Il layout di testo e pagina non può essere modificato poiché l'esperienza di lettura è vicina a una versione di stampa, ma i sistemi di lettura possono ancora fornire opzioni di zoom"; diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 3c5e460dfd..534c32b8a5 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -679,8 +679,12 @@ ../../Sources/Shared/Publication/Subject.swift ../../Sources/Shared/Publication/TDM.swift ../../Sources/Shared/Resources -../../Sources/Shared/Resources/en-US.lproj -../../Sources/Shared/Resources/en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings +../../Sources/Shared/Resources/en.lproj +../../Sources/Shared/Resources/en.lproj/W3CAccessibilityMetadataDisplayGuide.strings +../../Sources/Shared/Resources/fr.lproj +../../Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings +../../Sources/Shared/Resources/it.lproj +../../Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings ../../Sources/Shared/Toolkit ../../Sources/Shared/Toolkit/Archive ../../Sources/Shared/Toolkit/Archive/ArchiveOpener.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 97a805b45e..a19bcedc01 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -57,6 +57,7 @@ 1BF9469B4574D30E5C9BB75E /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCF859D4933121BDC376CC8A /* Event.swift */; }; 1CB986C7E440F94F264A3567 /* EPUBSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741AE26D76A8C2508437C2D /* EPUBSettings.swift */; }; 1D0B4067739311F6A54240E7 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4598F4671CE7BAE9299BF84B /* UIImage.swift */; }; + 1FB7DAE5EF125B0D05261318 /* W3CAccessibilityMetadataDisplayGuide.strings in Resources */ = {isa = PBXBuildFile; fileRef = A686B5257C30B0EA8087EB31 /* W3CAccessibilityMetadataDisplayGuide.strings */; }; 20D530EDB2B26ADECB4DAE82 /* EPUBDeobfuscator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3D785FEFDA202A61E620890 /* EPUBDeobfuscator.swift */; }; 216EA1C1ABA15836D60D910C /* GeneratedCoverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925CDE3176715EBEBF40B21F /* GeneratedCoverService.swift */; }; 21B27CD89562506DDC1D62D1 /* Signature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A5959877EC9688CB0C370E /* Signature.swift */; }; @@ -206,7 +207,6 @@ 7E456E5AA21BCD712C325B62 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57074892837A37E3BFEDB481 /* String.swift */; }; 7E45E10720EA6B4F18196316 /* Metadata+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC59A963F316359DF8B119AC /* Metadata+Presentation.swift */; }; 8029C2773AF704561B09BA99 /* DirectionalNavigationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F7D914B293DF0A912613D2 /* DirectionalNavigationAdapter.swift */; }; - 8066A9FCBA3AA96717A01CFD /* W3CAccessibilityMetadataDisplayGuide.strings in Resources */ = {isa = PBXBuildFile; fileRef = 63AE10E3A29A24DD9C05C1D3 /* W3CAccessibilityMetadataDisplayGuide.strings */; }; 80FACAC721EBA4A11764482C /* EPUBPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DA40519A11DDE69CDDBB1C /* EPUBPreferences.swift */; }; 81ADB258F083647221CED24F /* DataCompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EBC685D4A0E07997088DD2D /* DataCompression.swift */; }; 824B5370C029445F0BE08741 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8930DA1683F200ACE1AFC02A /* Format.swift */; }; @@ -616,6 +616,7 @@ 5124A0F95B52BA336E07C3D3 /* RelativeURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelativeURL.swift; sourceTree = ""; }; 529B55BE6996FCDC1082BF0A /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; 5380F05215D8ED61B97F8021 /* PublicationOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationOpener.swift; sourceTree = ""; }; + 538FDA65FCB39F10BF9C8BC0 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/W3CAccessibilityMetadataDisplayGuide.strings; sourceTree = ""; }; 53DAB92EBBB8031CA66B1E6F /* ReadiumAdapterLCPSQLite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumAdapterLCPSQLite.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5420CABB4B38006F64160F49 /* AccessibilityDisplayString+Generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessibilityDisplayString+Generated.swift"; sourceTree = ""; }; 54699BC0E00F327E67908F6A /* Encryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Encryption.swift; sourceTree = ""; }; @@ -636,7 +637,6 @@ 616C70674FBF020FE4607617 /* DeviceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceService.swift; sourceTree = ""; }; 622CB8B75A568846FECA44D6 /* Streamable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Streamable.swift; sourceTree = ""; }; 626CFFF131E0E840B76428F1 /* DecorableNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorableNavigator.swift; sourceTree = ""; }; - 63AE10E3A29A24DD9C05C1D3 /* W3CAccessibilityMetadataDisplayGuide.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = W3CAccessibilityMetadataDisplayGuide.strings; path = "en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings"; sourceTree = ""; }; 64ED7629E73022C1495081D1 /* Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Links.swift; sourceTree = ""; }; 6536C07F5A50F7F25FDBF69C /* ReadiumGCDWebServer.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReadiumGCDWebServer.xcframework; path = ../../Carthage/Build/ReadiumGCDWebServer.xcframework; sourceTree = ""; }; 65C8719E9CC8EF0D2430AD85 /* CompletionList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionList.swift; sourceTree = ""; }; @@ -675,6 +675,7 @@ 7C28B8CD48F8A660141F5983 /* DefaultLocatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocatorService.swift; sourceTree = ""; }; 7C9B7B0A5A1B891BA3D9B9C0 /* BufferingResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BufferingResource.swift; sourceTree = ""; }; 7CDE839ECF121D2EDD0C31C7 /* InputObservingGestureRecognizerAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputObservingGestureRecognizerAdapter.swift; sourceTree = ""; }; + 7E14E1BA1A6B15BBC1C19296 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/W3CAccessibilityMetadataDisplayGuide.strings; sourceTree = ""; }; 7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPublicationManifestAugmentor.swift; sourceTree = ""; }; 7FCA24A94D6376487FECAEF1 /* LCPPassphraseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPPassphraseRepository.swift; sourceTree = ""; }; 819D931708B3EE95CF9ADFED /* OPDSCopies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSCopies.swift; sourceTree = ""; }; @@ -849,6 +850,7 @@ F07214E263C6589987A561F9 /* SQLite.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SQLite.xcframework; path = ../../Carthage/Build/SQLite.xcframework; sourceTree = ""; }; F1F5FEE0323287B9CAA09F03 /* MediaOverlays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaOverlays.swift; sourceTree = ""; }; F2E780027410F4B6CC872B3D /* OPDSAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSAvailability.swift; sourceTree = ""; }; + F46CAAA92BFBFCCC24AD324A /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings; sourceTree = ""; }; F4FC8F971F00B5876803B62A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; F5C6D0C5860E802EDA23068C /* EditingAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditingAction.swift; sourceTree = ""; }; F5DA40519A11DDE69CDDBB1C /* EPUBPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBPreferences.swift; sourceTree = ""; }; @@ -1482,7 +1484,7 @@ 699E0FDF48F79D5EEACE0436 /* Resources */ = { isa = PBXGroup; children = ( - 63AE10E3A29A24DD9C05C1D3 /* W3CAccessibilityMetadataDisplayGuide.strings */, + A686B5257C30B0EA8087EB31 /* W3CAccessibilityMetadataDisplayGuide.strings */, ); path = Resources; sourceTree = ""; @@ -2304,8 +2306,8 @@ knownRegions = ( Base, en, - "en-US", fr, + it, ); mainGroup = 2C63ECC3CC1230CCA416F55F; minimizedProjectReferenceProxies = 1; @@ -2356,7 +2358,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 8066A9FCBA3AA96717A01CFD /* W3CAccessibilityMetadataDisplayGuide.strings in Resources */, + 1FB7DAE5EF125B0D05261318 /* W3CAccessibilityMetadataDisplayGuide.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2862,6 +2864,16 @@ name = Localizable.strings; sourceTree = ""; }; + A686B5257C30B0EA8087EB31 /* W3CAccessibilityMetadataDisplayGuide.strings */ = { + isa = PBXVariantGroup; + children = ( + 7E14E1BA1A6B15BBC1C19296 /* en */, + F46CAAA92BFBFCCC24AD324A /* fr */, + 538FDA65FCB39F10BF9C8BC0 /* it */, + ); + name = W3CAccessibilityMetadataDisplayGuide.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift b/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift index e6d72e1fab..3720fa74f5 100644 --- a/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift +++ b/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift @@ -30,6 +30,32 @@ class AccessibilityMetadataDisplayGuideTests: XCTestCase { ) } + /// Tests the fallback behavior for strings without -compact/-descriptive + /// suffixes. + /// Some strings in thorium-locales have identical compact and descriptive + /// values, so they are stored with only the base key. + func testDisplayStatementLocalizedStringFallbackToBaseKey() { + // Test hazardsNoMetadata + XCTAssertEqual( + AccessibilityDisplayString.hazardsNoMetadata.localized(descriptive: false).string, + "No information is available" + ) + XCTAssertEqual( + AccessibilityDisplayString.hazardsNoMetadata.localized(descriptive: true).string, + "No information is available" + ) + + // Test conformanceNo + XCTAssertEqual( + AccessibilityDisplayString.additionalAccessibilityInformationRubyAnnotations.localized(descriptive: false).string, + "Some Ruby annotations" + ) + XCTAssertEqual( + AccessibilityDisplayString.additionalAccessibilityInformationRubyAnnotations.localized(descriptive: true).string, + "Some Ruby annotations" + ) + } + func testDisplayStatementCustomLocalizedString() { let statement = AccessibilityDisplayStatement( string: .waysOfReadingNonvisualReadingReadable, @@ -433,9 +459,9 @@ class AccessibilityMetadataDisplayGuideTests: XCTestCase { transcript: true ).statements.map(\.id), [ - .richContentExtended, + .richContentExtendedDescriptions, .richContentAccessibleMathDescribed, - .richContentAccessibleMathAsMathml, + .richContentMathAsMathml, .richContentAccessibleMathAsLatex, .richContentAccessibleChemistryAsMathml, .richContentAccessibleChemistryAsLatex, @@ -459,7 +485,7 @@ class AccessibilityMetadataDisplayGuideTests: XCTestCase { transcript: false ).statements.map(\.id), [ - .richContentExtended, + .richContentExtendedDescriptions, ] ) @@ -493,7 +519,7 @@ class AccessibilityMetadataDisplayGuideTests: XCTestCase { transcript: false ).statements.map(\.id), [ - .richContentAccessibleMathAsMathml, + .richContentMathAsMathml, ] ) From 2edd43145a52ac3f433d140103f0abc1b81140b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 28 Jan 2026 14:33:54 +0100 Subject: [PATCH 28/55] Improve standalone audio files' metadata (#709) --- CHANGELOG.md | 1 + .../AudioPublicationManifestAugmentor.swift | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae38e5b65..d3635eb5cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file. Take a look * EPUB manifest item fallbacks are now exposed as `alternates` in the corresponding `Link`. * EPUBs with only bitmap images in the spine are now treated as Divina publications with fixed layout. * When an EPUB spine item is HTML with a bitmap image fallback (or vice versa), the image is preferred as the primary link. +* Standalone audio files (e.g. MP3) metadata extraction now includes `narrators` (from the composer metadata fields) and merges artist metadata into `authors`, following conventions used by common audiobook tools. ### Changed diff --git a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift index 3402b0726a..24c25be8b1 100644 --- a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift +++ b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift @@ -52,16 +52,32 @@ public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifest metadata.published = avMetadata.filter([.commonIdentifierCreationDate, .id3MetadataDate]).first(where: { $0.dateValue }) metadata.languages = avMetadata.filter([.commonIdentifierLanguage, .id3MetadataLanguage]).compactMap(\.stringValue).removingDuplicates() metadata.subjects = avMetadata.filter([.commonIdentifierSubject]).compactMap(\.stringValue).removingDuplicates().map { Subject(name: $0) } - metadata.authors = avMetadata.filter([.commonIdentifierAuthor, .iTunesMetadataAuthor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } - metadata.artists = avMetadata.filter([.commonIdentifierArtist, .id3MetadataOriginalArtist, .iTunesMetadataArtist, .iTunesMetadataOriginalArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + // Authors are often stored as "artist": + // - https://www.audiobookshelf.org/docs/#book-audio-metadata + // - https://github.com/denizsafak/abogen#about-metadata-tags + metadata.authors = avMetadata.filter( + [ + .commonIdentifierAuthor, + .iTunesMetadataAuthor, + .commonIdentifierArtist, + .id3MetadataOriginalArtist, + .iTunesMetadataArtist, + .iTunesMetadataOriginalArtist, + ] + ).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } metadata.illustrators = avMetadata.filter([.iTunesMetadataAlbumArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } metadata.contributors = avMetadata.filter([.commonIdentifierContributor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } metadata.publishers = avMetadata.filter([.commonIdentifierPublisher, .id3MetadataPublisher, .iTunesMetadataPublisher]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + // Narrators are often stored as "composer": + // - https://www.audiobookshelf.org/docs/#book-audio-metadata + // - https://github.com/denizsafak/abogen#about-metadata-tags + metadata.narrators = avMetadata.filter([.id3MetadataComposer, .iTunesMetadataComposer]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } metadata.description = avMetadata.filter([.commonIdentifierDescription, .iTunesMetadataDescription]).first?.stringValue metadata.duration = avAssets.reduce(0) { duration, avAsset in guard let duration = duration, let avAsset = avAsset else { return nil } return duration + avAsset.duration.seconds } + manifest.metadata = metadata let cover = avMetadata.filter([.commonIdentifierArtwork, .id3MetadataAttachedPicture, .iTunesMetadataCoverArt]).first(where: { $0.dataValue.flatMap(UIImage.init(data:)) }) return .init(manifest: manifest, cover: cover) From 566d13dcf8c9423bb9818b9b1b82f05ec6bba07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 29 Jan 2026 13:47:15 +0100 Subject: [PATCH 29/55] Add fallback cover from reading order and refactor default cover service (#710) --- CHANGELOG.md | 1 + .../Services/Cover/CoverService.swift | 29 +---- .../Services/Cover/ResourceCoverService.swift | 63 ++++++++++ .../Services/PublicationServicesBuilder.swift | 2 +- Sources/Shared/Toolkit/Format/MediaType.swift | 2 +- .../Streamer/Parser/Image/ImageParser.swift | 9 +- Support/Carthage/.xcodegen | 1 + .../Readium.xcodeproj/project.pbxproj | 4 + .../Services/Cover/CoverServiceTests.swift | 111 ++++++++++++++---- .../PublicationServicesBuilderTests.swift | 5 +- .../Parser/Image/ImageParserTests.swift | 8 +- 11 files changed, 179 insertions(+), 56 deletions(-) create mode 100644 Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d3635eb5cd..9547576f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. Take a look #### Shared * Added support for JXL (JPEG XL) bitmap images. JXL is decoded natively on iOS 17+. +* `Publication.cover()` now falls back on the first reading order resource if it's a bitmap image and no cover is declared. #### Navigator diff --git a/Sources/Shared/Publication/Services/Cover/CoverService.swift b/Sources/Shared/Publication/Services/Cover/CoverService.swift index c38e71c8d7..dd1d1a9a6f 100644 --- a/Sources/Shared/Publication/Services/Cover/CoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/CoverService.swift @@ -49,35 +49,18 @@ public extension CoverService { public extension Publication { /// Returns the publication cover as a bitmap at its maximum size. func cover() async -> ReadResult { - if let service = findService(CoverService.self) { - return await service.cover() - } else { - return await coverFromManifest() + guard let service = findService(CoverService.self) else { + return .success(nil) } + return await service.cover() } /// Returns the publication cover as a bitmap, scaled down to fit the given `maxSize`. func coverFitting(maxSize: CGSize) async -> ReadResult { - if let service = findService(CoverService.self) { - return await service.coverFitting(maxSize: maxSize) - } else { - return await coverFromManifest() - .map { $0?.scaleToFit(maxSize: maxSize) } - } - } - - /// Extracts the first valid cover from the manifest links with `cover` relation. - private func coverFromManifest() async -> ReadResult { - for link in linksWithRel(.cover) { - guard let image = await get(link)? - .read().getOrNil() - .flatMap({ UIImage(data: $0) }) - else { - continue - } - return .success(image) + guard let service = findService(CoverService.self) else { + return .success(nil) } - return .success(nil) + return await service.coverFitting(maxSize: maxSize) } } diff --git a/Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift b/Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift new file mode 100644 index 0000000000..0f79f0bced --- /dev/null +++ b/Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift @@ -0,0 +1,63 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import UIKit + +/// A `CoverService` which retrieves the cover from the publication container. +/// +/// It will look for: +/// 1. Links with explicit `cover` relation in the resources. +/// 2. First `readingOrder` resource if it's a bitmap, or if it has a bitmap +/// `alternates`. +public final class ResourceCoverService: CoverService { + private let context: PublicationServiceContext + + public init(context: PublicationServiceContext) { + self.context = context + } + + public func cover() async -> ReadResult { + // Try resources with explicit `cover` relation + for link in context.manifest.linksWithRel(.cover) { + if let image = await loadImage(from: link) { + return .success(image) + } + } + + // Fallback: first reading order bitmap or alternate + if let firstLink = context.manifest.readingOrder.first { + if firstLink.mediaType?.isBitmap == true { + if let image = await loadImage(from: firstLink) { + return .success(image) + } + } + for alternate in firstLink.alternates { + if alternate.mediaType?.isBitmap == true { + if let image = await loadImage(from: alternate) { + return .success(image) + } + } + } + } + + return .success(nil) + } + + private func loadImage(from link: Link) async -> UIImage? { + guard + let resource = context.container[link.url()], + let data = try? await resource.read().get() + else { + return nil + } + return UIImage(data: data) + } + + public static func makeFactory() -> (PublicationServiceContext) -> ResourceCoverService { + { ResourceCoverService(context: $0) } + } +} diff --git a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift index bb05a34cef..7fc019103b 100644 --- a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift +++ b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift @@ -15,7 +15,7 @@ public struct PublicationServicesBuilder { public init( content: ContentServiceFactory? = nil, contentProtection: ContentProtectionServiceFactory? = nil, - cover: CoverServiceFactory? = nil, + cover: CoverServiceFactory? = ResourceCoverService.makeFactory(), locator: LocatorServiceFactory? = { DefaultLocatorService(publication: $0.publication) }, positions: PositionsServiceFactory? = nil, search: SearchServiceFactory? = nil, diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index 45212bb798..0ad3be2a13 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -188,7 +188,7 @@ public struct MediaType: Hashable, Loggable, Sendable { /// Returns whether this media type is of a bitmap image, so excluding vectorial formats. public var isBitmap: Bool { - matchesAny(.bmp, .gif, .jpeg, .jxl, .png, .tiff, .webp) + matchesAny(.avif, .bmp, .gif, .jpeg, .jxl, .png, .tiff, .webp) } /// Returns whether this media type is of an audio clip. diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index eea46d692f..a1b25fa4d2 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -142,19 +142,16 @@ public final class ImageParser: PublicationParser { ) } - // Determine cover page index - let coverIndex: Int + // Set cover if explicitly declared in ComicInfo.xml + var coverIndex: Int? if let coverPage = comicInfo?.firstPageWithType(.frontCover), coverPage.image >= 0, coverPage.image < readingOrder.count { coverIndex = coverPage.image - } else { - // Default: first resource is the cover - coverIndex = 0 + readingOrder[coverPage.image].rels.append(.cover) } - readingOrder[coverIndex].rels.append(.cover) // Determine story start index (where actual content begins) // Only set if different from cover page (prefer .cover if same page) diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 534c32b8a5..1f25687e6d 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -662,6 +662,7 @@ ../../Sources/Shared/Publication/Services/Cover ../../Sources/Shared/Publication/Services/Cover/CoverService.swift ../../Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +../../Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift ../../Sources/Shared/Publication/Services/Locator ../../Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift ../../Sources/Shared/Publication/Services/Locator/LocatorService.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index a19bcedc01..7572eb63d2 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -142,6 +142,7 @@ 5240984F642C951743FB153F /* CBZNavigatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 239A56BB0E6DAF17E0A13447 /* CBZNavigatorViewController.swift */; }; 540E43EC30EEDDB740ADE046 /* BufferingResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9B7B0A5A1B891BA3D9B9C0 /* BufferingResource.swift */; }; 5591563FD08A956B80C37716 /* XMLFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF20C1D3C33365D25704663 /* XMLFormatSniffer.swift */; }; + 559F3EF06F73E78848C772EA /* ResourceCoverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */; }; 56A9C67C15BD88FBE576ADF8 /* HTTPProblemDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05E365EBAFDA0CF841F583B /* HTTPProblemDetails.swift */; }; 56CB87DACCA10F737710BFF6 /* Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68FF131876FA3A63025F2662 /* Language.swift */; }; 5730E84475195005D1291672 /* Publication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF03272C07D6951ADC1311E /* Publication.swift */; }; @@ -835,6 +836,7 @@ E6CB6D3B390CC927AE547A5C /* DebugError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugError.swift; sourceTree = ""; }; E6E97CCA91F910315C260373 /* ReadiumWebPubParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumWebPubParser.swift; sourceTree = ""; }; E7D002FDDAD1A21AC5BB84CE /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; + E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceCoverService.swift; sourceTree = ""; }; EC329362A0E8AC6CC018452A /* ReadiumOPDS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumOPDS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EC59A963F316359DF8B119AC /* Metadata+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+Presentation.swift"; sourceTree = ""; }; EC5ED9E15482AED288A6634F /* EPUBNavigatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBNavigatorViewController.swift; sourceTree = ""; }; @@ -1253,6 +1255,7 @@ children = ( A4F0C112656C4786F3861973 /* CoverService.swift */, 925CDE3176715EBEBF40B21F /* GeneratedCoverService.swift */, + E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */, ); path = Cover; sourceTree = ""; @@ -2740,6 +2743,7 @@ 31909E8E0CB313AA7C390762 /* RelativeURL.swift in Sources */, 977C8677BEB5B235E8F82A4C /* Resource.swift in Sources */, 94E5D205567FEBB52E38F318 /* ResourceContentExtractor.swift in Sources */, + 559F3EF06F73E78848C772EA /* ResourceCoverService.swift in Sources */, 92C06DC4CF7986B15F1C82B3 /* ResourceFactory.swift in Sources */, 30F89196BD5163B0A09BF9F7 /* ResourceProperties.swift in Sources */, 01E785BEA7F30AD1C8A5F3DE /* SearchService.swift in Sources */, diff --git a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift index ede34be533..b833cba9d2 100644 --- a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift @@ -14,54 +14,125 @@ class CoverServiceTests: XCTestCase { lazy var cover = UIImage(contentsOfFile: coverURL.path)! lazy var cover2 = UIImage(data: fixtures.data(at: "cover2.jpg"))! - /// `Publication.cover` will use the `CoverService` if there's one. - func testCoverHelperUsesCoverService() async { + /// `Publication.cover` will use a custom `CoverService` if provided. + func testCoverHelperUsesCustomCoverService() async { let publication = makePublication { _ in TestCoverService(cover: self.cover2) } let result = await publication.cover() AssertImageEqual(result, .success(cover2)) } - /// `Publication.cover` will try to fetch the cover from a manifest link with rel `cover`, if - /// no `CoverService` is provided. - func testCoverHelperFallsBackOnManifest() async { + /// `Publication.cover` uses `ResourceCoverService` by default. + func testCoverHelperUsesResourceCoverServiceByDefault() async { let publication = makePublication() let result = await publication.cover() AssertImageEqual(result, .success(cover)) } - /// `Publication.coverFitting` will use the `CoverService` if there's one. - func testCoverFittingHelperUsesCoverService() async { + /// `Publication.coverFitting` will use a custom `CoverService` if provided. + func testCoverFittingHelperUsesCustomCoverService() async { let size = CGSize(width: 100, height: 100) let publication = makePublication { _ in TestCoverService(cover: self.cover2) } let result = await publication.coverFitting(maxSize: size) AssertImageEqual(result, .success(cover2.scaleToFit(maxSize: size))) } - /// `Publication.coverFitting` will try to fetch the cover from a manifest link with rel `cover`, if - /// no `CoverService` is provided. - func testCoverFittingHelperFallsBackOnManifest() async { + /// `Publication.coverFitting` uses `ResourceCoverService` by default. + func testCoverFittingHelperUsesResourceCoverServiceByDefault() async { let size = CGSize(width: 100, height: 100) let publication = makePublication() let result = await publication.coverFitting(maxSize: size) AssertImageEqual(result, .success(cover.scaleToFit(maxSize: size))) } - private func makePublication(cover: CoverServiceFactory? = nil) -> Publication { - let coverPath = "cover.jpg" - return Publication( - manifest: Manifest( - metadata: Metadata( - title: "title" + /// `ResourceCoverService` uses the first bitmap reading order item when no explicit `.cover` + /// link is declared. + func testResourceCoverServiceUsesFirstBitmapReadingOrderItem() async { + let publication = makePublication( + readingOrder: [ + Link(href: "cover.jpg", mediaType: .jpeg), + Link(href: "page2.jpg", mediaType: .jpeg), + ], + resources: [] + ) + let result = await publication.cover() + AssertImageEqual(result, .success(cover)) + } + + /// `ResourceCoverService` uses the first bitmap alternate of the first reading order item + /// when that item is not a bitmap. + func testResourceCoverServiceUsesFirstReadingOrderBitmapAlternate() async { + let publication = makePublication( + readingOrder: [ + Link( + href: "chapter1.xhtml", + mediaType: .xhtml, + alternates: [ + Link(href: "cover.jpg", mediaType: .jpeg), + ] ), + ], + resources: [] + ) + let result = await publication.cover() + AssertImageEqual(result, .success(cover)) + } + + /// `ResourceCoverService` returns nil when no explicit `.cover` link is declared and no bitmap + /// is available. + func testResourceCoverServiceReturnsNilWhenNoBitmapAvailable() async { + let publication = makePublication( + readingOrder: [Link(href: "chapter1.xhtml", mediaType: .xhtml)], + resources: [] + ) + let result = await publication.cover() + AssertImageEqual(result, .success(nil)) + } + + /// `ResourceCoverService` prioritizes explicit `.cover` links over first reading order item. + func testResourceCoverServicePrioritizesExplicitCoverLink() async { + let publication = Publication( + manifest: Manifest( + metadata: Metadata(title: "title"), readingOrder: [ - Link(href: "titlepage.xhtml", rels: [.cover]), + Link(href: "page1.jpg", mediaType: .jpeg), ], resources: [ - Link(href: coverPath, rels: [.cover]), + Link(href: "cover2.jpg", rels: [.cover]), ] ), - container: FileContainer(href: RelativeURL(path: coverPath)!, file: coverURL), - servicesBuilder: PublicationServicesBuilder(cover: cover) + container: CompositeContainer( + SingleResourceContainer( + resource: FileResource(file: fixtures.url(for: "cover.jpg")), + at: AnyURL(string: "page1.jpg")! + ), + SingleResourceContainer( + resource: FileResource(file: fixtures.url(for: "cover2.jpg")), + at: AnyURL(string: "cover2.jpg")! + ) + ) + ) + let result = await publication.cover() + AssertImageEqual(result, .success(cover2)) + } + + private func makePublication( + readingOrder: [Link] = [], + resources: [Link] = [Link(href: "cover.jpg", rels: [.cover])], + cover: CoverServiceFactory? = nil + ) -> Publication { + var builder = PublicationServicesBuilder() + if let cover { builder.setCoverServiceFactory(cover) } + return Publication( + manifest: Manifest( + metadata: Metadata(title: "title"), + readingOrder: readingOrder, + resources: resources + ), + container: SingleResourceContainer( + resource: FileResource(file: coverURL), + at: AnyURL(string: "cover.jpg")! + ), + servicesBuilder: builder ) } } diff --git a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift index 616b3ab83b..845c9b6928 100644 --- a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift +++ b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift @@ -42,7 +42,7 @@ class PublicationServicesBuilderTests: XCTestCase { let services = builder.build(context: context) - XCTAssert(services.count == 3) + XCTAssert(services.count == 4) XCTAssert(services.contains { $0 is FooServiceA }) XCTAssert(services.contains { $0 is BarServiceA }) } @@ -50,8 +50,9 @@ class PublicationServicesBuilderTests: XCTestCase { func testBuildDefault() { let builder = PublicationServicesBuilder() let services = builder.build(context: context) - XCTAssertEqual(services.count, 1) + XCTAssertEqual(services.count, 2) XCTAssert(services.contains { $0 is DefaultLocatorService }) + XCTAssert(services.contains { $0 is ResourceCoverService }) } func testSetOverwrite() { diff --git a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index fbb87e2349..6e26add569 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -80,10 +80,12 @@ class ImageParserTests: XCTestCase { ]) } - func testFirstReadingOrderItemIsCover() async throws { + /// When no ComicInfo.xml declares a cover, no `cover` rel should be set. + /// The cover will be determined at runtime with the default + /// `ResourceCoverService`. + func testNoCoverRelWhenNoExplicitCover() async throws { let publication = try await parser.parse(asset: cbzAsset, warnings: nil).get().build() - let cover = try XCTUnwrap(publication.linkWithRel(.cover)) - XCTAssertEqual(publication.readingOrder.first, cover) + XCTAssertNil(publication.linkWithRel(.cover)) } func testPositions() async throws { From 907f35b67193e53361b7c7042ff1c2dee258f743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 4 Feb 2026 16:46:03 +0100 Subject: [PATCH 30/55] 3.7.0 (#711) --- CHANGELOG.md | 5 +- MAINTAINING.md | 50 ++++++---- README.md | 95 +++++++++++-------- .../Resources/it.lproj/Localizable.strings | 2 + ...CAccessibilityMetadataDisplayGuide.strings | 2 +- ...CAccessibilityMetadataDisplayGuide.strings | 2 +- Support/Carthage/.xcodegen | 18 ++-- .../Readium.xcodeproj/project.pbxproj | 34 +++---- Support/Carthage/project.yml | 16 ++-- .../ReadiumAdapterGCDWebServer.podspec | 8 +- .../CocoaPods/ReadiumAdapterLCPSQLite.podspec | 8 +- Support/CocoaPods/ReadiumInternal.podspec | 4 +- Support/CocoaPods/ReadiumLCP.podspec | 8 +- Support/CocoaPods/ReadiumNavigator.podspec | 8 +- Support/CocoaPods/ReadiumOPDS.podspec | 8 +- Support/CocoaPods/ReadiumShared.podspec | 6 +- Support/CocoaPods/ReadiumStreamer.podspec | 8 +- TestApp/Sources/Info.plist | 4 +- docs/Migration Guide.md | 4 +- 19 files changed, 164 insertions(+), 126 deletions(-) create mode 100644 Sources/LCP/Resources/it.lproj/Localizable.strings diff --git a/CHANGELOG.md b/CHANGELOG.md index 9547576f1a..435f5187fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. -## [Unreleased] + + +## [3.7.0] ### Added @@ -1107,3 +1109,4 @@ progression. Now if no reading progression is set, the `effectiveReadingProgress [3.4.0]: https://github.com/readium/swift-toolkit/compare/3.3.0...3.4.0 [3.5.0]: https://github.com/readium/swift-toolkit/compare/3.4.0...3.5.0 [3.6.0]: https://github.com/readium/swift-toolkit/compare/3.5.0...3.6.0 +[3.7.0]: https://github.com/readium/swift-toolkit/compare/3.6.0...3.7.0 diff --git a/MAINTAINING.md b/MAINTAINING.md index f51cb4d72c..6ef1f51714 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -1,6 +1,15 @@ # Maintaining the Readium Swift toolkit -## Releasing a new version +## Bumping the Minimum iOS Deployment Target + +To bump the minimum required iOS version, update these files: + +- `README.md`, section "Minimum Requirements" +- `Package.swift` +- `Support/Carthage/project.yml` +- `Support/CocoaPods/*.podspec` + +## Releasing a New Version You are ready to release a new version of the Swift toolkit? Great, follow these steps: @@ -14,31 +23,27 @@ You are ready to release a new version of the Swift toolkit? Great, follow these ``` 4. Try to run the Test App, adjusting the integration if needed. 5. Delete the Git tag created previously. -3. Update the [migration guide](Documentation/Migration%20Guide.md) in case of breaking changes. -4. Issue the new release. +3. Update the localized strings (`make update-locales`). +4. Review the list of supported features in `README.md`. +5. Update the [migration guide](Documentation/Migration%20Guide.md) in case of breaking changes. +6. Issue the new release. 1. Create a branch with the same name as the future tag, from `develop`. 2. Bump the version numbers in the `Support/CocoaPods/*.podspec` files. * :warning: Don't forget to bump the version numbers of the Readium dependencies as well. - 3. Bump the version numbers in `README.md`. + 3. Bump the version numbers in `README.md`, and check the "Minimum Requirements" section. 4. Bump the version numbers in `TestApp/Sources/Info.plist`. 5. Close the version in the `CHANGELOG.md`, [for example](https://github.com/readium/swift-toolkit/pull/353/commits/a0714589b3da928dd923ba78f379116715797333#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4ed). 6. Create a PR to merge in `develop` and verify the CI workflows. - 7. Squash and merge the PR. - 8. Tag the new version from `develop`. - ```shell - git checkout develop - git pull - git tag -a 3.0.1 -m 3.0.1 - git push --tags - ``` - 9. Release the updated Podspecs: + 7. Release the updated Podspecs: ```shell cd Support/CocoaPods pod repo add readium git@github.com:readium/podspecs.git pod repo push readium ReadiumInternal.podspec + pod repo push readium ReadiumShared.podspec + pod repo push readium ReadiumStreamer.podspec pod repo push readium ReadiumNavigator.podspec pod repo push readium ReadiumOPDS.podspec @@ -46,10 +51,17 @@ You are ready to release a new version of the Swift toolkit? Great, follow these pod repo push readium ReadiumAdapterGCDWebServer.podspec pod repo push readium ReadiumAdapterLCPSQLite.podspec ``` -5. Verify you can fetch the new version from the latest Test App with `make spm|carthage|cocoapods version=3.0.1` -7. Announce the release. + 8. Squash and merge the PR. + 9. Tag the new version from `develop`. + ```shell + git checkout develop + git pull + git tag -a 3.0.1 -m 3.0.1 + git push --tags + ``` +7. Verify you can fetch the new version from the latest Test App with `make spm|carthage|cocoapods version=3.0.1` +8. Announce the release. 1. Create a new release on GitHub. - 2. Publish a new TestFlight beta with LCP enabled. - * Click on "External Groups" > "Public Beta", then add the new build so that it's available to everyone. -8. Merge `develop` into `main`. - + 2. Write a high-level summary of the changelog for the blog. + 3. Post the blog summary on Discord's `#announcement`, with a link to the GitHub release. +9. Merge `develop` into `main`. diff --git a/README.md b/README.md index 542e42cc31..0fad0dab56 100644 --- a/README.md +++ b/README.md @@ -7,53 +7,67 @@ ## Features -✅ Implemented      🚧 Partially implemented      📆 Planned      👀 Want to do      ❓ Not planned +✅ Implemented      🚧 Partially implemented      📆 Planned      👀 Want to do      ❌ Not planned ### Formats -| Format | Status | -|---|:---:| -| EPUB 2 | ✅ | -| EPUB 3 | ✅ | -| Readium Web Publication | 🚧 | -| PDF | ✅ | -| Readium Audiobook | ✅ | -| Zipped Audiobook | ✅ | -| Standalone audio files (MP3, AAC, etc.) | ✅ | -| Readium Divina | 🚧 | -| CBZ (Comic Book ZIP) | 🚧 | -| CBR (Comic Book RAR) | ❓ | -| [DAISY](https://daisy.org/activities/standards/daisy/) | 👀 | +#### Ebook and Document Formats + +| Format | Status | +|--------------------------------------------------------|:------:| +| EPUB (reflowable) | ✅ | +| EPUB (fixed-layout) | ✅ | +| PDF | ✅ | +| Readium Web Publication | 🚧 | +| [DAISY](https://daisy.org/activities/standards/daisy/) | 👀 | + +#### Audiobook Formats + +| Format | Status | +|--------------------------------------------------------|:------:| +| Readium Audiobook | ✅ | +| Zipped Audiobook | ✅ | +| Standalone audio files (MP3, AAC, etc.) | ✅ | +| [DAISY](https://daisy.org/activities/standards/daisy/) | 👀 | + +#### Comic Formats + +| Format | Status | +|----------------------|:------:| +| Readium Divina | ✅ | +| CBZ (Comic Book ZIP) | ✅ | +| CBR (Comic Book RAR) | ❌ | ### Features A number of features are implemented only for some publication formats. -| Feature | EPUB (reflow) | EPUB (FXL) | PDF | -|---|:---:|:---:|:---:| -| Pagination | ✅ | ✅ | ✅ | -| Scrolling | ✅ | 👀 | ✅ | -| Right-to-left (RTL) | ✅ | ✅ | ✅ | -| Search in textual content | ✅ | ✅ | 👀 | -| Highlighting (Decoration API) | ✅ | ✅ | 👀 | -| Text-to-speech (TTS) | ✅ | ✅ | 👀 | -| Media overlays | 📆 | 📆 | | +| Feature | EPUB (reflow) | EPUB (FXL) | PDF | +|-------------------------------|:-------------:|:----------:|:---:| +| Pagination | ✅ | ✅ | ✅ | +| Scrolling | ✅ | 👀 | ✅ | +| Right-to-left (RTL) | ✅ | ✅ | ✅ | +| Search in textual content | ✅ | ✅ | 👀 | +| Highlighting (Decoration API) | ✅ | ✅ | 👀 | +| Text-to-speech (TTS) | ✅ | ✅ | 👀 | +| Media overlays | 📆 | 📆 | | ### OPDS Support -| Feature | Status | -|---|:---:| -| [OPDS Catalog 1.2](https://specs.opds.io/opds-1.2) | ✅ | -| [OPDS Catalog 2.0](https://drafts.opds.io/opds-2.0) | ✅ | -| [Authentication for OPDS](https://drafts.opds.io/authentication-for-opds-1.0.html) | 📆 | -| [Readium LCP Automatic Key Retrieval](https://readium.org/lcp-specs/notes/lcp-key-retrieval.html) | 📆 | +| Feature | Status | +|---------------------------------------------------------------------------------------------------|:------:| +| [OPDS Catalog 1.2](https://specs.opds.io/opds-1.2) | ✅ | +| [OPDS Catalog 2.0](https://drafts.opds.io/opds-2.0) | ✅ | +| [Authentication for OPDS](https://drafts.opds.io/authentication-for-opds-1.0.html) | 📆 | +| [OPDS Progression](https://github.com/opds-community/drafts/pull/91) | 📆 | +| [Readium LCP Automatic Key Retrieval](https://readium.org/lcp-specs/notes/lcp-key-retrieval.html) | 📆 | ### DRM Support -| Feature | Status | -|---|:---:| -| [Readium LCP](https://www.edrlab.org/projects/readium-lcp/) | ✅ | -| [Adobe ACS](https://www.adobe.com/fr/solutions/ebook/content-server.html) | ❓ | +| Feature | Status | +|---------------------------------------------------------------------------|:------:| +| [Readium LCP](https://www.edrlab.org/projects/readium-lcp/) | ✅ | +| [Adobe ACS](https://www.adobe.com/fr/solutions/ebook/content-server.html) | ❌ | ## User Guides @@ -87,7 +101,8 @@ Guides are available to help you make the most of the toolkit. | Readium | iOS | Swift compiler | Xcode | |-----------|------|----------------|-------| -| `develop` | 13.4 | 6.0 | 16.2 | +| `develop` | 15.0 | 6.0 | 16.4 | +| 3.7.0 | 15.0 | 6.0 | 16.4 | | 3.0.0 | 13.4 | 5.10 | 15.4 | | 2.5.1 | 11.0 | 5.6.1 | 13.4 | | 2.5.0 | 10.0 | 5.6.1 | 13.4 | @@ -113,7 +128,7 @@ If you're stuck, find more information at [developer.apple.com](https://develope Add the following to your `Cartfile`: ``` -github "readium/swift-toolkit" ~> 3.6.0 +github "readium/swift-toolkit" ~> 3.7.0 ``` Then, [follow the usual Carthage steps](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) to add the Readium libraries to your project. @@ -143,11 +158,11 @@ Add the following `pod` statements to your `Podfile` for the Readium libraries y source 'https://github.com/readium/podspecs' source 'https://cdn.cocoapods.org/' -pod 'ReadiumShared', '~> 3.6.0' -pod 'ReadiumStreamer', '~> 3.6.0' -pod 'ReadiumNavigator', '~> 3.6.0' -pod 'ReadiumOPDS', '~> 3.6.0' -pod 'ReadiumLCP', '~> 3.6.0' +pod 'ReadiumShared', '~> 3.7.0' +pod 'ReadiumStreamer', '~> 3.7.0' +pod 'ReadiumNavigator', '~> 3.7.0' +pod 'ReadiumOPDS', '~> 3.7.0' +pod 'ReadiumLCP', '~> 3.7.0' ``` Take a look at [CocoaPods's documentation](https://guides.cocoapods.org/using/using-cocoapods.html) for more information. diff --git a/Sources/LCP/Resources/it.lproj/Localizable.strings b/Sources/LCP/Resources/it.lproj/Localizable.strings new file mode 100644 index 0000000000..3982505447 --- /dev/null +++ b/Sources/LCP/Resources/it.lproj/Localizable.strings @@ -0,0 +1,2 @@ +// DO NOT EDIT. File generated automatically from the it JSON strings of https://github.com/edrlab/thorium-locales/. + diff --git a/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings b/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings index 44f03cd8e9..0d2105ce0c 100644 --- a/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings +++ b/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings @@ -75,7 +75,7 @@ "readium.a11y.hazards-title" = "Points d'attention"; "readium.a11y.hazards-unknown" = "La présence de risques est inconnue"; "readium.a11y.legal-considerations-exempt-compact" = "Déclare être sous le coup d'une exemption dans certaines juridictions"; -"readium.a11y.legal-considerations-exempt-descriptive" = "Cette publication dééclare être sous le coup d'une exemption dans certaines juridictions"; +"readium.a11y.legal-considerations-exempt-descriptive" = "Cette publication déclare être sous le coup d'une exemption dans certaines juridictions"; "readium.a11y.legal-considerations-no-metadata" = "Aucune information disponible"; "readium.a11y.legal-considerations-title" = "Considérations légales"; "readium.a11y.navigation-index-compact" = "Index"; diff --git a/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings b/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings index 76133ad4fc..13aaa51e23 100644 --- a/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings +++ b/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings @@ -28,7 +28,7 @@ "readium.a11y.conformance-a-compact" = "Questa pubblicazione soddisfa gli standard minimi di accessibilità"; "readium.a11y.conformance-a-descriptive" = "La pubblicazione contiene una dichiarazione di conformità che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello A"; "readium.a11y.conformance-aa-compact" = "Questa pubblicazione soddisfa gli standard di accessibilità accettati"; -"readium.a11y.conformance-aa-descriptive" = "La pubblicazione contiene una dichiarazione di conformità che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello AAA"; +"readium.a11y.conformance-aa-descriptive" = "La pubblicazione contiene una dichiarazione di conformità che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello AA"; "readium.a11y.conformance-aaa-compact" = "Questa pubblicazione supera gli standard di accessibilità"; "readium.a11y.conformance-aaa-descriptive" = "La pubblicazione contiene una dichiarazione di conformità che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello AAA"; "readium.a11y.conformance-certifier" = "La pubblicazione è stata certificata da "; diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 1f25687e6d..f72a6e4f2c 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -81,7 +81,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -106,7 +106,7 @@ "target" : "ReadiumLCP" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -120,7 +120,7 @@ "type" : "framework" }, "ReadiumInternal" : { - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -151,7 +151,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -182,7 +182,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -215,7 +215,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -249,7 +249,7 @@ "sdk" : "CoreServices.framework" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -277,7 +277,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -379,6 +379,8 @@ ../../Sources/LCP/Resources/en.lproj/Localizable.strings ../../Sources/LCP/Resources/fr.lproj ../../Sources/LCP/Resources/fr.lproj/Localizable.strings +../../Sources/LCP/Resources/it.lproj +../../Sources/LCP/Resources/it.lproj/Localizable.strings ../../Sources/LCP/Resources/prod-license.lcpl ../../Sources/LCP/Services ../../Sources/LCP/Services/CRLService.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 7572eb63d2..97b037195f 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -700,6 +700,7 @@ 8DC02496961068F28D1B2A52 /* Key.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Key.swift; sourceTree = ""; }; 8EA0008AF1B9B97962824D85 /* FallbackContentProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackContentProtection.swift; sourceTree = ""; }; 8F485F9F15CF41925D2D3D5C /* ActivatePointerObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivatePointerObserver.swift; sourceTree = ""; }; + 8FC5570EB4530B7BD60A6A88 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 91F34B9B08BC6FB84CE54A26 /* LCPProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPProgress.swift; sourceTree = ""; }; 9234A0351FDE626D8D242223 /* ContainerLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerLicenseContainer.swift; sourceTree = ""; }; 925CDE3176715EBEBF40B21F /* GeneratedCoverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedCoverService.swift; sourceTree = ""; }; @@ -2864,6 +2865,7 @@ children = ( B7C9D54352714641A87F64A0 /* en */, 0885992D0F70AD0B493985CE /* fr */, + 8FC5570EB4530B7BD60A6A88 /* it */, ); name = Localizable.strings; sourceTree = ""; @@ -2892,7 +2894,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2920,7 +2922,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2948,7 +2950,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2976,7 +2978,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3004,7 +3006,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3032,7 +3034,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3060,7 +3062,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3088,7 +3090,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3116,7 +3118,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3144,7 +3146,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3234,7 +3236,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3262,7 +3264,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3290,7 +3292,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3373,7 +3375,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3401,7 +3403,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3425,7 +3427,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Support/Carthage/project.yml b/Support/Carthage/project.yml index a552c3d548..c860029db6 100644 --- a/Support/Carthage/project.yml +++ b/Support/Carthage/project.yml @@ -9,7 +9,7 @@ targets: ReadiumShared: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Shared dependencies: @@ -26,7 +26,7 @@ targets: ReadiumStreamer: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Streamer excludes: @@ -45,7 +45,7 @@ targets: ReadiumNavigator: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Navigator excludes: @@ -66,7 +66,7 @@ targets: ReadiumOPDS: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/OPDS dependencies: @@ -80,7 +80,7 @@ targets: ReadiumLCP: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/LCP dependencies: @@ -96,7 +96,7 @@ targets: ReadiumAdapterGCDWebServer: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Adapters/GCDWebServer dependencies: @@ -110,7 +110,7 @@ targets: ReadiumAdapterLCPSQLite: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Adapters/LCPSQLite dependencies: @@ -124,7 +124,7 @@ targets: ReadiumInternal: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Internal settings: diff --git a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec index 504c41a6c0..3a6d1df858 100644 --- a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec +++ b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterGCDWebServer" - s.version = "3.6.0" + s.version = "3.7.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use GCDWebServer as an HTTP server in Readium" s.homepage = "http://readium.github.io" @@ -11,11 +11,11 @@ Pod::Spec.new do |s| s.source_files = "Sources/Adapters/GCDWebServer/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumShared', '~> 3.6.0' - s.dependency 'ReadiumInternal', '~> 3.6.0' + s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.7.0' s.dependency 'ReadiumGCDWebServer', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec index 8b02c8a46b..2948703547 100644 --- a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec +++ b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterLCPSQLite" - s.version = "3.6.0" + s.version = "3.7.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use SQLite.swift for the Readium LCP repositories" s.homepage = "http://readium.github.io" @@ -11,11 +11,11 @@ Pod::Spec.new do |s| s.source_files = "Sources/Adapters/LCPSQLite/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumLCP', '~> 3.6.0' - s.dependency 'ReadiumShared', '~> 3.6.0' + s.dependency 'ReadiumLCP', '~> 3.7.0' + s.dependency 'ReadiumShared', '~> 3.7.0' s.dependency 'SQLite.swift', '~> 0.15.0' end diff --git a/Support/CocoaPods/ReadiumInternal.podspec b/Support/CocoaPods/ReadiumInternal.podspec index 076b09ec34..55aab319db 100644 --- a/Support/CocoaPods/ReadiumInternal.podspec +++ b/Support/CocoaPods/ReadiumInternal.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumInternal" - s.version = "3.6.0" + s.version = "3.7.0" s.license = "BSD 3-Clause License" s.summary = "Private utilities used by the Readium modules" s.homepage = "http://readium.github.io" @@ -11,7 +11,7 @@ Pod::Spec.new do |s| s.source_files = "Sources/Internal/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } end diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index b85c6ad2e8..fa65467001 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumLCP" - s.version = "3.6.0" + s.version = "3.7.0" s.license = "BSD 3-Clause License" s.summary = "Readium LCP" s.homepage = "http://readium.github.io" @@ -17,11 +17,11 @@ Pod::Spec.new do |s| s.source_files = "Sources/LCP/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2'} - s.dependency 'ReadiumShared' , '~> 3.6.0' - s.dependency 'ReadiumInternal', '~> 3.6.0' + s.dependency 'ReadiumShared' , '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.7.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' s.dependency 'CryptoSwift', '~> 1.8.0' end diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index 7a0ce1a3d4..7538dcfde5 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumNavigator" - s.version = "3.6.0" + s.version = "3.7.0" s.license = "BSD 3-Clause License" s.summary = "Readium Navigator" s.homepage = "http://readium.github.io" @@ -17,10 +17,10 @@ Pod::Spec.new do |s| s.source_files = "Sources/Navigator/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" - s.dependency 'ReadiumShared', '~> 3.6.0' - s.dependency 'ReadiumInternal', '~> 3.6.0' + s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.7.0' s.dependency 'DifferenceKit', '~> 1.0' s.dependency 'SwiftSoup', '~> 2.7.0' diff --git a/Support/CocoaPods/ReadiumOPDS.podspec b/Support/CocoaPods/ReadiumOPDS.podspec index b90994b03e..09dc4d3c01 100644 --- a/Support/CocoaPods/ReadiumOPDS.podspec +++ b/Support/CocoaPods/ReadiumOPDS.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumOPDS" - s.version = "3.6.0" + s.version = "3.7.0" s.license = "BSD 3-Clause License" s.summary = "Readium OPDS" s.homepage = "http://readium.github.io" @@ -11,11 +11,11 @@ Pod::Spec.new do |s| s.source_files = "Sources/OPDS/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumShared', '~> 3.6.0' - s.dependency 'ReadiumInternal', '~> 3.6.0' + s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.7.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index 614acf9cea..2ebb22d01a 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumShared" - s.version = "3.6.0" + s.version = "3.7.0" s.license = "BSD 3-Clause License" s.summary = "Readium Shared" s.homepage = "http://readium.github.io" @@ -14,7 +14,7 @@ Pod::Spec.new do |s| s.source_files = "Sources/Shared/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.frameworks = "CoreServices" s.libraries = "xml2" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } @@ -23,6 +23,6 @@ Pod::Spec.new do |s| s.dependency 'SwiftSoup', '~> 2.7.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' - s.dependency 'ReadiumInternal', '~> 3.6.0' + s.dependency 'ReadiumInternal', '~> 3.7.0' end diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index 614658c963..bdd65139cd 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "ReadiumStreamer" - s.version = "3.6.0" + s.version = "3.7.0" s.license = "BSD 3-Clause License" s.summary = "Readium Streamer" s.homepage = "http://readium.github.io" @@ -17,13 +17,13 @@ Pod::Spec.new do |s| s.source_files = "Sources/Streamer/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.libraries = 'z', 'xml2' s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.dependency 'ReadiumFuzi', '~> 4.0.0' - s.dependency 'ReadiumShared', '~> 3.6.0' - s.dependency 'ReadiumInternal', '~> 3.6.0' + s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.7.0' s.dependency 'CryptoSwift', '~> 1.8.0' end diff --git a/TestApp/Sources/Info.plist b/TestApp/Sources/Info.plist index 3b9725df93..9613aa7e79 100644 --- a/TestApp/Sources/Info.plist +++ b/TestApp/Sources/Info.plist @@ -252,9 +252,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 3.6.0 + 3.7.0 CFBundleVersion - 3.6.0 + 3.7.0 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index 41473cfb59..f760219d96 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -2,7 +2,9 @@ All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file. -## Unreleased + + +## 3.7.0 ### LCP Dialog Localization Keys From bc3227057a07e11adbb81e1e6f9a87088ca15be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 5 Feb 2026 20:11:30 +0100 Subject: [PATCH 31/55] Add Keychain-based LCP repositories, deprecate SQLite adapter (#713) --- CHANGELOG.md | 18 +- Package.swift | 1 + .../SQLiteLCPLicenseRepository.swift | 57 ++- .../SQLiteLCPPassphraseRepository.swift | 68 ++- Sources/Internal/Keychain.swift | 241 ++++++++++ Sources/LCP/LCPPassphraseRepository.swift | 18 +- .../LCPKeychainLicenseRepository.swift | 264 +++++++++++ .../LCPKeychainPassphraseRepository.swift | 198 +++++++++ Sources/LCP/Services/PassphrasesService.swift | 23 +- Support/Carthage/.xcodegen | 5 + .../Readium.xcodeproj/project.pbxproj | 28 ++ TestApp/Integrations/Carthage/project+lcp.yml | 2 - TestApp/Integrations/CocoaPods/Podfile+lcp | 1 - TestApp/Integrations/Local/project+lcp.yml | 2 - TestApp/Integrations/SPM/project+lcp.yml | 2 - TestApp/Sources/App/Readium.swift | 5 +- TestApp/Sources/LCP/LCPModule.swift | 1 - Tests/InternalTests/KeychainTests.swift | 235 ++++++++++ .../LCPKeychainLicenseRepositoryTests.swift | 417 ++++++++++++++++++ ...LCPKeychainPassphraseRepositoryTests.swift | 375 ++++++++++++++++ docs/Guides/Getting Started.md | 1 - docs/Guides/Readium LCP.md | 11 +- docs/Migration Guide.md | 50 ++- 23 files changed, 1972 insertions(+), 51 deletions(-) create mode 100644 Sources/Internal/Keychain.swift create mode 100644 Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift create mode 100644 Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift create mode 100644 Tests/InternalTests/KeychainTests.swift create mode 100644 Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift create mode 100644 Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 435f5187fc..f6575b191e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,23 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. - +## [Unreleased] + +### Added + +#### LCP + +* New Keychain-based implementations of the LCP license and passphrase repositories: `LCPKeychainLicenseRepository` and `LCPKeychainPassphraseRepository`. + * Stored securely in the iOS/macOS Keychain. + * Persist across app reinstalls. + * Optionally synchronized across devices via iCloud Keychain. + +### Deprecated + +#### LCP + +* `ReadiumAdapterLCPSQLite` is now deprecated in favor of the built-in Keychain repositories. See [the migration guide](docs/Migration%20Guide.md) for instructions. + ## [3.7.0] diff --git a/Package.swift b/Package.swift index ba97b9dce4..9814d2c551 100644 --- a/Package.swift +++ b/Package.swift @@ -131,6 +131,7 @@ let package = Package( name: "ReadiumLCP", dependencies: [ "CryptoSwift", + "ReadiumInternal", "ReadiumShared", .product(name: "ReadiumZIPFoundation", package: "ZIPFoundation"), ], diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift index b44cb4013f..f42c112f36 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift @@ -6,9 +6,11 @@ import Foundation import ReadiumLCP +import ReadiumShared import SQLite -public class LCPSQLiteLicenseRepository: LCPLicenseRepository { +@available(*, deprecated, message: "Use LCPKeychainLicenseRepository from ReadiumLCP instead") +public class LCPSQLiteLicenseRepository: LCPLicenseRepository, Loggable { let licenses = Table("Licenses") let id = SQLite.Expression("id") let printsLeft = SQLite.Expression("printsLeft") @@ -117,4 +119,57 @@ public class LCPSQLiteLicenseRepository: LCPLicenseRepository { copy: get(copiesLeft, for: id) ) } + + /// Migrates all licenses from this SQLite repository to the target + /// keychain repository. + /// + /// This migration transfers consumable rights (print/copy counts) and + /// device registration status to the target repository. The full + /// ``LicenseDocument`` is not stored in SQLite and will be automatically + /// added to the target repository when each publication is opened + /// for the first time after migration. + /// + /// - Returns: `true` if all the licenses were migrated successfully. + @discardableResult + public func migrate(to target: LCPKeychainLicenseRepository) async throws -> Bool { + let allLicenseData = try db.prepare(licenses).map { row in + try ( + id: row.get(id), + printsLeft: row.get(printsLeft), + copiesLeft: row.get(copiesLeft), + registered: row.get(registered) + ) + } + + var successCount = 0 + var failureCount = 0 + + for licenseData in allLicenseData { + do { + let rights = LCPConsumableUserRights( + print: licenseData.printsLeft, + copy: licenseData.copiesLeft + ) + + try await target.importLicenseRights( + for: licenseData.id, + rights: rights, + registered: licenseData.registered + ) + + successCount += 1 + } catch { + failureCount += 1 + log(.error, "Failed to migrate license \(licenseData.id): \(error)") + } + } + + if failureCount > 0 { + log(.info, "License migration completed with \(successCount) succeeded, \(failureCount) failed") + } else { + log(.info, "License migration completed successfully: \(successCount) licenses migrated") + } + + return failureCount == 0 + } } diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift index 7936bfe697..c192217aa3 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift @@ -9,6 +9,7 @@ import ReadiumLCP import ReadiumShared import SQLite +@available(*, deprecated, message: "Use LCPKeychainPassphraseRepository from ReadiumLCP instead") public class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable { let transactions = Table("Transactions") let licenseId = SQLite.Expression("licenseId") @@ -41,19 +42,17 @@ public class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable { public func passphrasesMatching(userID: User.ID?, provider: LicenseDocument.Provider) async throws -> [LCPPassphraseHash] { try logAndRethrow { - var passphrases = - try db.prepare(transactions.select(passphrase) - .filter(self.userId == userID && self.provider == provider) - ) - .compactMap { try $0.get(passphrase) } + try db.prepare(transactions.select(passphrase) + .filter(self.userId == userID && self.provider == provider) + ) + .compactMap { try $0.get(passphrase) } + } + } - // The legacy SQLite database did not save all the new - // (passphrase, userID, provider) tuples. So we need to fall back - // on checking all the saved passphrases for a match. - passphrases += try db.prepare(transactions.select(passphrase)) + public func passphrases() async throws -> [LCPPassphraseHash] { + try logAndRethrow { + try db.prepare(transactions.select(passphrase)) .compactMap { try $0.get(passphrase) } - - return passphrases } } @@ -71,13 +70,46 @@ public class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable { } } - private func all() -> [String] { - let query = transactions.select(passphrase) - do { - return try db.prepare(query).compactMap { try $0.get(passphrase) } - } catch { - log(.error, error) - return [] + /// Migrates all passphrases from this SQLite repository to the target + /// repository. + /// + /// - Returns: `true` if all the passphrases were migrated successfully. + @discardableResult + public func migrate(to target: LCPPassphraseRepository) async throws -> Bool { + let allPassphraseData = try db.prepare(transactions).map { row in + try ( + licenseId: row.get(licenseId), + passphrase: row.get(passphrase), + provider: row.get(provider), + userId: row.get(userId) + ) } + + var successCount = 0 + var failureCount = 0 + + for passphraseData in allPassphraseData { + do { + try await target.addPassphrase( + passphraseData.passphrase, + for: passphraseData.licenseId, + userID: passphraseData.userId, + provider: passphraseData.provider + ) + successCount += 1 + } catch { + failureCount += 1 + // Log error but continue with other passphrases + log(.error, "Failed to migrate passphrase for license \(passphraseData.licenseId): \(error)") + } + } + + if failureCount > 0 { + log(.info, "Passphrase migration completed with \(successCount) succeeded, \(failureCount) failed") + } else { + log(.info, "Passphrase migration completed successfully: \(successCount) passphrases migrated") + } + + return failureCount == 0 } } diff --git a/Sources/Internal/Keychain.swift b/Sources/Internal/Keychain.swift new file mode 100644 index 0000000000..6f1add32f9 --- /dev/null +++ b/Sources/Internal/Keychain.swift @@ -0,0 +1,241 @@ +// +// 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 Security + +/// Errors occurring in ``Keychain``. +public enum KeychainError: Error { + /// The item was not found in the Keychain. + case itemNotFound + + /// An item with this key already exists. + case duplicateItem + + /// The data retrieved from the Keychain is invalid. + case invalidData + + /// An unhandled Keychain error occurred. + case unhandledError(OSStatus) +} + +/// Utility for managing Keychain operations. +/// +/// This class handles low-level Security framework calls for storing, retrieving, +/// updating, and deleting data from the iOS/macOS Keychain. +public final class Keychain: Sendable { + private let serviceName: String + private let synchronizable: Bool + + /// Initializes a ``Keychain`` with the specified configuration. + /// + /// - Parameters: + /// - serviceName: The service identifier for Keychain items. + /// - synchronizable: Whether items should sync via iCloud Keychain. + public init( + serviceName: String, + synchronizable: Bool = true + ) { + self.serviceName = serviceName + self.synchronizable = synchronizable + } + + /// Saves data to the Keychain with the specified key. + /// + /// - Parameters: + /// - data: The data to save. + /// - key: The account identifier. + public func save(data: Data, forKey key: String) throws (KeychainError) { + var query = baseQuery(forKey: key, forAdding: true) + query[kSecValueData as String] = data + + let status = SecItemAdd(query as CFDictionary, nil) + + guard status == errSecSuccess else { + throw mapError(status) + } + } + + /// Loads data from the Keychain for the specified key. + /// + /// - Parameter key: The account identifier. + /// - Returns: The data if found, or `nil` if no item exists with this key. + public func load(forKey key: String) throws (KeychainError) -> Data? { + var query = baseQuery(forKey: key, forAdding: false) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return nil + } + + guard status == errSecSuccess else { + throw mapError(status) + } + + guard let data = result as? Data else { + throw KeychainError.invalidData + } + + return data + } + + /// Updates existing data in the Keychain for the specified key. + /// + /// - Parameters: + /// - data: The new data to save. + /// - key: The account identifier. + public func update(data: Data, forKey key: String) throws (KeychainError) { + let query = baseQuery(forKey: key, forAdding: false) + let attributesToUpdate: [String: Any] = [ + kSecValueData as String: data, + ] + + let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + + guard status == errSecSuccess else { + throw mapError(status) + } + } + + /// Deletes an item from the Keychain for the specified key. + /// + /// - Parameter key: The account identifier. + public func delete(forKey key: String) throws (KeychainError) { + let query = baseQuery(forKey: key, forAdding: false) + let status = SecItemDelete(query as CFDictionary) + + // Success or item not found are both acceptable + guard status == errSecSuccess || status == errSecItemNotFound else { + throw mapError(status) + } + } + + /// Deletes all items for this service from the Keychain. + public func deleteAll() throws (KeychainError) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + ] + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw mapError(status) + } + } + + /// Returns all account identifiers (keys) stored for this service. + /// + /// - Returns: An array of account identifiers. + public func allKeys() throws (KeychainError) -> [String] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return [] + } + + guard status == errSecSuccess else { + throw mapError(status) + } + + guard let items = result as? [[String: Any]] else { + return [] + } + + return items.compactMap { $0[kSecAttrAccount as String] as? String } + } + + /// Returns all items stored for this service. + /// + /// - Returns: A dictionary where keys are account identifiers and values are + /// the stored data. + public func allItems() throws (KeychainError) -> [String: Data] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return [:] + } + + guard status == errSecSuccess else { + throw mapError(status) + } + + guard let items = result as? [[String: Any]] else { + return [:] + } + + var itemsDictionary: [String: Data] = [:] + for item in items { + if let account = item[kSecAttrAccount as String] as? String, + let data = item[kSecValueData as String] as? Data + { + itemsDictionary[account] = data + } + } + + return itemsDictionary + } + + // MARK: - Private Helpers + + /// Creates the base query dictionary for Keychain operations. + /// + /// - Parameters: + /// - key: The account identifier. + /// - forAdding: If `true`, uses the boolean `synchronizable` value for adding items. + /// If `false`, uses `kSecAttrSynchronizableAny` for queries/updates/deletes. + private func baseQuery(forKey key: String, forAdding: Bool) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + if forAdding { + query[kSecAttrSynchronizable as String] = synchronizable + } else { + query[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny + } + + return query + } + + /// Maps OSStatus error codes to KeychainError cases. + private func mapError(_ status: OSStatus) -> KeychainError { + switch status { + case errSecItemNotFound: + return .itemNotFound + case errSecDuplicateItem: + return .duplicateItem + default: + return .unhandledError(status) + } + } +} diff --git a/Sources/LCP/LCPPassphraseRepository.swift b/Sources/LCP/LCPPassphraseRepository.swift index 4d4a8278b1..b7d369a270 100644 --- a/Sources/LCP/LCPPassphraseRepository.swift +++ b/Sources/LCP/LCPPassphraseRepository.swift @@ -9,21 +9,23 @@ import Foundation /// Represents an LCP passphrase hash. public typealias LCPPassphraseHash = String -/// The passphrase repository stores passphrase hashes associated to a license document, user ID and -/// provider. +/// The passphrase repository stores passphrase hashes associated to a license +/// document, user ID and provider. public protocol LCPPassphraseRepository { /// Returns the passphrase hash associated with the given `licenseID`. func passphrase(for licenseID: LicenseDocument.ID) async throws -> LCPPassphraseHash? - /// Returns a list of passphrase hashes that may match the given `userID`, and `provider`. - func passphrasesMatching( - userID: User.ID?, - provider: LicenseDocument.Provider - ) async throws -> [LCPPassphraseHash] + /// Returns a list of passphrase hashes that may match the given `userID` + /// and `provider`. + func passphrasesMatching(userID: User.ID?, provider: LicenseDocument.Provider) async throws -> [LCPPassphraseHash] + + /// Returns all the saved passphrase hashes. + func passphrases() async throws -> [LCPPassphraseHash] /// Adds a new passphrase hash to the repository. /// - /// If a passphrase is already associated with the given `licenseID`, it will be updated. + /// If a passphrase is already associated with the given `licenseID`, it + /// will be updated. func addPassphrase( _ hash: LCPPassphraseHash, for licenseID: LicenseDocument.ID, diff --git a/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift new file mode 100644 index 0000000000..b889fceeca --- /dev/null +++ b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift @@ -0,0 +1,264 @@ +// +// 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 ReadiumInternal +import ReadiumShared + +/// Errors occurring in ``LCPKeychainLicenseRepository``. +public enum LCPKeychainLicenseRepositoryError: Error { + /// The license with the given `id` was not found in the repository. + case licenseNotFound(id: LicenseDocument.ID) + + /// An error occurred while accessing the keychain. + case keychain(KeychainError) + + /// An error occurred while decoding or encoding a License. + case coding(Error) +} + +/// Keychain-based implementation of ``LCPLicenseRepository``. +/// +/// Stores license data securely in the iOS/macOS Keychain with optional iCloud +/// synchronization. +public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { + /// Internal data structure for storing license information in the Keychain. + private struct License: Codable { + /// Unique identifier for this license. + let licenseID: LicenseDocument.ID + + /// JSON representation of the ``LicenseDocument``. + var licenseJSON: String? + + /// Remaining pages to print. + var printsLeft: Int? + + /// Remaining number of characters to copy. + var copiesLeft: Int? + + /// Date when the device was registered for this license. + var registered: Bool + + /// Date this license was added to the Keychain. + let created: Date + + /// Date this license was updated in the Keychain. + var updated: Date + } + + private let keychain: Keychain + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Initializes a Keychain-based license repository. + /// + /// - Parameters: + /// - synchronizable: Whether items should sync via iCloud Keychain. + public init(synchronizable: Bool = true) { + keychain = Keychain( + serviceName: "org.readium.lcp.licenses", + synchronizable: synchronizable + ) + + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + // MARK: - LCPLicenseRepository + + public func addLicense(_ licenseDocument: LicenseDocument) async throws { + if var license = try getLicense(for: licenseDocument.id) { + // License exists - update it without overwriting consumable rights + license.licenseJSON = licenseDocument.jsonString + try updateLicense(license, for: licenseDocument.id) + + } else { + // New license - initialize with rights from license document + let newLicense = License( + licenseID: licenseDocument.id, + licenseJSON: licenseDocument.jsonString, + printsLeft: licenseDocument.rights.print, + copiesLeft: licenseDocument.rights.copy, + registered: false, + created: Date(), + updated: Date() + ) + + try addLicense(newLicense, for: licenseDocument.id) + } + } + + public func license(for id: LicenseDocument.ID) async throws -> LicenseDocument? { + guard + let licenseData = try getLicense(for: id), + let jsonString = licenseData.licenseJSON, + let jsonData = jsonString.data(using: .utf8), + let licenseDocument = try? LicenseDocument(data: jsonData) + else { + return nil + } + + return licenseDocument + } + + public func isDeviceRegistered(for id: LicenseDocument.ID) async throws -> Bool { + try requireLicense(for: id).registered + } + + public func registerDevice(for id: LicenseDocument.ID) async throws { + var license = try requireLicense(for: id) + license.registered = true + try updateLicense(license, for: id) + } + + public func userRights(for id: LicenseDocument.ID) async throws -> LCPConsumableUserRights { + guard let licenseData = try getLicense(for: id) else { + throw LCPKeychainLicenseRepositoryError.licenseNotFound(id: id) + } + + return LCPConsumableUserRights( + print: licenseData.printsLeft, + copy: licenseData.copiesLeft + ) + } + + public func updateUserRights( + for id: LicenseDocument.ID, + with changes: (inout LCPConsumableUserRights) -> Void + ) async throws { + var license = try requireLicense(for: id) + + // Get current rights + var currentRights = LCPConsumableUserRights( + print: license.printsLeft, + copy: license.copiesLeft + ) + + // Apply changes + changes(¤tRights) + + // Update the data + license.printsLeft = currentRights.print + license.copiesLeft = currentRights.copy + + try updateLicense(license, for: id) + } + + // MARK: - Migration Support + + /// Imports license rights from an external source without requiring the + /// full ``LicenseDocument``. + /// + /// This is used during migration from repositories that don't store the + /// full document, like the legacy SQLite repositories. + /// + /// When the publication is later opened, `addLicense()` will add the full + /// document while preserving these migrated rights. + /// + /// - Parameters: + /// - licenseID: The license identifier + /// - rights: The consumable user rights to store + /// - registered: Whether the device is registered for this license + public func importLicenseRights( + for licenseID: LicenseDocument.ID, + rights: LCPConsumableUserRights, + registered: Bool + ) async throws { + // We don't overwrite the rights if the license already exists. + guard try getLicense(for: licenseID) == nil else { + return + } + + // Create new entry without the full license document, which will + // be added when the publication is opened again. + let newData = License( + licenseID: licenseID, + licenseJSON: nil, + printsLeft: rights.print, + copiesLeft: rights.copy, + registered: registered, + created: Date(), + updated: Date() + ) + try addLicense(newData, for: licenseID) + } + + // MARK: - Keychain Access + + private func requireLicense(for licenseID: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) -> License { + guard let license = try getLicense(for: licenseID) else { + throw .licenseNotFound(id: licenseID) + } + return license + } + + /// Gets a license from the Keychain for the given license ID. + private func getLicense(for licenseID: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) -> License? { + guard let data = try getFromKeychain(id: licenseID) else { + return nil + } + + return try decode(data) + } + + /// Adds a new license to the Keychain. + private func addLicense(_ license: License, for id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) { + try addToKeychain(data: encode(license), for: id) + } + + /// Updates an existing license in the Keychain. + private func updateLicense(_ license: License, for id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) { + var license = license + license.updated = Date() + let data = try encode(license) + try updateKeychain(data: data, for: id) + } + + // MARK: - Low-Level Helpers + + private func getFromKeychain(id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) -> Data? { + do { + return try keychain.load(forKey: id) + } catch { + throw .keychain(error) + } + } + + private func addToKeychain(data: Data, for id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) { + do { + try keychain.save(data: data, forKey: id) + } catch { + throw .keychain(error) + } + } + + private func updateKeychain(data: Data, for id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) { + do { + try keychain.update(data: data, forKey: id) + } catch { + throw .keychain(error) + } + } + + private func decode(_ data: Data) throws (LCPKeychainLicenseRepositoryError) -> License { + do { + return try decoder.decode(License.self, from: data) + } catch { + throw .coding(error) + } + } + + private func encode(_ license: License) throws (LCPKeychainLicenseRepositoryError) -> Data { + do { + return try encoder.encode(license) + } catch { + throw .coding(error) + } + } +} diff --git a/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift b/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift new file mode 100644 index 0000000000..5efa384e1b --- /dev/null +++ b/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift @@ -0,0 +1,198 @@ +// +// 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 ReadiumInternal +import ReadiumShared + +/// Errors occurring in ``LCPKeychainPassphraseRepository``. +public enum LCPKeychainPassphraseRepositoryError: Error { + /// An error occurred while accessing the keychain. + case keychain(KeychainError) + + /// An error occurred while decoding or encoding a passphrase. + case coding(Error) +} + +/// Keychain-based implementation of ``LCPPassphraseRepository``. +/// +/// Stores passphrase hashes securely in the iOS/macOS Keychain with optional +/// iCloud synchronization. +public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable { + /// Internal data structure for storing passphrase information in the + /// Keychain. + private struct Passphrase: Codable { + /// Unique identifier for the license this passphrase belongs to. + let licenseID: LicenseDocument.ID + + /// The hashed passphrase. + var passphraseHash: LCPPassphraseHash + + /// The license provider. + var provider: LicenseDocument.Provider + + /// The user identifier. + var userID: User.ID? + + /// Date this passphrase was added to the Keychain. + let created: Date + + /// Date this passphrase was updated in the Keychain. + var updated: Date + } + + private let keychain: Keychain + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Initializes a Keychain-based passphrase repository. + /// + /// - Parameters: + /// - synchronizable: Whether items should sync via iCloud Keychain. + public init(synchronizable: Bool = true) { + keychain = Keychain( + serviceName: "org.readium.lcp.passphrases", + synchronizable: synchronizable + ) + + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + // MARK: - LCPPassphraseRepository + + public func passphrase(for licenseID: LicenseDocument.ID) async throws -> LCPPassphraseHash? { + try getPassphrase(for: licenseID)?.passphraseHash + } + + public func passphrasesMatching( + userID: User.ID?, + provider: LicenseDocument.Provider + ) async throws -> [LCPPassphraseHash] { + try await getAllPassphrases() + .filter { passphrase in + passphrase.provider == provider && (userID == nil || passphrase.userID == userID) + } + .map(\.passphraseHash) + } + + public func passphrases() async throws -> [LCPPassphraseHash] { + try await getAllPassphrases() + .map(\.passphraseHash) + } + + public func addPassphrase( + _ hash: LCPPassphraseHash, + for licenseID: LicenseDocument.ID, + userID: User.ID?, + provider: LicenseDocument.Provider + ) async throws { + if var passphrase = try getPassphrase(for: licenseID) { + passphrase.passphraseHash = hash + passphrase.provider = provider + passphrase.userID = userID + try updatePassphrase(passphrase, for: licenseID) + } else { + let passphrase = Passphrase( + licenseID: licenseID, + passphraseHash: hash, + provider: provider, + userID: userID, + created: Date(), + updated: Date() + ) + + try addPassphrase(passphrase, for: licenseID) + } + } + + // MARK: - Keychain Access + + private func getAllPassphrases() async throws (LCPKeychainPassphraseRepositoryError) -> [Passphrase] { + try getAllFromKeychain() + .compactMap { _, data in + guard let passphrase = try? decoder.decode(Passphrase.self, from: data) else { + return nil + } + return passphrase + } + } + + /// Gets a passphrase from the Keychain for the given license ID. + private func getPassphrase(for licenseID: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) -> Passphrase? { + guard let data = try getFromKeychain(id: licenseID) else { + return nil + } + + return try decode(data) + } + + /// Adds a new passphrase to the Keychain. + private func addPassphrase(_ passphrase: Passphrase, for id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) { + try addToKeychain(data: encode(passphrase), for: id) + } + + /// Updates an existing passphrase in the Keychain. + private func updatePassphrase(_ passphrase: Passphrase, for id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) { + var passphrase = passphrase + passphrase.updated = Date() + let data = try encode(passphrase) + try updateKeychain(data: data, for: id) + } + + // MARK: - Low-Level Helpers + + private func getFromKeychain(id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) -> Data? { + do { + return try keychain.load(forKey: id) + } catch { + throw .keychain(error) + } + } + + private func getAllFromKeychain() throws (LCPKeychainPassphraseRepositoryError) -> [String: Data] { + do { + return try keychain.allItems() + } catch { + throw .keychain(error) + } + } + + private func addToKeychain(data: Data, for id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) { + do { + try keychain.save(data: data, forKey: id) + } catch { + throw .keychain(error) + } + } + + private func updateKeychain(data: Data, for id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) { + do { + try keychain.update(data: data, forKey: id) + } catch { + throw .keychain(error) + } + } + + private func decode(_ data: Data) throws (LCPKeychainPassphraseRepositoryError) -> Passphrase { + do { + return try decoder.decode(Passphrase.self, from: data) + } catch { + throw .coding(error) + } + } + + private func encode(_ passphrase: Passphrase) throws (LCPKeychainPassphraseRepositoryError) -> Data { + do { + return try encoder.encode(passphrase) + } catch { + throw .coding(error) + } + } +} diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index 4a39a4a2ac..6708ec35fe 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -38,12 +38,7 @@ final class PassphrasesService { return passphrase } - // Look for alternative candidates based on the provider and user ID. - let candidates = try await repository.passphrasesMatching( - userID: license.user.id, - provider: license.provider - ) - var passphrase: LCPPassphraseHash? = findValidPassphrase(in: candidates, for: license) + var passphrase = try await findAlternatePassphrase(for: license) // Fallback on the provided `LCPAuthenticating` implementation. if passphrase == nil, let authentication = authentication { @@ -64,6 +59,22 @@ final class PassphrasesService { return passphrase } + private func findAlternatePassphrase(for license: LicenseDocument) async throws -> LCPPassphraseHash? { + // Look for alternative candidates based on the provider and user ID. + let candidates = try await repository.passphrasesMatching( + userID: license.user.id, + provider: license.provider + ) + if let passphrase = findValidPassphrase(in: candidates, for: license) { + return passphrase + } + + // The legacy SQLite database did not save all the new (passphrase, + // userID, provider) tuples. So we need to fall back on checking all the + // saved passphrases for a match. + return try await findValidPassphrase(in: repository.passphrases(), for: license) + } + private func findValidPassphrase(in hashes: [LCPPassphraseHash], for license: LicenseDocument) -> LCPPassphraseHash? { guard !hashes.isEmpty else { return nil diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index f72a6e4f2c..648e6982ff 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -327,6 +327,7 @@ ../../Sources/Internal/Extensions/UInt64.swift ../../Sources/Internal/Extensions/URL.swift ../../Sources/Internal/JSON.swift +../../Sources/Internal/Keychain.swift ../../Sources/Internal/Measure.swift ../../Sources/Internal/UTI.swift ../../Sources/LCP @@ -374,6 +375,10 @@ ../../Sources/LCP/License/Model/Components/LSD/PotentialRights.swift ../../Sources/LCP/License/Model/LicenseDocument.swift ../../Sources/LCP/License/Model/StatusDocument.swift +../../Sources/LCP/Repositories +../../Sources/LCP/Repositories/Keychain +../../Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift +../../Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift ../../Sources/LCP/Resources ../../Sources/LCP/Resources/en.lproj ../../Sources/LCP/Resources/en.lproj/Localizable.strings diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 97b037195f..07a334bf74 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -120,6 +120,7 @@ 44152DBECE34F063AD0E93BC /* Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3E6442F0C7FE2098D71F27 /* Link.swift */; }; 448374F2605586249A6CB4C8 /* FailureResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78FFDF8CF77437EDB41E4547 /* FailureResource.swift */; }; 47125BFFEC67DEB2C3D1B48C /* AudioNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE34D74E282834684E1C999 /* AudioNavigator.swift */; }; + 483307A27A07B086D5FA8500 /* LCPKeychainPassphraseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD5904F9B9E29E2C1CA1CB5 /* LCPKeychainPassphraseRepository.swift */; }; 4977279900B4BA602D92B5C4 /* ReadiumFuzi.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2828D89EBB52CCA782ED1146 /* ReadiumFuzi.xcframework */; }; 4A5F53CCC083D3E348379963 /* Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF64D7C05A790D9CA5DD442 /* Types.swift */; }; 4AD286114A634A74BE78B1A0 /* LicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15980B67505AAF10642B56C8 /* LicenseContainer.swift */; }; @@ -179,6 +180,7 @@ 6B08C5FB1ABF696CDB6EDB03 /* LCPObservableAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791CEAC3DA5ED971DAE984CB /* LCPObservableAuthentication.swift */; }; 6BE745329D68EE0533E42D14 /* DiffableDecoration+HTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01265194649A8E2A821CC2A4 /* DiffableDecoration+HTML.swift */; }; 6C3C96A32EA2439AAEFD4967 /* ReadiumNavigatorLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBFAC2D57DE7EBB4E2F31BE /* ReadiumNavigatorLocalizedString.swift */; }; + 6CEB7B8167884E863116A1E0 /* LCPKeychainLicenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65A22BA2FF8230955BC7C06 /* LCPKeychainLicenseRepository.swift */; }; 6D3BCAFF29D91DCA08809D71 /* CRLService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93B0556DAAAF429893B0692 /* CRLService.swift */; }; 6DD5A5F5F08C76DA690FFB41 /* Contributor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 456192DBCB3A29ADA9C3CCB9 /* Contributor.swift */; }; 6F01765B4C03EC36C95D02E3 /* CGPDF.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6EB7CAF6D058380A2AB711A /* CGPDF.swift */; }; @@ -313,6 +315,7 @@ C368C73C819F65CE3409D35D /* Fuzi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE34EA8AF2D815F7169CA45 /* Fuzi.swift */; }; C3F4CBE80D741D4158CA8407 /* ReadiumInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42FD63C2720614E558522675 /* ReadiumInternal.framework */; }; C4F0A98562FDDB478F7DD0A9 /* LCPLicense.swift in Sources */ = {isa = PBXBuildFile; fileRef = 093629E752DE17264B97C598 /* LCPLicense.swift */; }; + C5D80E7716B243980FD3DFE6 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491E1402A31F88054442D58F /* Keychain.swift */; }; C73D876AC0852AE89D6AC3A1 /* ManifestTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D33BD0E923EACCCDB91362C /* ManifestTransformer.swift */; }; C77A30C7519839AA94996549 /* SQLite.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = F07214E263C6589987A561F9 /* SQLite.xcframework */; }; C8786D16C8EDCC0AECCA36E4 /* CoverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F0C112656C4786F3861973 /* CoverService.swift */; }; @@ -603,6 +606,7 @@ 47B9196192A22B8AB80E6B2F /* LCPDFPositionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPDFPositionsService.swift; sourceTree = ""; }; 48435C1A16C23C5BBB9C590C /* DirectoryContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryContainer.swift; sourceTree = ""; }; 48856E9AB402E2907B5230F3 /* CGRect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGRect.swift; sourceTree = ""; }; + 491E1402A31F88054442D58F /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; 4944D2DB99CC59F945FDA2CA /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 4BB5D42EEF0083D833E2A572 /* Publication+OPDS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publication+OPDS.swift"; sourceTree = ""; }; 4BCDF341872EEFB88B6674DE /* HTTPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPServer.swift; sourceTree = ""; }; @@ -757,6 +761,7 @@ B22A0E76866F626D79F0A64C /* SQLiteLCPPassphraseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLiteLCPPassphraseRepository.swift; sourceTree = ""; }; B53B841C2F5A59BA3B161258 /* Resource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = ""; }; B5CE464C519852D38F873ADB /* PotentialRights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotentialRights.swift; sourceTree = ""; }; + B65A22BA2FF8230955BC7C06 /* LCPKeychainLicenseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPKeychainLicenseRepository.swift; sourceTree = ""; }; B7457AD096857CA307F6ED6A /* InputObservable+Legacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InputObservable+Legacy.swift"; sourceTree = ""; }; B7C9D54352714641A87F64A0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; BAA7CEF568A02BA2CB4AAD7F /* OPDSFormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSFormatSniffer.swift; sourceTree = ""; }; @@ -788,6 +793,7 @@ CAD79372361D085CA0500CF4 /* Properties+OPDS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+OPDS.swift"; sourceTree = ""; }; CBB57FCAEE605484A7290DBB /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; CC925E451D875E5F74748EDC /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; + CCD5904F9B9E29E2C1CA1CB5 /* LCPKeychainPassphraseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPKeychainPassphraseRepository.swift; sourceTree = ""; }; CDA8111A330AB4D7187DD743 /* LocatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorService.swift; sourceTree = ""; }; CF31AEFB5FF0E7892C6D903E /* EPUBPreferences+Legacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EPUBPreferences+Legacy.swift"; sourceTree = ""; }; CFE1142A6C038A35C527CE84 /* URITemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URITemplate.swift; sourceTree = ""; }; @@ -1370,6 +1376,7 @@ isa = PBXGroup; children = ( 529B55BE6996FCDC1082BF0A /* JSON.swift */, + 491E1402A31F88054442D58F /* Keychain.swift */, 4FEE01FAD273864D0908C358 /* Measure.swift */, 218BE3110D2886B252A769A2 /* UTI.swift */, 40D18A37080F5B1D114CE2E1 /* Extensions */, @@ -1541,6 +1548,14 @@ path = XML; sourceTree = ""; }; + 747E6C3EE93B2F8240970E94 /* Repositories */ = { + isa = PBXGroup; + children = ( + D0B5292E979486B3745DC1BD /* Keychain */, + ); + path = Repositories; + sourceTree = ""; + }; 75C5238287B0D2F1DF6889DB /* Protection */ = { isa = PBXGroup; children = ( @@ -1895,6 +1910,15 @@ path = Parser; sourceTree = ""; }; + D0B5292E979486B3745DC1BD /* Keychain */ = { + isa = PBXGroup; + children = ( + B65A22BA2FF8230955BC7C06 /* LCPKeychainLicenseRepository.swift */, + CCD5904F9B9E29E2C1CA1CB5 /* LCPKeychainPassphraseRepository.swift */, + ); + path = Keychain; + sourceTree = ""; + }; D4358DF9D15D9ADE4F9E8BE4 /* LCP */ = { isa = PBXGroup; children = ( @@ -1910,6 +1934,7 @@ 7F42F058A2DC364B554BF7F2 /* Authentications */, F9064CEF2968AEDCDCCFD399 /* Content Protection */, 2C4C6FBF69B19C83DFCCF835 /* License */, + 747E6C3EE93B2F8240970E94 /* Repositories */, F389B1290B1CAA8E5F65573B /* Resources */, 11502B18FA9A9C92352052CE /* Services */, B25D1AE9818E91E1D1497ABB /* Toolkit */, @@ -2511,6 +2536,7 @@ 330690F62A5F240B77A14337 /* Date+ISO8601.swift in Sources */, 674BEEF110667C3051296E9B /* Double.swift in Sources */, DDD0C8AC27EF8D1A893DF6CC /* JSON.swift in Sources */, + C5D80E7716B243980FD3DFE6 /* Keychain.swift in Sources */, E8C3B837B9FB2ABCB5F82380 /* Measure.swift in Sources */, F631EA324143E669070523F3 /* NSRegularExpression.swift in Sources */, 606EBE8AC2096BC681F92908 /* Number.swift in Sources */, @@ -2559,6 +2585,8 @@ B066F9DDCD00A8917478CB6C /* LCPDialogViewController.swift in Sources */, 25349166318EB00EE8A0765C /* LCPError+wrap.swift in Sources */, 98702AFB56F9C50F7246CDDA /* LCPError.swift in Sources */, + 6CEB7B8167884E863116A1E0 /* LCPKeychainLicenseRepository.swift in Sources */, + 483307A27A07B086D5FA8500 /* LCPKeychainPassphraseRepository.swift in Sources */, C4F0A98562FDDB478F7DD0A9 /* LCPLicense.swift in Sources */, 9A463F872E1B05B64E026EBB /* LCPLicenseRepository.swift in Sources */, 6B08C5FB1ABF696CDB6EDB03 /* LCPObservableAuthentication.swift in Sources */, diff --git a/TestApp/Integrations/Carthage/project+lcp.yml b/TestApp/Integrations/Carthage/project+lcp.yml index e6d96a5294..c533f379a2 100644 --- a/TestApp/Integrations/Carthage/project+lcp.yml +++ b/TestApp/Integrations/Carthage/project+lcp.yml @@ -28,7 +28,6 @@ targets: - framework: Carthage/Build/Minizip.xcframework - framework: Carthage/Build/R2LCPClient.xcframework - framework: Carthage/Build/ReadiumAdapterGCDWebServer.xcframework - - framework: Carthage/Build/ReadiumAdapterLCPSQLite.xcframework - framework: Carthage/Build/ReadiumFuzi.xcframework - framework: Carthage/Build/ReadiumGCDWebServer.xcframework - framework: Carthage/Build/ReadiumInternal.xcframework @@ -38,7 +37,6 @@ targets: - framework: Carthage/Build/ReadiumShared.xcframework - framework: Carthage/Build/ReadiumStreamer.xcframework - framework: Carthage/Build/ReadiumZIPFoundation.xcframework - - framework: Carthage/Build/SQLite.xcframework - framework: Carthage/Build/SwiftSoup.xcframework - package: GRDB - package: Kingfisher diff --git a/TestApp/Integrations/CocoaPods/Podfile+lcp b/TestApp/Integrations/CocoaPods/Podfile+lcp index 5fa4c80ac8..7d10b49e32 100644 --- a/TestApp/Integrations/CocoaPods/Podfile+lcp +++ b/TestApp/Integrations/CocoaPods/Podfile+lcp @@ -13,7 +13,6 @@ target 'TestApp' do pod 'ReadiumOPDS', '~> VERSION' pod 'ReadiumLCP', '~> VERSION' pod 'ReadiumAdapterGCDWebServer', '~> VERSION' - pod 'ReadiumAdapterLCPSQLite', '~> VERSION' pod 'R2LCPClient', podspec: 'LCP_URL' pod 'GRDB.swift', '~> 6.0' diff --git a/TestApp/Integrations/Local/project+lcp.yml b/TestApp/Integrations/Local/project+lcp.yml index 4cc5bbbba8..b2e266f2d9 100644 --- a/TestApp/Integrations/Local/project+lcp.yml +++ b/TestApp/Integrations/Local/project+lcp.yml @@ -49,8 +49,6 @@ targets: product: ReadiumNavigator - package: Readium product: ReadiumAdapterGCDWebServer - - package: Readium - product: ReadiumAdapterLCPSQLite - package: Readium product: ReadiumOPDS - package: Readium diff --git a/TestApp/Integrations/SPM/project+lcp.yml b/TestApp/Integrations/SPM/project+lcp.yml index 193da92268..058b595927 100644 --- a/TestApp/Integrations/SPM/project+lcp.yml +++ b/TestApp/Integrations/SPM/project+lcp.yml @@ -41,8 +41,6 @@ targets: product: ReadiumNavigator - package: Readium product: ReadiumAdapterGCDWebServer - - package: Readium - product: ReadiumAdapterLCPSQLite - package: Readium product: ReadiumOPDS - package: Readium diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index 3aec73e4df..a7c6eba878 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -12,7 +12,6 @@ import ReadiumStreamer #if LCP import R2LCPClient - import ReadiumAdapterLCPSQLite import ReadiumLCP #endif @@ -45,8 +44,8 @@ final class Readium { lazy var lcpService = LCPService( client: LCPClient(), - licenseRepository: try! LCPSQLiteLicenseRepository(), - passphraseRepository: try! LCPSQLitePassphraseRepository(), + licenseRepository: LCPKeychainLicenseRepository(), + passphraseRepository: LCPKeychainPassphraseRepository(), assetRetriever: assetRetriever, httpClient: httpClient ) diff --git a/TestApp/Sources/LCP/LCPModule.swift b/TestApp/Sources/LCP/LCPModule.swift index cbeafb581b..4aaf244d0e 100644 --- a/TestApp/Sources/LCP/LCPModule.swift +++ b/TestApp/Sources/LCP/LCPModule.swift @@ -9,7 +9,6 @@ import ReadiumShared #if LCP import R2LCPClient - import ReadiumAdapterLCPSQLite import ReadiumLCP #endif diff --git a/Tests/InternalTests/KeychainTests.swift b/Tests/InternalTests/KeychainTests.swift new file mode 100644 index 0000000000..c5c285a87f --- /dev/null +++ b/Tests/InternalTests/KeychainTests.swift @@ -0,0 +1,235 @@ +// +// 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 +@testable import ReadiumInternal +import Testing + +// FIXME: Keychain testing require an host application with entitlements. +/* + @Suite struct KeychainTests { + let keychain: Keychain + let testServiceName = "org.readium.lcp.test.keychain-helper" + + init() throws { + keychain = Keychain( + serviceName: testServiceName, + synchronizable: false + ) + // Clean up any existing test data + try? keychain.deleteAll() + } + + // MARK: - Save Tests + + @Test func saveData() throws { + defer { try? keychain.deleteAll() } + + let testData = "Test Value".data(using: .utf8)! + try keychain.save(data: testData, forKey: "test-key") + + let retrieved = try keychain.load(forKey: "test-key") + #expect(retrieved == testData) + } + + @Test func saveDuplicateKeyThrowsError() throws { + defer { try? keychain.deleteAll() } + + let testData = "Test Value".data(using: .utf8)! + try keychain.save(data: testData, forKey: "duplicate-key") + + #expect(throws: KeychainError.self) { + try keychain.save(data: testData, forKey: "duplicate-key") + } + } + + @Test func saveMultipleKeys() throws { + defer { try? keychain.deleteAll() } + + let data1 = "Value 1".data(using: .utf8)! + let data2 = "Value 2".data(using: .utf8)! + let data3 = "Value 3".data(using: .utf8)! + + try keychain.save(data: data1, forKey: "key1") + try keychain.save(data: data2, forKey: "key2") + try keychain.save(data: data3, forKey: "key3") + + let loaded1 = try keychain.load(forKey: "key1") + let loaded2 = try keychain.load(forKey: "key2") + let loaded3 = try keychain.load(forKey: "key3") + #expect(loaded1 == data1) + #expect(loaded2 == data2) + #expect(loaded3 == data3) + } + + // MARK: - Load Tests + + @Test func loadNonExistentKeyReturnsNil() throws { + defer { try? keychain.deleteAll() } + + let result = try keychain.load(forKey: "non-existent") + #expect(result == nil) + } + + @Test func loadAfterSave() throws { + defer { try? keychain.deleteAll() } + + let testData = "Persistent Value".data(using: .utf8)! + try keychain.save(data: testData, forKey: "persistent-key") + + let loaded = try keychain.load(forKey: "persistent-key") + #expect(loaded == testData) + } + + // MARK: - Update Tests + + @Test func updateExistingKey() throws { + defer { try? keychain.deleteAll() } + + let originalData = "Original".data(using: .utf8)! + let updatedData = "Updated".data(using: .utf8)! + + try keychain.save(data: originalData, forKey: "update-key") + try keychain.update(data: updatedData, forKey: "update-key") + + let result = try keychain.load(forKey: "update-key") + #expect(result == updatedData) + } + + @Test func updateNonExistentKeyThrowsError() throws { + defer { try? keychain.deleteAll() } + + let testData = "Test".data(using: .utf8)! + + #expect(throws: KeychainError.self) { + try keychain.update(data: testData, forKey: "non-existent") + } + } + + // MARK: - Delete Tests + + @Test func deleteExistingKey() throws { + defer { try? keychain.deleteAll() } + + let testData = "Delete Me".data(using: .utf8)! + try keychain.save(data: testData, forKey: "delete-key") + + try keychain.delete(forKey: "delete-key") + + let result = try keychain.load(forKey: "delete-key") + #expect(result == nil) + } + + @Test func deleteNonExistentKeyDoesNotThrow() throws { + defer { try? keychain.deleteAll() } + + // Should not throw an error + #expect(throws: Never.self) { + try keychain.delete(forKey: "non-existent") + } + } + + // MARK: - DeleteAll Tests + + @Test func deleteAll() throws { + defer { try? keychain.deleteAll() } + + let data1 = "Value 1".data(using: .utf8)! + let data2 = "Value 2".data(using: .utf8)! + let data3 = "Value 3".data(using: .utf8)! + + try keychain.save(data: data1, forKey: "key1") + try keychain.save(data: data2, forKey: "key2") + try keychain.save(data: data3, forKey: "key3") + + try keychain.deleteAll() + + let loaded1 = try keychain.load(forKey: "key1") + let loaded2 = try keychain.load(forKey: "key2") + let loaded3 = try keychain.load(forKey: "key3") + #expect(loaded1 == nil) + #expect(loaded2 == nil) + #expect(loaded3 == nil) + } + + @Test func deleteAllWithNoItemsDoesNotThrow() throws { + #expect(throws: Never.self) { + try keychain.deleteAll() + } + } + + // MARK: - AllKeys Tests + + @Test func allKeysEmpty() throws { + defer { try? keychain.deleteAll() } + + let keys = try keychain.allKeys() + #expect(keys.isEmpty) + } + + @Test func allKeysReturnsSavedKeys() throws { + defer { try? keychain.deleteAll() } + + let data = "Test".data(using: .utf8)! + try keychain.save(data: data, forKey: "key1") + try keychain.save(data: data, forKey: "key2") + try keychain.save(data: data, forKey: "key3") + + let keys = try keychain.allKeys() + #expect(Set(keys) == Set(["key1", "key2", "key3"])) + } + + // MARK: - AllItems Tests + + @Test func allItemsEmpty() throws { + defer { try? keychain.deleteAll() } + + let items = try keychain.allItems() + #expect(items.isEmpty) + } + + @Test func allItemsReturnsSavedData() throws { + defer { try? keychain.deleteAll() } + + let data1 = "Value 1".data(using: .utf8)! + let data2 = "Value 2".data(using: .utf8)! + + try keychain.save(data: data1, forKey: "key1") + try keychain.save(data: data2, forKey: "key2") + + let items = try keychain.allItems() + #expect(items.count == 2) + #expect(items["key1"] == data1) + #expect(items["key2"] == data2) + } + + // MARK: - Service Isolation Tests + + @Test func serviceIsolation() throws { + // Create two keychains with different service names + let keychain1 = Keychain( + serviceName: "org.readium.lcp.test.service1", + synchronizable: false + ) + let keychain2 = Keychain( + serviceName: "org.readium.lcp.test.service2", + synchronizable: false + ) + + defer { + try? keychain1.deleteAll() + try? keychain2.deleteAll() + } + + let data = "Test".data(using: .utf8)! + try keychain1.save(data: data, forKey: "shared-key") + + // keychain2 should not see the data from keychain1 + let loaded = try keychain2.load(forKey: "shared-key") + #expect(loaded == nil) + } + } + */ diff --git a/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift b/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift new file mode 100644 index 0000000000..5152fa7dd7 --- /dev/null +++ b/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift @@ -0,0 +1,417 @@ +// +// 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 +@testable import ReadiumLCP +import ReadiumShared +import Testing + +@Suite struct LCPKeychainLicenseRepositoryTests { + let repository: LCPKeychainLicenseRepository + + init() throws { + repository = LCPKeychainLicenseRepository( + synchronizable: false + ) + // Clean up any existing test data + try? cleanupAllTestData() + } + + private func cleanupAllTestData() throws { + // Delete all test licenses by using the Keychain directly + let keychain = Keychain( + serviceName: "org.readium.lcp.licenses", + synchronizable: false + ) + try keychain.deleteAll() + } + + // MARK: - Test Helpers + + private func createTestLicenseDocument( + id: String = UUID().uuidString, + printLimit: Int? = 10, + copyLimit: Int? = 100 + ) throws -> LicenseDocument { + let licenseJSON = """ + { + "provider": "https://test.provider.com", + "id": "\(id)", + "issued": "2024-01-01T00:00:00Z", + "updated": "2024-01-01T00:00:00Z", + "encryption": { + "profile": "http://readium.org/lcp/basic-profile", + "content_key": { + "algorithm": "http://www.w3.org/2001/04/xmlenc#aes256-cbc", + "encrypted_value": "dGVzdA==" + }, + "user_key": { + "algorithm": "http://www.w3.org/2001/04/xmlenc#sha256", + "text_hint": "Enter your passphrase", + "key_check": "dGVzdA==" + } + }, + "links": [ + { + "rel": "publication", + "href": "https://test.com/publication", + "type": "application/epub+zip" + } + ], + "user": { + "id": "user123", + "email": "test@example.com", + "name": "Test User" + }, + "rights": { + \(printLimit != nil ? "\"print\": \(printLimit!)," : "") + \(copyLimit != nil ? "\"copy\": \(copyLimit!)," : "") + "start": "2024-01-01T00:00:00Z" + }, + "signature": { + "algorithm": "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", + "certificate": "dGVzdA==", + "value": "dGVzdA==" + } + } + """ + + let data = licenseJSON.data(using: .utf8)! + return try LicenseDocument(data: data) + } + + // MARK: - AddLicense Tests + + @Test func addLicenseNewLicense() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-license-1", + printLimit: 5, + copyLimit: 50 + ) + + try await repository.addLicense(license) + + // Verify the license was added + let storedLicense = try await repository.license(for: license.id) + #expect(storedLicense != nil) + #expect(storedLicense?.id == license.id) + + // Verify user rights were initialized + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 5) + #expect(rights.copy == 50) + + // Verify device not registered initially + let registered = try await repository.isDeviceRegistered(for: license.id) + #expect(!registered) + } + + @Test func addLicenseExistingLicenseDoesNotOverwriteRights() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-license-2", + printLimit: 10, + copyLimit: 100 + ) + + // Add license first time + try await repository.addLicense(license) + + // Consume some rights + try await repository.updateUserRights(for: license.id) { rights in + rights.print = 5 + rights.copy = 50 + } + + // Add license again (simulating re-adding same license) + try await repository.addLicense(license) + + // Rights should NOT be reset + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 5) + #expect(rights.copy == 50) + } + + @Test func addLicenseWithNilRights() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-license-unlimited", + printLimit: nil, + copyLimit: nil + ) + + try await repository.addLicense(license) + + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == nil) + #expect(rights.copy == nil) + } + + // MARK: - License Retrieval Tests + + @Test func licenseRetrievalReturnsStoredDocument() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument(id: "test-license-retrieve") + + try await repository.addLicense(license) + + let retrieved = try await repository.license(for: license.id) + #expect(retrieved != nil) + #expect(retrieved?.id == license.id) + #expect(retrieved?.provider == license.provider) + #expect(retrieved?.user.id == license.user.id) + } + + @Test func licenseRetrievalNonExistentReturnsNil() async throws { + defer { try? cleanupAllTestData() } + + let retrieved = try await repository.license(for: "non-existent-license") + #expect(retrieved == nil) + } + + // MARK: - Device Registration Tests + + @Test func deviceRegistrationInitiallyFalse() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument(id: "test-registration-1") + try await repository.addLicense(license) + + let registered = try await repository.isDeviceRegistered(for: license.id) + #expect(!registered) + } + + @Test func registerDevice() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument(id: "test-registration-2") + try await repository.addLicense(license) + + try await repository.registerDevice(for: license.id) + + let registered = try await repository.isDeviceRegistered(for: license.id) + #expect(registered) + } + + @Test func registerDeviceIdempotent() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument(id: "test-registration-3") + try await repository.addLicense(license) + + try await repository.registerDevice(for: license.id) + try await repository.registerDevice(for: license.id) + + let registered = try await repository.isDeviceRegistered(for: license.id) + #expect(registered) + } + + @Test func deviceRegistrationNonExistentLicenseThrows() async throws { + defer { try? cleanupAllTestData() } + + await #expect(throws: (any Error).self) { + _ = try await repository.isDeviceRegistered(for: "non-existent") + } + } + + @Test func registerDeviceNonExistentLicenseThrows() async throws { + defer { try? cleanupAllTestData() } + + await #expect(throws: (any Error).self) { + try await repository.registerDevice(for: "non-existent") + } + } + + // MARK: - User Rights Tests + + @Test func userRightsRetrieval() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-rights-1", + printLimit: 20, + copyLimit: 200 + ) + try await repository.addLicense(license) + + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 20) + #expect(rights.copy == 200) + } + + @Test func userRightsNonExistentLicenseThrows() async throws { + defer { try? cleanupAllTestData() } + + await #expect(throws: (any Error).self) { + _ = try await repository.userRights(for: "non-existent") + } + } + + @Test func updateUserRights() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-rights-update", + printLimit: 10, + copyLimit: 100 + ) + try await repository.addLicense(license) + + try await repository.updateUserRights(for: license.id) { rights in + rights.print = 5 + rights.copy = 50 + } + + let updatedRights = try await repository.userRights(for: license.id) + #expect(updatedRights.print == 5) + #expect(updatedRights.copy == 50) + } + + @Test func updateUserRightsDecrement() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-rights-decrement", + printLimit: 10, + copyLimit: 100 + ) + try await repository.addLicense(license) + + // Simulate consuming a print + try await repository.updateUserRights(for: license.id) { rights in + if let currentPrint = rights.print { + rights.print = max(0, currentPrint - 1) + } + } + + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 9) + #expect(rights.copy == 100) + } + + @Test func updateUserRightsToNil() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-rights-nil", + printLimit: 10, + copyLimit: 100 + ) + try await repository.addLicense(license) + + try await repository.updateUserRights(for: license.id) { rights in + rights.print = nil + rights.copy = nil + } + + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == nil) + #expect(rights.copy == nil) + } + + @Test func updateUserRightsNonExistentLicenseThrows() async throws { + defer { try? cleanupAllTestData() } + + await #expect(throws: (any Error).self) { + try await repository.updateUserRights(for: "non-existent") { _ in } + } + } + + // MARK: - Concurrency Tests + + @Test func concurrentAddLicense() async throws { + defer { try? cleanupAllTestData() } + + let licenses = try (0 ..< 5).map { index in + try createTestLicenseDocument(id: "concurrent-\(index)") + } + + // Add licenses concurrently + try await withThrowingTaskGroup(of: Void.self) { group in + for license in licenses { + group.addTask { + try await repository.addLicense(license) + } + } + try await group.waitForAll() + } + + // Verify all licenses were added + for license in licenses { + let stored = try await repository.license(for: license.id) + #expect(stored != nil) + } + } + + @Test func concurrentUserRightsUpdate() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "concurrent-rights", + printLimit: 100, + copyLimit: 1000 + ) + try await repository.addLicense(license) + + // Perform concurrent updates + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0 ..< 10 { + group.addTask { + try await repository.updateUserRights(for: license.id) { rights in + if let currentPrint = rights.print { + rights.print = max(0, currentPrint - 1) + } + } + } + } + try await group.waitForAll() + } + + // Verify rights were decremented correctly + // Note: Due to actor serialization, all updates should apply + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 90) + } + + // MARK: - Multiple License Tests + + @Test func multipleLicenses() async throws { + defer { try? cleanupAllTestData() } + + let license1 = try createTestLicenseDocument( + id: "multi-1", + printLimit: 5, + copyLimit: 50 + ) + let license2 = try createTestLicenseDocument( + id: "multi-2", + printLimit: 10, + copyLimit: 100 + ) + let license3 = try createTestLicenseDocument( + id: "multi-3", + printLimit: 15, + copyLimit: 150 + ) + + try await repository.addLicense(license1) + try await repository.addLicense(license2) + try await repository.addLicense(license3) + + let rights1 = try await repository.userRights(for: "multi-1") + let rights2 = try await repository.userRights(for: "multi-2") + let rights3 = try await repository.userRights(for: "multi-3") + + #expect(rights1.print == 5) + #expect(rights2.print == 10) + #expect(rights3.print == 15) + } +} diff --git a/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift b/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift new file mode 100644 index 0000000000..bc1f8c42c1 --- /dev/null +++ b/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift @@ -0,0 +1,375 @@ +// +// 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 +@testable import ReadiumLCP +import ReadiumShared +import Testing + +@Suite struct LCPKeychainPassphraseRepositoryTests { + let repository: LCPKeychainPassphraseRepository + + init() throws { + repository = LCPKeychainPassphraseRepository( + synchronizable: false + ) + // Clean up any existing test data + try? cleanupAllTestData() + } + + private func cleanupAllTestData() throws { + // Delete all test passphrases by using the Keychain directly + let keychain = Keychain( + serviceName: "org.readium.lcp.passphrases", + synchronizable: false + ) + try keychain.deleteAll() + } + + // MARK: - AddPassphrase Tests + + @Test func addPassphrase() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash123", + for: "license-1", + userID: "user-1", + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-1") + #expect(retrieved == "hash123") + } + + @Test func addPassphraseUpsert() async throws { + defer { try? cleanupAllTestData() } + + // Add initial passphrase + try await repository.addPassphrase( + "hash-old", + for: "license-2", + userID: "user-1", + provider: "https://provider.com" + ) + + // Update with new passphrase + try await repository.addPassphrase( + "hash-new", + for: "license-2", + userID: "user-1", + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-2") + #expect(retrieved == "hash-new") + } + + @Test func addPassphraseWithNilUserID() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-no-user", + for: "license-3", + userID: nil, + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-3") + #expect(retrieved == "hash-no-user") + } + + // MARK: - Passphrase Retrieval Tests + + @Test func passphraseForLicense() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-retrieve", + for: "license-retrieve", + userID: "user-1", + provider: "https://provider.com" + ) + + let passphrase = try await repository.passphrase(for: "license-retrieve") + #expect(passphrase == "hash-retrieve") + } + + @Test func passphraseForNonExistentLicense() async throws { + defer { try? cleanupAllTestData() } + + let passphrase = try await repository.passphrase(for: "non-existent-license") + #expect(passphrase == nil) + } + + // MARK: - PassphrasesMatching Tests + + @Test func passphrasesMatchingByProviderAndUserID() async throws { + defer { try? cleanupAllTestData() } + + // Add passphrases with different providers and user IDs + try await repository.addPassphrase( + "hash-1", + for: "license-1", + userID: "user-1", + provider: "https://provider1.com" + ) + try await repository.addPassphrase( + "hash-2", + for: "license-2", + userID: "user-1", + provider: "https://provider1.com" + ) + try await repository.addPassphrase( + "hash-3", + for: "license-3", + userID: "user-2", + provider: "https://provider1.com" + ) + try await repository.addPassphrase( + "hash-4", + for: "license-4", + userID: "user-1", + provider: "https://provider2.com" + ) + + // Search for passphrases with provider1 and user-1 + let matches = try await repository.passphrasesMatching( + userID: "user-1", + provider: "https://provider1.com" + ) + + #expect(Set(matches) == Set(["hash-1", "hash-2"])) + } + + @Test func passphrasesMatchingByProviderOnly() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-1", + for: "license-1", + userID: "user-1", + provider: "https://provider.com" + ) + try await repository.addPassphrase( + "hash-2", + for: "license-2", + userID: "user-2", + provider: "https://provider.com" + ) + try await repository.addPassphrase( + "hash-3", + for: "license-3", + userID: "user-3", + provider: "https://other-provider.com" + ) + + // Search with nil userID should match all for the provider + let matches = try await repository.passphrasesMatching( + userID: nil, + provider: "https://provider.com" + ) + + #expect(Set(matches) == Set(["hash-1", "hash-2"])) + } + + @Test func passphrasesMatchingNoMatches() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-1", + for: "license-1", + userID: "user-1", + provider: "https://provider.com" + ) + + let matches = try await repository.passphrasesMatching( + userID: "user-99", + provider: "https://non-existent.com" + ) + + #expect(matches.isEmpty) + } + + @Test func passphrasesMatchingEmptyRepository() async throws { + defer { try? cleanupAllTestData() } + + let matches = try await repository.passphrasesMatching( + userID: "user-1", + provider: "https://provider.com" + ) + + #expect(matches.isEmpty) + } + + // MARK: - Multiple Passphrases Tests + + @Test func multiplePassphrasesForDifferentLicenses() async throws { + defer { try? cleanupAllTestData() } + + let passphrases = [ + ("license-1", "hash-1"), + ("license-2", "hash-2"), + ("license-3", "hash-3"), + ] + + for (licenseID, hash) in passphrases { + try await repository.addPassphrase( + hash, + for: licenseID, + userID: "user-1", + provider: "https://provider.com" + ) + } + + // Verify each passphrase can be retrieved + for (licenseID, expectedHash) in passphrases { + let retrieved = try await repository.passphrase(for: licenseID) + #expect(retrieved == expectedHash) + } + } + + // MARK: - Concurrency Tests + + @Test func concurrentAddPassphrase() async throws { + defer { try? cleanupAllTestData() } + + let passphrases = (0 ..< 10).map { index in + ("license-concurrent-\(index)", "hash-\(index)") + } + + // Add passphrases concurrently + try await withThrowingTaskGroup(of: Void.self) { group in + for (licenseID, hash) in passphrases { + group.addTask { + try await repository.addPassphrase( + hash, + for: licenseID, + userID: "user-1", + provider: "https://provider.com" + ) + } + } + try await group.waitForAll() + } + + // Verify all passphrases were added + for (licenseID, expectedHash) in passphrases { + let retrieved = try await repository.passphrase(for: licenseID) + #expect(retrieved == expectedHash) + } + } + + // MARK: - Special Characters Tests + + @Test func passphraseWithSpecialCharacters() async throws { + defer { try? cleanupAllTestData() } + + let specialHashes = [ + "hash+with+plus", + "hash/with/slash", + "hash=with=equals", + "hash-with-unicode-é-ñ-中", + ] + + for (index, hash) in specialHashes.enumerated() { + try await repository.addPassphrase( + hash, + for: "license-special-\(index)", + userID: "user-1", + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-special-\(index)") + #expect(retrieved == hash) + } + } + + @Test func providerWithSpecialCharacters() async throws { + defer { try? cleanupAllTestData() } + + let providers = [ + "https://provider.com/path?query=value", + "https://provider.com:8080", + "https://provider.com/path#fragment", + ] + + for (index, provider) in providers.enumerated() { + try await repository.addPassphrase( + "hash-\(index)", + for: "license-provider-\(index)", + userID: "user-1", + provider: provider + ) + + let matches = try await repository.passphrasesMatching( + userID: "user-1", + provider: provider + ) + + #expect(matches.contains("hash-\(index)")) + } + } + + // MARK: - Edge Cases Tests + + @Test func longPassphraseHash() async throws { + defer { try? cleanupAllTestData() } + + // Test with very long hash (e.g., 512-bit hash) + let longHash = String(repeating: "a", count: 128) + + try await repository.addPassphrase( + longHash, + for: "license-long-hash", + userID: "user-1", + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-long-hash") + #expect(retrieved == longHash) + } + + @Test func longUserID() async throws { + defer { try? cleanupAllTestData() } + + let longUserID = String(repeating: "u", count: 200) + + try await repository.addPassphrase( + "hash", + for: "license-long-user", + userID: longUserID, + provider: "https://provider.com" + ) + + let matches = try await repository.passphrasesMatching( + userID: longUserID, + provider: "https://provider.com" + ) + + #expect(matches == ["hash"]) + } + + @Test func longProvider() async throws { + defer { try? cleanupAllTestData() } + + let longProvider = "https://provider.com/" + String(repeating: "p", count: 200) + + try await repository.addPassphrase( + "hash", + for: "license-long-provider", + userID: "user-1", + provider: longProvider + ) + + let matches = try await repository.passphrasesMatching( + userID: "user-1", + provider: longProvider + ) + + #expect(matches == ["hash"]) + } +} diff --git a/docs/Guides/Getting Started.md b/docs/Guides/Getting Started.md index e61d58a1d7..30606e66bf 100644 --- a/docs/Guides/Getting Started.md +++ b/docs/Guides/Getting Started.md @@ -29,7 +29,6 @@ The toolkit has been designed following these core tenets: ### Adapters to third-party dependencies * `ReadiumAdapterGCDWebServer` provides an HTTP server built with [GCDWebServer](https://github.com/swisspol/GCDWebServer). -* `ReadiumAdapterLCPSQLite` provides implementations of the `ReadiumLCP` license and passphrase repositories using [SQLite.swift](https://github.com/stephencelis/SQLite.swift). ## Overview of the shared models (`ReadiumShared`) diff --git a/docs/Guides/Readium LCP.md b/docs/Guides/Readium LCP.md index 28a1ba02be..3d4086b24b 100644 --- a/docs/Guides/Readium LCP.md +++ b/docs/Guides/Readium LCP.md @@ -177,11 +177,10 @@ A file required by the LCP library needs to be downloaded from an insecure HTTP `ReadiumLCP` offers an `LCPService` object that exposes its API. Since the `ReadiumLCP` package is not linked with `R2LCPClient`, you need to create your own adapter when setting up the `LCPService`. -The `LCPService` expects repositories to store the opened licenses and passphrases. While you can implement your own persistence layer, the `ReadiumAdapterLCPSQLite` module provides default implementations based on an SQLite database. +The `LCPService` expects repositories to store the opened licenses and passphrases. `ReadiumLCP` provides built-in Keychain-based implementations that store data securely in the iOS/macOS Keychain. Unlike database-based storage, Keychain data persists across app reinstalls and can optionally be synchronized across the user's devices via iCloud Keychain. ```swift import R2LCPClient -import ReadiumAdapterLCPSQLite import ReadiumLCP let httpClient = DefaultHTTPClient() @@ -192,8 +191,8 @@ let assetRetriever = AssetRetriever( let lcpService = LCPService( client: LCPClientAdapter(), - licenseRepository: try LCPSQLiteLicenseRepository(), - passphraseRepository: try LCPSQLitePassphraseRepository(), + licenseRepository: LCPKeychainLicenseRepository(), + passphraseRepository: LCPKeychainPassphraseRepository(), assetRetriever: assetRetriever, httpClient: httpClient ) @@ -214,6 +213,10 @@ class LCPClientAdapter: ReadiumLCP.LCPClient { } ``` +To disable iCloud synchronization, pass `synchronizable: false` when creating the repositories. + +You may also implement your own persistence layer by conforming to `LCPLicenseRepository` and `LCPPassphraseRepository`. + ## Acquiring a publication from a License Document (LCPL) Users need to import a License Document into your application to download the protected publication (`.epub`, `.lcpdf`, or `.lcpa`). diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index f760219d96..74c2509c34 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -2,7 +2,55 @@ All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file. - +## Unreleased + +### Migrating LCP Repositories from SQLite to the Keychain + +The `ReadiumAdapterLCPSQLite` module is now deprecated. `ReadiumLCP` provides built-in Keychain-based repositories that are more secure, persist across app reinstalls, and optionally synchronize across devices via iCloud Keychain. + +#### Updating the `LCPService` initialization + +Replace the SQLite repositories with their Keychain equivalents: + +```diff +-import ReadiumAdapterLCPSQLite + import ReadiumLCP + + let lcpService = LCPService( + client: LCPClient(), +- licenseRepository: try! LCPSQLiteLicenseRepository(), +- passphraseRepository: try! LCPSQLitePassphraseRepository(), ++ licenseRepository: LCPKeychainLicenseRepository(), ++ passphraseRepository: LCPKeychainPassphraseRepository(), + assetRetriever: assetRetriever, + httpClient: httpClient + ) +``` + +Then remove `ReadiumAdapterLCPSQLite` from your project dependencies: + +* **Swift Package Manager:** Remove the `ReadiumAdapterLCPSQLite` product from your target dependencies. +* **Carthage:** Remove `ReadiumAdapterLCPSQLite.xcframework` and `SQLite.xcframework` from your project. +* **CocoaPods:** Remove `pod 'ReadiumAdapterLCPSQLite'` from your `Podfile` and run `pod install`. + +#### Migrating existing data + +If your app already stores LCP data in the SQLite database, you can migrate it to the Keychain using the built-in migration helpers. Run this migration once, for example during an app update: + +```swift +import ReadiumAdapterLCPSQLite +import ReadiumLCP + +let keychainLicenseRepository = LCPKeychainLicenseRepository() +let keychainPassphraseRepository = LCPKeychainPassphraseRepository() + +let sqliteLicenseRepository = try LCPSQLiteLicenseRepository() +let sqlitePassphraseRepository = try LCPSQLitePassphraseRepository() + +try await sqliteLicenseRepository.migrate(to: keychainLicenseRepository) +try await sqlitePassphraseRepository.migrate(to: keychainPassphraseRepository) +``` + ## 3.7.0 From 146d1b373b8471059be4e05b7022efb54e97ead1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 10 Feb 2026 18:10:21 +0100 Subject: [PATCH 32/55] Fix default FXL spread positions (#714) --- CHANGELOG.md | 7 + Sources/Navigator/EPUB/EPUBSpread.swift | 46 +- Sources/Navigator/Preferences/Types.swift | 10 - .../NavigatorTests/EPUB/EPUBSpreadTests.swift | 484 ++++++++++++++++++ 4 files changed, 529 insertions(+), 18 deletions(-) create mode 100644 Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f6575b191e..d4f774eee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ All notable changes to this project will be documented in this file. Take a look * Persist across app reinstalls. * Optionally synchronized across devices via iCloud Keychain. +### Fixed + +#### Navigator + +* The first resource of a fixed-layout EPUB is now displayed on its own by default, matching Apple Books behavior. +* Fixed the default spread position for single fixed-layout EPUB spreads that are not the first page. + ### Deprecated #### LCP diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 320af5c696..0c2988b277 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -110,6 +110,9 @@ enum EPUBSpread: EPUBSpreadProtocol { } /// Builds a list of two-page spreads for the given Publication. + /// + /// `offsetFirstPage` is the user preference used to control if the first + /// resource is displayed on its own. private static func makeTwoPagesSpreads( for publication: Publication, readingOrder: [Link], @@ -122,12 +125,22 @@ enum EPUBSpread: EPUBSpreadProtocol { while index < readingOrder.count { var first = readingOrder[index] - // 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 + // The first resource (often the cover) has special rules for its + // position in the spread. + if index == 0 { + 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 + // 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. + // + // We display it centered rather than on the left or right + // to ensure it fills the entire viewport in portrait mode. + first.properties.page = .center + } } let nextIndex = index + 1 @@ -163,7 +176,8 @@ enum EPUBSpread: EPUBSpreadProtocol { } /// Two resources are consecutive if their position hint (Properties.Page) - /// are paired according to the reading progression. + /// are paired according to the reading progression from the publication + /// (not user preferences). private static func areConsecutive( _ first: Link, _ second: Link, @@ -220,10 +234,26 @@ struct EPUBSingleSpread: EPUBSpreadProtocol, Loggable { [ resource.json( forBaseURL: baseURL, - page: resource.link.properties.page ?? readingProgression.startingPage + page: resource.link.properties.page ?? defaultPage(in: readingProgression) ), ] } + + /// Returns the default spread position (left or right) for the single + /// resource, in the given reading progression. + /// + /// The first page (typically a cover) defaults to the starting page (right + /// for LTR). Other unpaired pages default to the leading position they + /// would have had in a spread pair. + private func defaultPage(in readingProgression: ReadingProgression) -> Properties.Page { + let isFirstPage = (resource.index == 0) + return switch readingProgression { + case .ltr: + isFirstPage ? .right : .left + case .rtl: + isFirstPage ? .left : .right + } + } } /// A spread displaying two resources side by side (FXL only). diff --git a/Sources/Navigator/Preferences/Types.swift b/Sources/Navigator/Preferences/Types.swift index c27430d58f..bad2a7f2a8 100644 --- a/Sources/Navigator/Preferences/Types.swift +++ b/Sources/Navigator/Preferences/Types.swift @@ -37,16 +37,6 @@ public enum ReadingProgression: String, Codable, Hashable { default: return nil } } - - /// Returns the starting page for the reading progression. - var startingPage: Properties.Page { - switch self { - case .ltr: - return .right - case .rtl: - return .left - } - } } extension ReadiumShared.ReadingProgression { diff --git a/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift b/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift new file mode 100644 index 0000000000..d869943bec --- /dev/null +++ b/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift @@ -0,0 +1,484 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@testable import ReadiumNavigator +import ReadiumShared +import Testing + +@Suite enum EPUBSpreadTests { + @Suite("Single pages") struct SinglePages { + @Test("with an empty reading order") + func emptyReadingOrder() { + let pub = fxlPublication(readingOrder: []) + let spreads = makeSpreads(publication: pub, spread: false) + #expect(spreads.isEmpty) + } + + @Test("each link produces a single spread") + func multipleLinks() { + let pub = fxlPublication(readingOrder: [ + link("p1.html"), + link("p2.html"), + link("p3.html"), + ]) + let spreads = makeSpreads(publication: pub, spread: false) + + #expect(spreads.count == 3) + for (i, spread) in spreads.enumerated() { + guard case let .single(s) = spread else { + Issue.record("Expected .single at index \(i)") + continue + } + #expect(s.resource.index == i) + } + } + } + + @Suite("Dual pages") struct DualPages { + @Test("never combines reflowable pages") + func neverCombinesReflowable() { + let pub = reflowablePublication(readingOrder: [ + link("c1.html"), + link("c2.html"), + link("c3.html"), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 3) + for spread in spreads { + guard case .single = spread else { + Issue.record("Expected all .single for reflowable") + return + } + } + } + + @Suite("FXL") enum FXL { + @Suite("First page position") struct FirstPagePosition { + @Test("defaults to center when no page property") + func firstPageDefaultsToCenter() { + let pub = fxlPublication(readingOrder: [ + link("cover.html"), + 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(cover) = spreads[0] else { + Issue.record("Expected cover to be .single") + return + } + #expect(cover.resource.link.href == "cover.html") + guard case .double = spreads[1] else { + Issue.record("Expected p1+p2 to be .double") + return + } + } + + @Test("offsetFirstPage: true keeps first page single") + func offsetFirstPageTrue() { + let pub = fxlPublication(readingOrder: [ + link("cover.html"), + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true, offsetFirstPage: true) + + #expect(spreads.count == 2) + guard case .single = spreads[0] else { + Issue.record("Expected cover to be .single with offsetFirstPage=true") + return + } + } + + @Test("offsetFirstPage: false allows first page to combine") + func offsetFirstPageFalse() { + let pub = fxlPublication(readingOrder: [ + link("p1.html"), + link("p2.html"), + ]) + let spreads = makeSpreads(publication: pub, spread: true, offsetFirstPage: false) + + #expect(spreads.count == 1) + guard case .double = spreads[0] else { + Issue.record("Expected .double when offsetFirstPage=false") + return + } + } + + @Test("explicit .left on first page is preserved") + func firstPageExplicitLeftKeepsIt() { + let pub = fxlPublication(readingOrder: [ + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 1) + guard case .double = spreads[0] else { + Issue.record("Expected .double when first page has explicit .left") + return + } + } + } + + @Suite("Page pairing (LTR)") struct PairingLTR { + @Test("left + right pages are combined") + func leftRightCombined() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 2) + guard case let .double(d) = spreads[1] else { + Issue.record("Expected left+right to combine in LTR") + return + } + #expect(d.first.link.href == "p1.html") + #expect(d.second.link.href == "p2.html") + } + + @Test("right + left pages are not combined") + func rightLeftNotCombined() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .right), + link("p2.html", page: .left), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 3) + for spread in spreads { + guard case .single = spread else { + Issue.record("Expected all .single for wrong-order LTR pages") + return + } + } + } + + @Test("nil + nil defaults to left + right") + func nilNilDefaultsToLeftRight() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html"), + link("p2.html"), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 2) + guard case .double = spreads[1] else { + Issue.record("Expected nil+nil to combine in LTR") + return + } + } + + @Test("center + left pages are not combined") + func centerLeftNotCombined() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .center), + link("p2.html", page: .left), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 3) + } + + @Test("odd number of pages leaves last page single") + func oddNumberLastPageSingle() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .left), + link("p2.html", page: .right), + link("p3.html", page: .left), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 3) + guard case .single = spreads[0] else { + Issue.record("Expected cover to be single") + return + } + guard case .double = spreads[1] else { + Issue.record("Expected p1+p2 to be double") + return + } + guard case let .single(last) = spreads[2] else { + Issue.record("Expected last page to be single") + return + } + #expect(last.resource.link.href == "p3.html") + } + } + + @Suite("Page pairing (RTL)") struct PairingRTL { + @Test("right + left pages are combined") + func rightLeftCombined() { + let pub = fxlPublication(readingProgression: .rtl, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .right), + link("p2.html", page: .left), + ]) + let spreads = makeSpreads(publication: pub, readingProgression: .rtl, spread: true) + + #expect(spreads.count == 2) + guard case let .double(d) = spreads[1] else { + Issue.record("Expected right+left to combine in RTL") + return + } + #expect(d.first.link.href == "p1.html") + #expect(d.second.link.href == "p2.html") + } + + @Test("left + right pages are not combined") + func leftRightNotCombined() { + let pub = fxlPublication(readingProgression: .rtl, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, readingProgression: .rtl, spread: true) + + #expect(spreads.count == 3) + } + + @Test("nil + nil defaults to right + left") + func nilNilDefaultsToRightLeft() { + let pub = fxlPublication(readingProgression: .rtl, readingOrder: [ + link("cover.html", page: .center), + link("p1.html"), + link("p2.html"), + ]) + let spreads = makeSpreads(publication: pub, readingProgression: .rtl, spread: true) + + #expect(spreads.count == 2) + guard case .double = spreads[1] else { + Issue.record("Expected nil+nil to combine in RTL") + return + } + } + } + } + } + + @Suite enum Properties { + @Suite struct SingleSpread { + @Test func readingOrderIndices() { + let spread = EPUBSpread.single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 3, link: link("p.html")) + )) + #expect(spread.readingOrderIndices == 3 ... 3) + } + + @Test func first() { + let resource = EPUBSpreadResource(index: 5, link: link("p.html")) + let spread = EPUBSpread.single(EPUBSingleSpread(resource: resource)) + #expect(spread.first.index == 5) + #expect(spread.first.link.href == "p.html") + } + + @Test func containsIndex() { + let single = EPUBSpread.single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 2, link: link("p.html")) + )) + #expect(single.contains(index: 2)) + #expect(!single.contains(index: 0)) + #expect(!single.contains(index: 3)) + } + } + + @Suite struct DoubleSpread { + @Test func readingOrderIndices() { + let spread = EPUBSpread.double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 2, link: link("p1.html")), + second: EPUBSpreadResource(index: 3, link: link("p2.html")) + )) + #expect(spread.readingOrderIndices == 2 ... 3) + } + + @Test func first() { + let spread = EPUBSpread.double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 1, link: link("p1.html")), + second: EPUBSpreadResource(index: 2, link: link("p2.html")) + )) + #expect(spread.first.index == 1) + #expect(spread.first.link.href == "p1.html") + } + + @Test func containsIndex() { + let double = EPUBSpread.double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 4, link: link("p1.html")), + second: EPUBSpreadResource(index: 5, link: link("p2.html")) + )) + #expect(double.contains(index: 4)) + #expect(double.contains(index: 5)) + #expect(!double.contains(index: 3)) + #expect(!double.contains(index: 6)) + } + + @Test("LTR: left is first, right is second") + func ltrLeftIsFirstRightIsSecond() { + let first = EPUBSpreadResource(index: 0, link: link("p1.html")) + let second = EPUBSpreadResource(index: 1, link: link("p2.html")) + let spread = EPUBDoubleSpread(first: first, second: second) + + #expect(spread.left(for: .ltr).link.href == "p1.html") + #expect(spread.right(for: .ltr).link.href == "p2.html") + } + + @Test("RTL: left is second, right is first") + func rtlLeftIsSecondRightIsFirst() { + let first = EPUBSpreadResource(index: 0, link: link("p1.html")) + let second = EPUBSpreadResource(index: 1, link: link("p2.html")) + let spread = EPUBDoubleSpread(first: first, second: second) + + #expect(spread.left(for: .rtl).link.href == "p2.html") + #expect(spread.right(for: .rtl).link.href == "p1.html") + } + } + } + + @Suite struct PositionCount { + @Test("for a single spread") + func single() { + let readingOrder: ReadingOrder = [ + link("p0.html"), + link("p1.html"), + link("p2.html"), + ] + let positions: [[Locator]] = [ + [Locator(href: "p0.html", mediaType: .html)], + [ + Locator(href: "p1.html", mediaType: .html), + Locator(href: "p1.html", mediaType: .html), + Locator(href: "p1.html", mediaType: .html), + ], + [Locator(href: "p2.html", mediaType: .html)], + ] + + let spread = EPUBSpread.single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 1, link: link("p1.html")) + )) + #expect(spread.positionCount(in: readingOrder, positionsByReadingOrder: positions) == 3) + } + + @Test("for a double spread sums both resources") + func double() { + let readingOrder: ReadingOrder = [ + link("p0.html"), + link("p1.html"), + link("p2.html"), + ] + let positions: [[Locator]] = [ + [Locator(href: "p0.html", mediaType: .html)], + [ + Locator(href: "p1.html", mediaType: .html), + Locator(href: "p1.html", mediaType: .html), + ], + [Locator(href: "p2.html", mediaType: .html)], + ] + + let spread = EPUBSpread.double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 1, link: link("p1.html")), + second: EPUBSpreadResource(index: 2, link: link("p2.html")) + )) + #expect(spread.positionCount(in: readingOrder, positionsByReadingOrder: positions) == 3) + } + + @Test("returns 0 for out-of-bounds index") + func outOfBounds() { + let readingOrder: ReadingOrder = [link("p0.html")] + let positions: [[Locator]] = [[Locator(href: "p0.html", mediaType: .html)]] + + let spread = EPUBSpread.single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 5, link: link("missing.html")) + )) + #expect(spread.positionCount(in: readingOrder, positionsByReadingOrder: positions) == 0) + } + } + + @Suite struct FirstIndexWithReadingOrderIndex { + @Test("finds the spread index containing a reading order index") + func findsSpreadIndex() { + let spreads: [EPUBSpread] = [ + .single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 0, link: link("cover.html")) + )), + .double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 1, link: link("p1.html")), + second: EPUBSpreadResource(index: 2, link: link("p2.html")) + )), + .single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 3, link: link("p3.html")) + )), + ] + + #expect(spreads.firstIndexWithReadingOrderIndex(0) == 0) + #expect(spreads.firstIndexWithReadingOrderIndex(1) == 1) + #expect(spreads.firstIndexWithReadingOrderIndex(2) == 1) + #expect(spreads.firstIndexWithReadingOrderIndex(3) == 2) + #expect(spreads.firstIndexWithReadingOrderIndex(99) == nil) + } + } +} + +// MARK: - Helpers + +private func link(_ href: String, page: Properties.Page? = nil) -> Link { + var properties = Properties() + properties.page = page + return Link(href: href, properties: properties) +} + +private func fxlPublication( + readingProgression: ReadiumShared.ReadingProgression = .auto, + readingOrder: [Link] +) -> Publication { + Publication( + manifest: Manifest( + metadata: Metadata( + title: "FXL", + layout: .fixed, + readingProgression: readingProgression + ), + readingOrder: readingOrder + ) + ) +} + +private func reflowablePublication(readingOrder: [Link]) -> Publication { + Publication( + manifest: Manifest( + metadata: Metadata(title: "Reflowable"), + readingOrder: readingOrder + ) + ) +} + +private func makeSpreads( + publication: Publication, + readingProgression: ReadiumNavigator.ReadingProgression = .ltr, + spread: Bool, + offsetFirstPage: Bool? = nil +) -> [EPUBSpread] { + EPUBSpread.makeSpreads( + for: publication, + readingOrder: publication.readingOrder, + readingProgression: readingProgression, + spread: spread, + offsetFirstPage: offsetFirstPage + ) +} + +private extension Locator { + init(href: String, mediaType: MediaType) { + self.init(href: AnyURL(string: href)!, mediaType: mediaType) + } +} From 6567cdf7b2eefe5e0276ab85f1f666c6bb38cc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 11 Feb 2026 17:23:10 +0100 Subject: [PATCH 33/55] Fix consuming LCP `print` rights (#717) --- CHANGELOG.md | 4 ++++ Sources/LCP/License/License.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f774eee2..0f6efe0477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ All notable changes to this project will be documented in this file. Take a look * The first resource of a fixed-layout EPUB is now displayed on its own by default, matching Apple Books behavior. * Fixed the default spread position for single fixed-layout EPUB spreads that are not the first page. +#### LCP + +* Fixed the `print` method consuming copy rights instead of print rights. + ### Deprecated #### LCP diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index c7ed839b68..459a170347 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -153,7 +153,7 @@ extension License: LCPLicense { return } - rights.copy = max(0, printLeft - pageCount) + rights.print = max(0, printLeft - pageCount) } return allowed From 800ae5774a84b982c0ce49e52e06ede059dc9bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 12 Feb 2026 13:21:43 +0100 Subject: [PATCH 34/55] Fix documentation issues (#718) --- docs/Guides/Content.md | 2 +- docs/Guides/Getting Started.md | 2 +- docs/Guides/Navigator/EPUB Fonts.md | 2 +- docs/Guides/Navigator/Input.md | 8 +-- docs/Guides/Navigator/Navigator.md | 14 ++--- docs/Guides/Readium LCP.md | 95 +++++++++++++---------------- docs/Migration Guide.md | 10 +-- 7 files changed, 60 insertions(+), 73 deletions(-) diff --git a/docs/Guides/Content.md b/docs/Guides/Content.md index ad66cbbb6c..92bb1a2d98 100644 --- a/docs/Guides/Content.md +++ b/docs/Guides/Content.md @@ -37,7 +37,7 @@ This is an expensive operation, proceed with caution and cache the result if you The individual `Content` elements can be iterated through with a regular `for` loop by converting it to a sequence: ```swift -for (element in content.sequence()) { +for element in content.sequence() { // Process element } ``` diff --git a/docs/Guides/Getting Started.md b/docs/Guides/Getting Started.md index 30606e66bf..af84c53700 100644 --- a/docs/Guides/Getting Started.md +++ b/docs/Guides/Getting Started.md @@ -112,7 +112,7 @@ let assetRetriever = AssetRetriever( httpClient: httpClient ) let publicationOpener = PublicationOpener( - publicationParser: DefaultPublicationParser( + parser: DefaultPublicationParser( httpClient: httpClient, assetRetriever: assetRetriever, pdfFactory: DefaultPDFDocumentFactory() diff --git a/docs/Guides/Navigator/EPUB Fonts.md b/docs/Guides/Navigator/EPUB Fonts.md index fc49be60e2..02a240dba1 100644 --- a/docs/Guides/Navigator/EPUB Fonts.md +++ b/docs/Guides/Navigator/EPUB Fonts.md @@ -1,6 +1,6 @@ # Font families in the EPUB navigator -Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](Navigator%20Preferences.md). +Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](Preferences.md). > [!NOTE] > You cannot change the default font family of a fixed-layout EPUB (with zoomable pages), as it is similar to a PDF or a comic book. diff --git a/docs/Guides/Navigator/Input.md b/docs/Guides/Navigator/Input.md index e3d83895b7..e86fc2bc6f 100644 --- a/docs/Guides/Navigator/Input.md +++ b/docs/Guides/Navigator/Input.md @@ -12,12 +12,12 @@ Here's an example of a simple `InputObserving` implementation that logs all key navigator.addObserver(InputObserver()) @MainActor final class InputObserver: InputObserving { - func didReceive(_ event: PointerEvent) -> Bool { + func didReceive(_ event: PointerEvent) async -> Bool { print("Received pointer event: \(event)") return false } - - func didReceive(_ event: KeyEvent) -> Bool { + + func didReceive(_ event: KeyEvent) async -> Bool { print("Received key event: \(event)") return false } @@ -47,7 +47,7 @@ navigator.addObserver(.click(modifiers: [.shift]) { event in ## Observing keyboard events -Similarly, the `KeyboardObserver` implementation provides an easy method to intercept keyboard events. +Similarly, the `KeyObserver` implementation provides an easy method to intercept keyboard events. ```swift navigator.addObserver(.key { event in diff --git a/docs/Guides/Navigator/Navigator.md b/docs/Guides/Navigator/Navigator.md index 4a00a4a61b..7be49357bc 100644 --- a/docs/Guides/Navigator/Navigator.md +++ b/docs/Guides/Navigator/Navigator.md @@ -111,13 +111,13 @@ class MyNavigatorDelegate: NavigatorDelegate { override func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { if let position = locator.locations.position { - print("At position \(position) on \(publication.positions.count)") + print("At position \(position)") } if let progression = locator.locations.progression { - return "Progression in the current resource: \(progression)%" + print("Progression in the current resource: \(progression.formatted(.percent))") } if let totalProgression = locator.locations.totalProgression { - return "Total progression in the publication: \(progression)%" + print("Total progression in the publication: \(totalProgression.formatted(.percent))") } // Save the position in your bookshelf database @@ -151,8 +151,8 @@ To display a percentage-based progression slider, use the `locations.totalProgre Given a progression from 0 to 1, you can obtain a `Locator` object from the `Publication`. This can be used to navigate to a specific percentage within the publication. ```swift -if let locator = publication.locate(progression: 0.5) { - navigator.go(to: locator) +if let locator = await publication.locate(progression: 0.5) { + await navigator.go(to: locator) } ``` @@ -161,9 +161,7 @@ if let locator = publication.locate(progression: 0.5) { > [!NOTE] > Readium does not have the concept of pages, as they are not useful when dealing with reflowable publications across different screen sizes. Instead, we use [**positions**](https://readium.org/architecture/models/locators/positions/) which remain stable even when the user changes the font size or device. -Not all Navigators provide positions, but most `VisualNavigator` implementations do. Verify if `publication.positions` is not empty to determine if it is supported. - -To find the total positions in the publication, use `publication.positions.count`. You can get the current position with `navigator.currentLocation?.locations.position`. +Not all Navigators provide positions, but most `VisualNavigator` implementations do. To find the total positions in the publication, use `try await publication.positions().get().count`. You can get the current position with `navigator.currentLocation?.locations.position`. ## Navigating with edge taps and keyboard arrows diff --git a/docs/Guides/Readium LCP.md b/docs/Guides/Readium LCP.md index 3d4086b24b..5d488fa636 100644 --- a/docs/Guides/Readium LCP.md +++ b/docs/Guides/Readium LCP.md @@ -224,8 +224,8 @@ Users need to import a License Document into your application to download the pr The `LCPService` offers an API to retrieve the full publication from an LCPL on the filesystem. ```swift -let acquisition = lcpService.acquirePublication( - from: lcplURL, +let result = await lcpService.acquirePublication( + from: .file(lcplURL), onProgress: { progress in switch progress { case .indefinite: @@ -233,21 +233,16 @@ let acquisition = lcpService.acquirePublication( case .percent(let percent): // Display a progress bar with percent from 0 to 1. } - }, - completion: { result in - switch result { - case let .success(publication): - // Import the `publication.localURL` file as any publication. - case let .failure(error): - // Display the error message - case .cancelled: - // The acquisition was cancelled before completion. - } } ) -``` -If the user wants to cancel the download, call `cancel()` on the object returned by `LCPService.acquirePublication()`. +switch result { +case let .success(publication): + // Import the `publication.localURL` file as any publication. +case let .failure(error): + // Display the error message. +} +``` After the download is completed, import the `publication.localURL` file into the bookshelf like any other publication file. @@ -308,7 +303,7 @@ The `allowUserInteraction` and `sender` arguments are forwarded to the `LCPAuthe When importing the publication to the bookshelf, set `allowUserInteraction` to `false` as you don't need the passphrase for accessing the publication metadata and cover. If you intend to present the publication using a Navigator, set `allowUserInteraction` to `true` as decryption will be required. > [!TIP] -> To check if a publication is protected with LCP before opening it, you can use `LCPService.isLCPProtected()`. +> To check if an asset is protected with LCP before opening it, you can use `asset.format.conformsTo(.lcp)`. ### Using the opened `Publication` @@ -370,36 +365,30 @@ An LCP License Document contains metadata such as its expiration date, the remai Use the `LCPService` to retrieve the `LCPLicense` instance for a publication. ```swift -lcpService.retrieveLicense( - from: publicationURL, +let result = await lcpService.retrieveLicense( + from: asset, authentication: LCPDialogAuthentication(), allowUserInteraction: true, sender: hostViewController -) { result in - switch result { - case .success(let lcpLicense): - if let lcpLicense = lcpLicense { - if let user = lcpLicense.license.user.name { - print("The publication was acquired by \(user)") - } - if let endDate = lcpLicense.license.rights.end { - print("The loan expires on \(endDate)") - } - if let copyLeft = lcpLicense.charactersToCopyLeft { - print("You can copy up to \(copyLeft) characters remaining.") - } - } else { - // The file was not protected by LCP. - } - case .failure(let error): - // Display the error. - case .cancelled: - // The operation was cancelled. +) + +switch result { +case .success(let lcpLicense): + if let user = lcpLicense.license.user.name { + print("The publication was acquired by \(user)") } + if let endDate = lcpLicense.license.rights.end { + print("The loan expires on \(endDate)") + } + if let copyLeft = await lcpLicense.charactersToCopyLeft() { + print("You can copy up to \(copyLeft) characters remaining.") + } +case .failure(let error): + // Display the error. } ``` -If you have already opened a `Publication` with the `Streamer`, you can directly obtain the `LCPLicense` using `publication.lcpLicense`. +If you have already opened a `Publication` with the `PublicationOpener`, you can directly obtain the `LCPLicense` using `publication.lcpLicense`. ## Managing a loan @@ -410,12 +399,12 @@ Readium LCP allows borrowing publications for a specific period. Use the `LCPLic Some loans can be returned before the end date. You can confirm this by using `lcpLicense.canReturnPublication`. To return the publication, execute: ```swift -lcpLicense.returnPublication { error in - if let error = error { - // Present the error. - } else { - // The publication was returned. - } +let result = await lcpLicense.returnPublication() +switch result { +case .success: + // The publication was returned. +case .failure(let error): + // Present the error. } ``` @@ -431,17 +420,17 @@ Readium LCP supports [two types of renewal interactions](https://readium.org/lcp You need to support both interactions by implementing the `LCPRenewDelegate` protocol. A default implementation is available with `LCPDefaultRenewDelegate`. ```swift -lcpLicense.renewLoan( +let result = await lcpLicense.renewLoan( with: LCPDefaultRenewDelegate( presentingViewController: hostViewController ) -) { result in - switch result { - case .success, .cancelled: - // The publication was renewed. - case let .failure(error): - // Display the error. - } +) + +switch result { +case .success: + // The publication was renewed. +case let .failure(error): + // Display the error. } ``` @@ -453,7 +442,7 @@ For an example, [take a look at the Test App](https://github.com/readium/swift-t ## Using the SwiftUI LCP Authentication dialog -If your application is built using SwiftUI, you cannot use `LCPAuthenticationDialog` because it requires a UIKit view controller as its host. Instead, use an `LCPObservableAuthentication` combined with our SwiftUI `LCPDialog` presented as a sheet. +If your application is built using SwiftUI, you cannot use `LCPDialogAuthentication` because it requires a UIKit view controller as its host. Instead, use an `LCPObservableAuthentication` combined with our SwiftUI `LCPDialog` presented as a sheet. ```swift @main diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index 74c2509c34..70bfa6b8f6 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -435,16 +435,16 @@ let navigator = try EPUBNavigatorViewController( ### Upgrading to the new Preferences API -The 2.5.0 release introduces a brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](Guides/Navigator%20Preferences.md). +The 2.5.0 release introduces a brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](Guides/Navigator/Preferences.md). If you integrated the EPUB navigator from a previous version, follow these steps to migrate: -1. Get familiar with [the concepts of this new API](Guides/Navigator%20Preferences.md#overview). +1. Get familiar with [the concepts of this new API](Guides/Navigator/Preferences.md#overview). 2. Migrate the local HTTP server from your app, [as explained in the previous section](#migrating-the-http-server). -3. Adapt your user settings interface to the new API using preferences editors. The [Test App](https://github.com/readium/swift-toolkit/blob/2.5.0/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift) and the [user guide](Guides/Navigator%20Preferences.md#build-a-user-settings-interface) contain examples using SwiftUI. -4. [Handle the persistence of the user preferences](Guides/Navigator%20Preferences.md#save-and-restore-the-user-preferences). The settings are not stored in the User Defaults by the toolkit anymore. Instead, you are responsible for persisting and restoring the user preferences as you see fit (e.g. as a JSON file). +3. Adapt your user settings interface to the new API using preferences editors. The [Test App](https://github.com/readium/swift-toolkit/blob/2.5.0/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift) and the [user guide](Guides/Navigator/Preferences.md#build-a-user-settings-interface) contain examples using SwiftUI. +4. [Handle the persistence of the user preferences](Guides/Navigator/Preferences.md#save-and-restore-the-user-preferences). The settings are not stored in the User Defaults by the toolkit anymore. Instead, you are responsible for persisting and restoring the user preferences as you see fit (e.g. as a JSON file). * If you want to migrate the legacy EPUB settings, you can use the helper `EPUBPreferences.fromLegacyPreferences()` which will create a new `EPUBPreferences` object after translating the existing user settings. -5. Make sure you [restore the stored user preferences](Guides/Navigator%20Preferences.md#setting-the-initial-navigator-preferences-and-app-defaults) when initializing the EPUB navigator. +5. Make sure you [restore the stored user preferences](Guides/Navigator/Preferences.md#setting-the-initial-navigator-preferences-and-app-defaults) when initializing the EPUB navigator. Please refer to the following table for the correspondence between legacy settings (from `UserSettings`) and new ones (`EPUBPreferences`). From ab5ff6571c780a748b04b72e4ad160e24e941914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 13 Feb 2026 12:42:26 +0100 Subject: [PATCH 35/55] Deprecate the CBZ Navigator (#720) --- CHANGELOG.md | 17 ++++--- .../CBZ/CBZNavigatorViewController.swift | 4 ++ Sources/Shared/Toolkit/Observable.swift | 2 + TestApp/Sources/Reader/CBZ/CBZModule.swift | 43 ----------------- .../Reader/CBZ/CBZViewController.swift | 46 ------------------- TestApp/Sources/Reader/EPUB/EPUBModule.swift | 6 +-- TestApp/Sources/Reader/ReaderModule.swift | 1 - 7 files changed, 18 insertions(+), 101 deletions(-) delete mode 100644 TestApp/Sources/Reader/CBZ/CBZModule.swift delete mode 100644 TestApp/Sources/Reader/CBZ/CBZViewController.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6efe0477..6b2743d815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,22 +13,27 @@ All notable changes to this project will be documented in this file. Take a look * Persist across app reinstalls. * Optionally synchronized across devices via iCloud Keychain. -### Fixed +### Deprecated #### Navigator -* The first resource of a fixed-layout EPUB is now displayed on its own by default, matching Apple Books behavior. -* Fixed the default spread position for single fixed-layout EPUB spreads that are not the first page. +* `CBZNavigatorViewController` is now deprecated. + * Open CBZ publications with `EPUBNavigatorViewController` instead, which has more configuration options and preferences. #### LCP -* Fixed the `print` method consuming copy rights instead of print rights. +* `ReadiumAdapterLCPSQLite` is now deprecated in favor of the built-in Keychain repositories. See [the migration guide](docs/Migration%20Guide.md) for instructions. -### Deprecated +### Fixed + +#### Navigator + +* The first resource of a fixed-layout EPUB is now displayed on its own by default, matching Apple Books behavior. +* Fixed the default spread position for single fixed-layout EPUB spreads that are not the first page. #### LCP -* `ReadiumAdapterLCPSQLite` is now deprecated in favor of the built-in Keychain repositories. See [the migration guide](docs/Migration%20Guide.md) for instructions. +* Fixed the `print` method consuming copy rights instead of print rights. ## [3.7.0] diff --git a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift index 11590d06d4..c539f87fcf 100644 --- a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift +++ b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift @@ -8,9 +8,11 @@ import ReadiumInternal import ReadiumShared import UIKit +@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") public protocol CBZNavigatorDelegate: VisualNavigatorDelegate {} /// A view controller used to render a CBZ `Publication`. +@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") open class CBZNavigatorViewController: InputObservableViewController, VisualNavigator, Loggable @@ -254,6 +256,7 @@ open class CBZNavigatorViewController: } } +@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") extension CBZNavigatorViewController: UIPageViewControllerDataSource { public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { guard let imageVC = viewController as? ImageViewController else { @@ -284,6 +287,7 @@ extension CBZNavigatorViewController: UIPageViewControllerDataSource { } } +@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") extension CBZNavigatorViewController: UIPageViewControllerDelegate { public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { if completed, let position = currentLocation { diff --git a/Sources/Shared/Toolkit/Observable.swift b/Sources/Shared/Toolkit/Observable.swift index 322dae1890..e2a7e2d631 100644 --- a/Sources/Shared/Toolkit/Observable.swift +++ b/Sources/Shared/Toolkit/Observable.swift @@ -8,6 +8,7 @@ import Foundation /// Holds an observable value. /// You can either get the value directly with `value`, or subscribe to its updates with `observe`. +@available(*, unavailable, message: "This is not used anymore in the toolkit") public class Observable { fileprivate var _value: Value { didSet { @@ -46,6 +47,7 @@ public class Observable { } } +@available(*, unavailable, message: "This is not used anymore in the toolkit") public class MutableObservable: Observable { override public var value: Value { get { diff --git a/TestApp/Sources/Reader/CBZ/CBZModule.swift b/TestApp/Sources/Reader/CBZ/CBZModule.swift deleted file mode 100644 index e8c3a945ec..0000000000 --- a/TestApp/Sources/Reader/CBZ/CBZModule.swift +++ /dev/null @@ -1,43 +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 Foundation -import ReadiumShared -import UIKit - -final class CBZModule: ReaderFormatModule { - weak var delegate: ReaderFormatModuleDelegate? - - init(delegate: ReaderFormatModuleDelegate?) { - self.delegate = delegate - } - - func supports(_ publication: Publication) -> Bool { - publication.conforms(to: .divina) - } - - @MainActor - func makeReaderViewController( - for publication: Publication, - locator: Locator?, - bookId: Book.Id, - books: BookRepository, - bookmarks: BookmarkRepository, - highlights: HighlightRepository, - readium: Readium - ) async throws -> UIViewController { - let cbzVC = try CBZViewController( - publication: publication, - locator: locator, - bookId: bookId, - books: books, - bookmarks: bookmarks, - httpServer: readium.httpServer - ) - cbzVC.moduleDelegate = delegate - return cbzVC - } -} diff --git a/TestApp/Sources/Reader/CBZ/CBZViewController.swift b/TestApp/Sources/Reader/CBZ/CBZViewController.swift deleted file mode 100644 index 491d2f9099..0000000000 --- a/TestApp/Sources/Reader/CBZ/CBZViewController.swift +++ /dev/null @@ -1,46 +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 ReadiumNavigator -import ReadiumShared -import ReadiumStreamer -import UIKit - -class CBZViewController: VisualReaderViewController { - init( - publication: Publication, - locator: Locator?, - bookId: Book.Id, - books: BookRepository, - bookmarks: BookmarkRepository, - httpServer: HTTPServer - ) throws { - let navigator = try CBZNavigatorViewController( - publication: publication, - initialLocation: locator, - httpServer: httpServer - ) - - super.init( - navigator: navigator, - publication: publication, - bookId: bookId, - books: books, - bookmarks: bookmarks, - highlights: nil - ) - - navigator.delegate = self - } - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .black - } -} - -extension CBZViewController: CBZNavigatorDelegate {} diff --git a/TestApp/Sources/Reader/EPUB/EPUBModule.swift b/TestApp/Sources/Reader/EPUB/EPUBModule.swift index 538ffa6d6b..e0748192d4 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBModule.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBModule.swift @@ -17,7 +17,7 @@ final class EPUBModule: ReaderFormatModule { } func supports(_ publication: Publication) -> Bool { - publication.conforms(to: .epub) || publication.readingOrder.allAreHTML + publication.conforms(to: .epub) || publication.conforms(to: .divina) || publication.readingOrder.allAreHTML } @MainActor @@ -30,10 +30,6 @@ final class EPUBModule: ReaderFormatModule { highlights: HighlightRepository, readium: Readium ) async throws -> UIViewController { - guard publication.metadata.identifier != nil else { - throw ReaderError.epubNotValid - } - let preferencesStore = makePreferencesStore(books: books) let epubViewController = try await EPUBViewController( publication: publication, diff --git a/TestApp/Sources/Reader/ReaderModule.swift b/TestApp/Sources/Reader/ReaderModule.swift index ef8a0e11e2..9d6473a026 100644 --- a/TestApp/Sources/Reader/ReaderModule.swift +++ b/TestApp/Sources/Reader/ReaderModule.swift @@ -48,7 +48,6 @@ final class ReaderModule: ReaderModuleAPI { formatModules = [ AudiobookModule(delegate: self), - CBZModule(delegate: self), EPUBModule(delegate: self), PDFModule(delegate: self), ] From d1a9ada91650f0b336b0f3c6ea30dbb546781fb7 Mon Sep 17 00:00:00 2001 From: Leonard Beus Date: Mon, 16 Feb 2026 13:40:18 +0100 Subject: [PATCH 36/55] Fix `ResourceProperties.mediaType` encoding (#719) --- CHANGELOG.md | 2 ++ Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2743d815..5b00a5d9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ All notable changes to this project will be documented in this file. Take a look ### Fixed +* Fixed casting of `ResourceProperties`'s `mediaType` (contributed by [@lbeus](https://github.com/readium/swift-toolkit/pull/719)). + #### Navigator * The first resource of a fixed-layout EPUB is now displayed on its own by default, matching Apple Books behavior. diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift index 65585d1574..ff7134c809 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift @@ -55,7 +55,7 @@ public extension ResourceProperties { } set { if let mediaType = newValue { - properties[mediaTypeKey] = mediaType + properties[mediaTypeKey] = mediaType.string } else { properties.removeValue(forKey: mediaTypeKey) } From f00ba11c4c6284afea474707f8b57dc7589bf462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 16 Feb 2026 14:06:35 +0100 Subject: [PATCH 37/55] Fix LCP localization fallback and add Keychain `clear` API (#722) --- BuildTools/Package.resolved | 4 +- BuildTools/Package.swift | 2 +- Package.swift | 2 +- .../SQLiteLCPPassphraseRepository.swift | 12 +- Sources/Internal/Keychain.swift | 14 +- Sources/Internal/UTI.swift | 8 +- .../Authentications/LCPAuthenticating.swift | 4 - Sources/LCP/Authentications/LCPDialog.swift | 8 +- .../LCPContentProtection.swift | 1 - Sources/LCP/LCPError.swift | 34 +- Sources/LCP/LCPRenewDelegate.swift | 2 +- Sources/LCP/License/License.swift | 22 +- Sources/LCP/License/LicenseValidation.swift | 40 ++- .../License/Model/Components/LSD/Event.swift | 10 +- .../LCP/License/Model/LicenseDocument.swift | 16 +- .../LCP/License/Model/StatusDocument.swift | 12 +- .../LCPKeychainLicenseRepository.swift | 27 +- .../LCPKeychainPassphraseRepository.swift | 29 +- .../Resources/fr.lproj/Localizable.strings | 11 + .../Resources/it.lproj/Localizable.strings | 11 + Sources/LCP/Services/CRLService.swift | 2 +- Sources/LCP/Services/DeviceService.swift | 2 +- Sources/LCP/Services/LicensesService.swift | 4 +- Sources/LCP/Toolkit/DataCompression.swift | 76 ++-- .../Navigator/Audiobook/AudioNavigator.swift | 8 +- .../Audiobook/PublicationMediaLoader.swift | 2 +- .../Decorator/DiffableDecoration.swift | 4 +- .../Navigator/EPUB/CSS/CSSProperties.swift | 92 +++-- .../EPUB/CSS/HTMLFontFamilyDeclaration.swift | 9 +- .../EPUB/EPUBNavigatorViewController.swift | 16 +- .../EPUB/EPUBNavigatorViewModel.swift | 29 +- .../EPUB/EPUBReflowableSpreadView.swift | 8 +- Sources/Navigator/EPUB/EPUBSpreadView.swift | 12 +- .../EPUB/Preferences/EPUBSettings.swift | 4 +- .../Input/InputObservableViewController.swift | 5 +- Sources/Navigator/Input/Key/Key.swift | 2 +- Sources/Navigator/Navigator.swift | 4 +- Sources/Navigator/PDF/PDFDocumentHolder.swift | 2 +- .../PDF/PDFNavigatorViewController.swift | 10 +- .../Navigator/Preferences/Configurable.swift | 4 +- .../Preferences/MappedPreference.swift | 14 +- .../Navigator/Preferences/Preference.swift | 22 +- .../Preferences/PreferencesEditor.swift | 4 +- Sources/Navigator/Preferences/Types.swift | 2 +- Sources/Navigator/SelectableNavigator.swift | 9 +- .../TTS/PublicationSpeechSynthesizer.swift | 9 +- Sources/Navigator/TTS/TTSVoice.swift | 20 +- .../Navigator/Toolkit/Extensions/UIView.swift | 6 +- Sources/Navigator/Toolkit/HTMLInjection.swift | 4 +- .../Navigator/Toolkit/PaginationView.swift | 16 +- Sources/OPDS/OPDS1Parser.swift | 10 +- Sources/OPDS/OPDS2Parser.swift | 1 - Sources/Shared/Logger/Logger.swift | 6 +- Sources/Shared/OPDS/Feed.swift | 2 +- Sources/Shared/OPDS/OPDSAcquisition.swift | 4 +- Sources/Shared/OPDS/OPDSPrice.swift | 2 +- ...AccessibilityDisplayString+Generated.swift | 2 +- .../AccessibilityMetadataDisplayGuide.swift | 89 +++-- Sources/Shared/Publication/Contributor.swift | 4 +- .../Presentation/Metadata+Presentation.swift | 4 +- Sources/Shared/Publication/LinkRelation.swift | 10 +- .../Shared/Publication/LocalizedString.swift | 8 +- Sources/Shared/Publication/Locator.swift | 24 +- .../Media Overlays/MediaOverlayNode.swift | 4 +- .../Media Overlays/MediaOverlays.swift | 4 +- Sources/Shared/Publication/Metadata.swift | 10 +- Sources/Shared/Publication/Properties.swift | 6 +- Sources/Shared/Publication/Publication.swift | 33 +- .../Publication/PublicationCollection.swift | 2 +- .../ContentProtectionService.swift | 9 +- .../Content Protection/UserRights.swift | 36 +- .../Services/Content/Content.swift | 30 +- .../HTMLResourceContentIterator.swift | 2 +- .../Cover/GeneratedCoverService.swift | 8 +- .../Services/Locator/LocatorService.swift | 14 +- .../Services/Positions/PositionsService.swift | 6 +- .../Services/PublicationService.swift | 8 +- .../Services/Search/SearchService.swift | 4 +- Sources/Shared/Publication/Subject.swift | 7 +- .../Toolkit/Data/Container/Container.swift | 4 +- .../Container/SingleResourceContainer.swift | 9 +- .../Container/TransformingContainer.swift | 4 +- .../Data/Resource/BufferingResource.swift | 4 +- .../Data/Resource/CachingResource.swift | 4 +- .../Resource/ResourceContentExtractor.swift | 14 +- .../Data/Resource/TailCachingResource.swift | 6 +- .../Data/Resource/TransformingResource.swift | 4 +- Sources/Shared/Toolkit/DocumentTypes.swift | 10 +- .../Toolkit/File/DirectoryContainer.swift | 5 +- .../Shared/Toolkit/File/FileResource.swift | 4 +- Sources/Shared/Toolkit/Format/MediaType.swift | 4 +- .../Sniffers/AudiobookFormatSniffer.swift | 2 +- .../Format/Sniffers/ComicFormatSniffer.swift | 2 +- .../Format/Sniffers/EPUBFormatSniffer.swift | 2 +- .../Format/Sniffers/RPFFormatSniffer.swift | 2 +- .../Toolkit/HTTP/DefaultHTTPClient.swift | 8 +- .../Shared/Toolkit/HTTP/HTTPResource.swift | 4 +- Sources/Shared/Toolkit/Language.swift | 4 +- .../Toolkit/Logging/WarningLogger.swift | 4 +- .../Shared/Toolkit/Media/AudioSession.swift | 4 +- Sources/Shared/Toolkit/PDF/PDFKit.swift | 36 +- .../Toolkit/ReadiumLocalizedString.swift | 61 +++- .../URL/Absolute URL/AbsoluteURL.swift | 8 +- .../URL/Absolute URL/UnknownAbsoluteURL.swift | 2 +- Sources/Shared/Toolkit/URL/AnyURL.swift | 8 +- Sources/Shared/Toolkit/URL/RelativeURL.swift | 4 +- Sources/Shared/Toolkit/URL/URITemplate.swift | 4 +- Sources/Shared/Toolkit/URL/URLProtocol.swift | 12 +- Sources/Shared/Toolkit/Weak.swift | 2 +- .../ZIP/Minizip/MinizipContainer.swift | 15 +- .../ZIPFoundationContainer.swift | 9 +- .../Parser/EPUB/EPUBMetadataParser.swift | 38 +- Sources/Streamer/Parser/EPUB/OPFMeta.swift | 12 +- Sources/Streamer/Parser/EPUB/OPFParser.swift | 4 +- .../Parser/Image/ComicInfoParser.swift | 13 +- Sources/Streamer/Parser/PDF/PDFParser.swift | 8 +- .../Parser/Readium/ReadiumWebPubParser.swift | 4 +- .../Streamer/Toolkit/DataCompression.swift | 76 ++-- TestApp/Sources/App/Readium.swift | 1 + TestApp/Sources/AppDelegate.swift | 2 +- TestApp/Sources/Common/UserError.swift | 4 +- TestApp/Sources/Data/Book.swift | 4 +- TestApp/Sources/Data/Bookmark.swift | 2 +- TestApp/Sources/Data/Database.swift | 15 +- TestApp/Sources/Data/Highlight.swift | 2 +- .../Library/LibraryViewController.swift | 10 +- .../Sources/OPDS/OPDSFeeds/OPDSFeedView.swift | 5 - .../OPDS/OPDSFeeds/OPDSNavigationRow.swift | 1 - .../Sources/OPDS/OPDSPlaceholderView.swift | 2 +- .../LCPManagementTableViewController.swift | 4 +- .../Common/Outline/OutlineTableView.swift | 3 +- .../Common/Outline/OutlineViewModels.swift | 2 +- .../Common/Preferences/UserPreferences.swift | 26 +- .../Reader/Common/ReaderViewController.swift | 2 +- .../Common/Search/SearchViewModel.swift | 14 +- .../Sources/Reader/Common/TTS/TTSView.swift | 2 +- .../Reader/Common/TTS/TTSViewModel.swift | 4 +- .../Common/VisualReaderViewController.swift | 10 +- .../Reader/EPUB/EPUBViewController.swift | 2 +- TestApp/Sources/Reader/ReaderFactory.swift | 2 +- Tests/InternalTests/Extensions/URLTests.swift | 8 +- .../LCPDecryptorTests.swift | 4 +- .../LCPKeychainLicenseRepositoryTests.swift | 27 ++ ...LCPKeychainPassphraseRepositoryTests.swift | 33 ++ .../Audio/PublicationMediaLoaderTests.swift | 26 +- .../Toolkit/HTMLElementTests.swift | 30 +- .../NavigatorTestHost/ReaderView.swift | 4 +- .../ReaderViewController.swift | 4 - .../NavigatorUITests/MemoryLeakTests.swift | 8 +- .../NavigatorUITests/XCUIApplication.swift | 4 - Tests/OPDSTests/readium_opds1_1_test.swift | 28 +- Tests/OPDSTests/readium_opds2_0_test.swift | 3 +- .../Publication/HREFNormalizerTests.swift | 4 +- .../Publication/LinkArrayTests.swift | 54 +-- Tests/SharedTests/Publication/LinkTests.swift | 18 +- .../Publication/LocatorTests.swift | 40 +-- .../Publication/PublicationTests.swift | 42 +-- .../ContentProtectionServiceTests.swift | 14 +- .../HTMLResourceContentIteratorTests.swift | 6 +- .../Services/Cover/CoverServiceTests.swift | 8 +- .../Cover/GeneratedCoverServiceTests.swift | 2 +- .../Locator/DefaultLocatorServiceTests.swift | 6 +- .../PerResourcePositionsServiceTests.swift | 10 +- .../Positions/PositionsServiceTests.swift | 6 +- .../Toolkit/DocumentTypesTests.swift | 32 +- .../File/DirectoryContainerTests.swift | 24 +- .../Toolkit/Format/FormatSniffersTests.swift | 14 +- .../Toolkit/Format/MediaTypeTests.swift | 326 +++++++++--------- .../URL/Absolute URL/FileURLTests.swift | 162 ++++----- .../URL/Absolute URL/HTTPURLTests.swift | 148 ++++---- .../UnknownAbsoluteURLTests.swift | 140 ++++---- .../SharedTests/Toolkit/URL/AnyURLTests.swift | 178 +++++----- .../Toolkit/URL/RelativeURLTests.swift | 126 ++++--- .../Toolkit/URL/URLQueryTests.swift | 14 +- Tests/SharedTests/Toolkit/XML/XMLTests.swift | 39 ++- .../Toolkit/ZIP/MinizipContainerTests.swift | 32 +- .../ZIP/ZIPFoundationContainerTests.swift | 32 +- .../Services/AudioLocatorServiceTests.swift | 6 +- .../EPUB/EPUBEncryptionParserTests.swift | 16 +- .../Parser/EPUB/EPUBManifestParserTests.swift | 4 +- .../Parser/EPUB/EPUBMetadataParserTests.swift | 8 +- .../Parser/EPUB/OPFParserTests.swift | 4 +- .../EPUBDeobfuscatorTests.swift | 2 +- .../Services/EPUBPositionsServiceTests.swift | 4 +- .../Parser/Image/ComicInfoParserTests.swift | 116 +++---- .../Parser/Image/ImageParserTests.swift | 12 +- 186 files changed, 1934 insertions(+), 1452 deletions(-) diff --git a/BuildTools/Package.resolved b/BuildTools/Package.resolved index f85cfb22bb..79fa37f1a3 100644 --- a/BuildTools/Package.resolved +++ b/BuildTools/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/nicklockwood/SwiftFormat", "state": { "branch": null, - "revision": "7ff506897aa5bdaf94f077087a2025b9505da112", - "version": "0.51.9" + "revision": "22a472ced4c621a0e41b982a6f32dec868d09392", + "version": "0.59.1" } } ] diff --git a/BuildTools/Package.swift b/BuildTools/Package.swift index 35ed5b2c1e..66488a67b4 100644 --- a/BuildTools/Package.swift +++ b/BuildTools/Package.swift @@ -11,7 +11,7 @@ let package = Package( name: "BuildTools", platforms: [.macOS(.v10_11)], dependencies: [ - .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.51.6"), + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.59.1"), ], targets: [.target(name: "BuildTools", path: "")] ) diff --git a/Package.swift b/Package.swift index 9814d2c551..2d0a2d9dc8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,6 @@ // swift-tools-version:5.10 // -// Copyright 2025 Readium Foundation. All rights reserved. +// 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. // diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift index c192217aa3..7255273d4f 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift @@ -33,19 +33,17 @@ public class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable { public func passphrase(for licenseID: LicenseDocument.ID) async throws -> LCPPassphraseHash? { try logAndRethrow { try db.prepare(transactions.select(passphrase) - .filter(self.licenseId == licenseID) - ) - .compactMap { try $0.get(passphrase) } - .first + .filter(self.licenseId == licenseID)) + .compactMap { try $0.get(passphrase) } + .first } } public func passphrasesMatching(userID: User.ID?, provider: LicenseDocument.Provider) async throws -> [LCPPassphraseHash] { try logAndRethrow { try db.prepare(transactions.select(passphrase) - .filter(self.userId == userID && self.provider == provider) - ) - .compactMap { try $0.get(passphrase) } + .filter(self.userId == userID && self.provider == provider)) + .compactMap { try $0.get(passphrase) } } } diff --git a/Sources/Internal/Keychain.swift b/Sources/Internal/Keychain.swift index 6f1add32f9..cd6e33b34c 100644 --- a/Sources/Internal/Keychain.swift +++ b/Sources/Internal/Keychain.swift @@ -48,7 +48,7 @@ public final class Keychain: Sendable { /// - Parameters: /// - data: The data to save. /// - key: The account identifier. - public func save(data: Data, forKey key: String) throws (KeychainError) { + public func save(data: Data, forKey key: String) throws(KeychainError) { var query = baseQuery(forKey: key, forAdding: true) query[kSecValueData as String] = data @@ -63,7 +63,7 @@ public final class Keychain: Sendable { /// /// - Parameter key: The account identifier. /// - Returns: The data if found, or `nil` if no item exists with this key. - public func load(forKey key: String) throws (KeychainError) -> Data? { + public func load(forKey key: String) throws(KeychainError) -> Data? { var query = baseQuery(forKey: key, forAdding: false) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -91,7 +91,7 @@ public final class Keychain: Sendable { /// - Parameters: /// - data: The new data to save. /// - key: The account identifier. - public func update(data: Data, forKey key: String) throws (KeychainError) { + public func update(data: Data, forKey key: String) throws(KeychainError) { let query = baseQuery(forKey: key, forAdding: false) let attributesToUpdate: [String: Any] = [ kSecValueData as String: data, @@ -107,7 +107,7 @@ public final class Keychain: Sendable { /// Deletes an item from the Keychain for the specified key. /// /// - Parameter key: The account identifier. - public func delete(forKey key: String) throws (KeychainError) { + public func delete(forKey key: String) throws(KeychainError) { let query = baseQuery(forKey: key, forAdding: false) let status = SecItemDelete(query as CFDictionary) @@ -118,7 +118,7 @@ public final class Keychain: Sendable { } /// Deletes all items for this service from the Keychain. - public func deleteAll() throws (KeychainError) { + public func deleteAll() throws(KeychainError) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, @@ -134,7 +134,7 @@ public final class Keychain: Sendable { /// Returns all account identifiers (keys) stored for this service. /// /// - Returns: An array of account identifiers. - public func allKeys() throws (KeychainError) -> [String] { + public func allKeys() throws(KeychainError) -> [String] { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, @@ -165,7 +165,7 @@ public final class Keychain: Sendable { /// /// - Returns: A dictionary where keys are account identifiers and values are /// the stored data. - public func allItems() throws (KeychainError) -> [String: Data] { + public func allItems() throws(KeychainError) -> [String: Data] { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, diff --git a/Sources/Internal/UTI.swift b/Sources/Internal/UTI.swift index c6bc72be70..63d1e89262 100644 --- a/Sources/Internal/UTI.swift +++ b/Sources/Internal/UTI.swift @@ -41,9 +41,13 @@ public struct UTI { self.init(type: type) } - public var name: String? { type.localizedDescription } + public var name: String? { + type.localizedDescription + } - public var string: String { type.identifier } + public var string: String { + type.identifier + } /// Returns the preferred tag for this `UTI`, with the given type `tagClass`. public func preferredTag(withClass tagClass: TagClass) -> String? { diff --git a/Sources/LCP/Authentications/LCPAuthenticating.swift b/Sources/LCP/Authentications/LCPAuthenticating.swift index 831a709085..f4b6ec3564 100644 --- a/Sources/LCP/Authentications/LCPAuthenticating.swift +++ b/Sources/LCP/Authentications/LCPAuthenticating.swift @@ -68,8 +68,4 @@ public struct LCPAuthenticatedLicense { /// License Document being opened. public let document: LicenseDocument - - init(document: LicenseDocument) { - self.document = document - } } diff --git a/Sources/LCP/Authentications/LCPDialog.swift b/Sources/LCP/Authentications/LCPDialog.swift index 73a1a898c5..8cf54c9755 100644 --- a/Sources/LCP/Authentications/LCPDialog.swift +++ b/Sources/LCP/Authentications/LCPDialog.swift @@ -58,7 +58,9 @@ public struct LCPDialog: View { } } - public var id: LCPDialog { self } + public var id: LCPDialog { + self + } private let hint: String? private let errorMessage: ErrorMessage? @@ -137,7 +139,7 @@ public struct LCPDialog: View { .navigationViewStyle(.stack) } - @ViewBuilder private var header: some View { + private var header: some View { Section { HStack { Spacer() @@ -168,7 +170,7 @@ public struct LCPDialog: View { .font(.callout) } - @ViewBuilder private var input: some View { + private var input: some View { Section { VStack(alignment: .leading, spacing: 8) { TextField(text: $passphrase) { diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index b0da43d686..add52e9569 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -56,7 +56,6 @@ final class LCPContentProtection: ContentProtection, Loggable { return await asset.resource.readAsLCPL() .mapError { .reading($0) } .asyncFlatMap { licenseDocument in - await assetRetriever.retrieve(link: licenseDocument.publicationLink) .flatMap { publicationAsset in switch publicationAsset { diff --git a/Sources/LCP/LCPError.swift b/Sources/LCP/LCPError.swift index 5426d9727a..43dea18f66 100644 --- a/Sources/LCP/LCPError.swift +++ b/Sources/LCP/LCPError.swift @@ -81,50 +81,50 @@ public enum StatusError: Error { /// Errors while renewing a loan. public enum RenewError: Error { - // Your publication could not be renewed properly. + /// Your publication could not be renewed properly. case renewFailed - // Incorrect renewal period, your publication could not be renewed. + /// Incorrect renewal period, your publication could not be renewed. case invalidRenewalPeriod(maxRenewDate: Date?) - // An unexpected error has occurred on the licensing server. + /// An unexpected error has occurred on the licensing server. case unexpectedServerError(HTTPError) } /// Errors while returning a loan. public enum ReturnError: Error { - // Your publication could not be returned properly. + /// Your publication could not be returned properly. case returnFailed - // Your publication has already been returned before or is expired. + /// Your publication has already been returned before or is expired. case alreadyReturnedOrExpired - // An unexpected error has occurred on the licensing server. + /// An unexpected error has occurred on the licensing server. case unexpectedServerError(HTTPError) } /// Errors while parsing the License or Status JSON Documents. public enum ParsingError: Error { - // The JSON is malformed and can't be parsed. + /// The JSON is malformed and can't be parsed. case malformedJSON - // The JSON is not representing a valid License Document. + /// The JSON is not representing a valid License Document. case licenseDocument - // The JSON is not representing a valid Status Document. + /// The JSON is not representing a valid Status Document. case statusDocument - // Invalid Link. + /// Invalid Link. case link - // Invalid Encryption. + /// Invalid Encryption. case encryption - // Invalid License Document Signature. + /// Invalid License Document Signature. case signature - // Invalid URL for link with rel %@. + /// Invalid URL for link with rel %@. case url(rel: String) } /// Errors while reading or writing a LCP container (LCPL, EPUB, LCPDF, etc.) public enum ContainerError: Error { - // Can't access the container, it's format is wrong. + /// Can't access the container, it's format is wrong. case openFailed(Error?) - // The file at given relative path is not found in the Container. + /// The file at given relative path is not found in the Container. case fileNotFound(String) - // Can't read the file at given relative path in the Container. + /// Can't read the file at given relative path in the Container. case readFailed(path: String) - // Can't write the file at given relative path in the Container. + /// Can't write the file at given relative path in the Container. case writeFailed(path: String) } diff --git a/Sources/LCP/LCPRenewDelegate.swift b/Sources/LCP/LCPRenewDelegate.swift index 06b71d8b72..190f948705 100644 --- a/Sources/LCP/LCPRenewDelegate.swift +++ b/Sources/LCP/LCPRenewDelegate.swift @@ -54,7 +54,7 @@ public class LCPDefaultRenewDelegate: NSObject, LCPRenewDelegate { } } - private var webPageContinuation: CheckedContinuation? = nil + private var webPageContinuation: CheckedContinuation? } extension LCPDefaultRenewDelegate: UIAdaptivePresentationControllerDelegate { diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index 459a170347..e8fab8f935 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -9,7 +9,7 @@ import ReadiumShared import ReadiumZIPFoundation final class License: Loggable { - // Last Documents which passed the integrity checks. + /// Last Documents which passed the integrity checks. private var documents: ValidatedDocuments // Dependencies @@ -37,19 +37,19 @@ final class License: Loggable { /// Public API extension License: LCPLicense { - public var license: LicenseDocument { + var license: LicenseDocument { documents.license } - public var status: StatusDocument? { + var status: StatusDocument? { documents.status } - public var isRestricted: Bool { + var isRestricted: Bool { documents.context.getOrNil() == nil } - public var error: LCPError? { + var error: LCPError? { switch documents.context { case .success: return nil @@ -65,11 +65,11 @@ extension License: LCPLicense { } } - public var encryptionProfile: String? { + var encryptionProfile: String? { license.encryption.profile } - public func decipher(_ data: Data) throws -> Data? { + func decipher(_ data: Data) throws -> Data? { let context = try documents.context.get() return client.decrypt(data: data, using: context) } @@ -185,7 +185,7 @@ extension License: LCPLicense { } } - // Finds the renew link according to `prefersWebPage`. + /// Finds the renew link according to `prefersWebPage`. func findRenewLink() -> Link? { guard let status = documents.status else { return nil @@ -208,7 +208,7 @@ extension License: LCPLicense { return status.linkWithNoType(for: .renew) } - // Renew the loan by presenting a web page to the user. + /// Renew the loan by presenting a web page to the user. func renewWithWebPage(_ link: Link) async throws -> Data { guard let statusURL = try? license.url(for: .status, preferredType: .lcpStatusDocument), @@ -227,9 +227,9 @@ extension License: LCPLicense { .get() } - // Programmatically renew the loan with a PUT request. + /// Programmatically renew the loan with a PUT request. func renewProgrammatically(_ link: Link) async throws -> Data { - // Asks the delegate for a renew date if there's an `end` parameter. + /// Asks the delegate for a renew date if there's an `end` parameter. func preferredEndDate() async throws -> Date? { (link.templateParameters.contains("end")) ? try await delegate.preferredEndDate(maximum: maxRenewDate) diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index a9ce140e76..cc23150d00 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -25,7 +25,7 @@ private let supportedProfiles = [ typealias Context = Result -// Holds the License/Status Documents and the DRM context, once validated. +/// Holds the License/Status Documents and the DRM context, once validated. struct ValidatedDocuments { let license: LicenseDocument let context: Context @@ -54,12 +54,12 @@ final actor LicenseValidation: Loggable { fileprivate let httpClient: HTTPClient fileprivate let passphrases: PassphrasesService - // List of observers notified when the Documents are validated, or if an error occurred. + /// List of observers notified when the Documents are validated, or if an error occurred. fileprivate var observers: [(callback: Observer, policy: ObserverPolicy)] = [] fileprivate let onLicenseValidated: (LicenseDocument) async throws -> Void - // Current state in the validation steps. + /// Current state in the validation steps. private(set) var state: State = .start { didSet { log(.debug, "* \(state)") @@ -90,7 +90,7 @@ final actor LicenseValidation: Loggable { self.onLicenseValidated = onLicenseValidated } - // Raw Document's data to validate. + /// Raw Document's data to validate. enum Document { case license(Data) case status(Data) @@ -153,12 +153,14 @@ extension LicenseValidation { } else { self = .fetchStatus(license) } + case let (.validateLicense(_, _), .failed(error)): self = .failure(error) // 2. Fetch the status document case let (.fetchStatus(license), .retrievedStatusData(data)): self = .validateStatus(license, data) + case let (.fetchStatus(license), .failed(_)): // We ignore any error while fetching the Status Document, as it is optional self = .checkLicenseStatus(license, nil, statusDocumentTakesPrecedence: false) @@ -171,6 +173,7 @@ extension LicenseValidation { } else { self = .checkLicenseStatus(license, status, statusDocumentTakesPrecedence: false) } + case let (.validateStatus(license, _), .failed(_)): // We ignore any error while validating the Status Document, as it is optional self = .checkLicenseStatus(license, nil, statusDocumentTakesPrecedence: false) @@ -178,6 +181,7 @@ extension LicenseValidation { // 3. Get an updated license if needed case let (.fetchLicense(_, status), .retrievedLicenseData(data)): self = .validateLicense(data, status) + case let (.fetchLicense(license, status), .failed(_)): // We ignore any error while fetching the updated License Document // Note: since we failed to get the updated License, then the Status Document will take precedence over the License when checking the status. @@ -194,8 +198,10 @@ extension LicenseValidation { // 5. Get the passphrase associated with the license case let (.requestPassphrase(license, status), .retrievedPassphrase(passphrase)): self = .validateIntegrity(license, status, passphrase: passphrase) + case let (.requestPassphrase, .failed(error)): self = .failure(error) + case let (.requestPassphrase(license, status), .passphraseNotFound): self = .valid(ValidatedDocuments(license, .failure(.missingPassphrase), status)) @@ -207,6 +213,7 @@ extension LicenseValidation { } else { self = .valid(documents) } + case let (.validateIntegrity(_, _, _), .failed(error)): self = .failure(error) @@ -217,6 +224,7 @@ extension LicenseValidation { } else { self = .valid(documents) } + case let (.registerDevice(documents, _), .failed(_)): // We ignore any error while registrating the device self = .valid(documents) @@ -236,25 +244,25 @@ extension LicenseValidation { } fileprivate enum Event { - // Raised when reading the License from its container, or when updating it from an LCP server. + /// Raised when reading the License from its container, or when updating it from an LCP server. case retrievedLicenseData(Data) - // Raised when the License Document is parsed and its structure is validated. + /// Raised when the License Document is parsed and its structure is validated. case validatedLicense(LicenseDocument) - // Raised after fetching the Status Document, or receiving it as a response of an LSD interaction. + /// Raised after fetching the Status Document, or receiving it as a response of an LSD interaction. case retrievedStatusData(Data) - // Raised after parsing and validating a Status Document's data. + /// Raised after parsing and validating a Status Document's data. case validatedStatus(StatusDocument) - // Raised after the License's status was checked, with any occurred status error. + /// Raised after the License's status was checked, with any occurred status error. case checkedLicenseStatus(StatusError?) - // Raised when we retrieved the passphrase from the local database, or from prompting the user. + /// Raised when we retrieved the passphrase from the local database, or from prompting the user. case retrievedPassphrase(String) - // Raised after validating the integrity of the License using liblcp.a. + /// Raised after validating the integrity of the License using liblcp.a. case validatedIntegrity(LCPClientContext) - // Raised when the device is registered, with an optional updated Status Document. + /// Raised when the device is registered, with an optional updated Status Document. case registeredDevice(Data?) - // Raised when any error occurs during the validation workflow. + /// Raised when any error occurs during the validation workflow. case failed(Error) - // Raised when no passphrase could be found or given by the user. + /// Raised when no passphrase could be found or given by the user. case passphraseNotFound } @@ -420,9 +428,9 @@ extension LicenseValidation { typealias Observer = (Result) -> Void enum ObserverPolicy { - // The observer is automatically removed when called. + /// The observer is automatically removed when called. case once - // The observer is called everytime the validation is finished. + /// The observer is called everytime the validation is finished. case always } diff --git a/Sources/LCP/License/Model/Components/LSD/Event.swift b/Sources/LCP/License/Model/Components/LSD/Event.swift index 6ca69ffbec..8ae430f521 100644 --- a/Sources/LCP/License/Model/Components/LSD/Event.swift +++ b/Sources/LCP/License/Model/Components/LSD/Event.swift @@ -9,15 +9,15 @@ import Foundation /// Event related to the change in status of a License Document. public struct Event { public enum EventType: String { - // Signals a successful registration event by a device. + /// Signals a successful registration event by a device. case register - // Signals a successful renew event. + /// Signals a successful renew event. case renew - // Signals a successful return event. + /// Signals a successful return event. case `return` - // Signals a revocation event. + /// Signals a revocation event. case revoke - // Signals a cancellation event. + /// Signals a cancellation event. case cancel } diff --git a/Sources/LCP/License/Model/LicenseDocument.swift b/Sources/LCP/License/Model/LicenseDocument.swift index a9400b74a9..460a837276 100644 --- a/Sources/LCP/License/Model/LicenseDocument.swift +++ b/Sources/LCP/License/Model/LicenseDocument.swift @@ -13,17 +13,17 @@ public struct LicenseDocument { public typealias ID = String public typealias Provider = String - // The possible rel of Links. + /// The possible rel of Links. public enum Rel: String { - // Location where a Reading System can redirect a User looking for additional information about the User Passphrase. + /// Location where a Reading System can redirect a User looking for additional information about the User Passphrase. case hint - // Location where the Publication associated with the License Document can be downloaded + /// Location where the Publication associated with the License Document can be downloaded case publication - // As defined in the IANA registry of link relations: "Conveys an identifier for the link's context." + /// As defined in the IANA registry of link relations: "Conveys an identifier for the link's context." case `self` - // Support resources for the user (either a website, an email or a telephone number). + /// Support resources for the user (either a website, an email or a telephone number). case support - // Location to the Status Document for this license. + /// Location to the Status Document for this license. case status } @@ -35,7 +35,7 @@ public struct LicenseDocument { public let issued: Date /// Date when the license was last updated. public let updated: Date - // Encryption object. + /// Encryption object. public let encryption: Encryption /// Used to associate the License Document with resources that are not locally available. public let links: Links @@ -83,7 +83,7 @@ public struct LicenseDocument { jsonData = data self.jsonString = jsonString - /// Checks that `links` contains at least one link with `publication` relation. + // Checks that `links` contains at least one link with `publication` relation. guard link(for: .publication) != nil else { throw ParsingError.licenseDocument } diff --git a/Sources/LCP/License/Model/StatusDocument.swift b/Sources/LCP/License/Model/StatusDocument.swift index 2997420dc6..aca43cda32 100644 --- a/Sources/LCP/License/Model/StatusDocument.swift +++ b/Sources/LCP/License/Model/StatusDocument.swift @@ -11,17 +11,17 @@ import ReadiumShared /// https://github.com/readium/lcp-specs/blob/master/schema/status.schema.json public struct StatusDocument { public enum Status: String { - // The License Document is available, but the user hasn't accessed the License and/or Status Document yet. + /// The License Document is available, but the user hasn't accessed the License and/or Status Document yet. case ready - // The license is active, and a device has been successfully registered for this license. This is the default value if the License Document does not contain a registration link, or a registration mechanism through the license itself. + /// The license is active, and a device has been successfully registered for this license. This is the default value if the License Document does not contain a registration link, or a registration mechanism through the license itself. case active - // The license is no longer active, it has been invalidated by the Issuer. + /// The license is no longer active, it has been invalidated by the Issuer. case revoked - // The license is no longer active, it has been invalidated by the User. + /// The license is no longer active, it has been invalidated by the User. case returned - // The license is no longer active because it was cancelled prior to activation. + /// The license is no longer active because it was cancelled prior to activation. case cancelled - // The license is no longer active because it has expired. + /// The license is no longer active because it has expired. case expired } diff --git a/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift index b889fceeca..c42db9b1a4 100644 --- a/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift +++ b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift @@ -150,6 +150,15 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { try updateLicense(license, for: id) } + /// Removes all licenses from the repository. + public func clear() async throws { + do { + try keychain.deleteAll() + } catch { + throw LCPKeychainLicenseRepositoryError.keychain(error) + } + } + // MARK: - Migration Support /// Imports license rights from an external source without requiring the @@ -191,7 +200,7 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { // MARK: - Keychain Access - private func requireLicense(for licenseID: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) -> License { + private func requireLicense(for licenseID: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) -> License { guard let license = try getLicense(for: licenseID) else { throw .licenseNotFound(id: licenseID) } @@ -199,7 +208,7 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { } /// Gets a license from the Keychain for the given license ID. - private func getLicense(for licenseID: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) -> License? { + private func getLicense(for licenseID: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) -> License? { guard let data = try getFromKeychain(id: licenseID) else { return nil } @@ -208,12 +217,12 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { } /// Adds a new license to the Keychain. - private func addLicense(_ license: License, for id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) { + private func addLicense(_ license: License, for id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) { try addToKeychain(data: encode(license), for: id) } /// Updates an existing license in the Keychain. - private func updateLicense(_ license: License, for id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) { + private func updateLicense(_ license: License, for id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) { var license = license license.updated = Date() let data = try encode(license) @@ -222,7 +231,7 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { // MARK: - Low-Level Helpers - private func getFromKeychain(id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) -> Data? { + private func getFromKeychain(id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) -> Data? { do { return try keychain.load(forKey: id) } catch { @@ -230,7 +239,7 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { } } - private func addToKeychain(data: Data, for id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) { + private func addToKeychain(data: Data, for id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) { do { try keychain.save(data: data, forKey: id) } catch { @@ -238,7 +247,7 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { } } - private func updateKeychain(data: Data, for id: LicenseDocument.ID) throws (LCPKeychainLicenseRepositoryError) { + private func updateKeychain(data: Data, for id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) { do { try keychain.update(data: data, forKey: id) } catch { @@ -246,7 +255,7 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { } } - private func decode(_ data: Data) throws (LCPKeychainLicenseRepositoryError) -> License { + private func decode(_ data: Data) throws(LCPKeychainLicenseRepositoryError) -> License { do { return try decoder.decode(License.self, from: data) } catch { @@ -254,7 +263,7 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { } } - private func encode(_ license: License) throws (LCPKeychainLicenseRepositoryError) -> Data { + private func encode(_ license: License) throws(LCPKeychainLicenseRepositoryError) -> Data { do { return try encoder.encode(license) } catch { diff --git a/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift b/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift index 5efa384e1b..13ff5b1f22 100644 --- a/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift +++ b/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift @@ -112,9 +112,18 @@ public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable } } + /// Removes all passphrases from the repository. + public func clear() async throws { + do { + try keychain.deleteAll() + } catch { + throw LCPKeychainPassphraseRepositoryError.keychain(error) + } + } + // MARK: - Keychain Access - private func getAllPassphrases() async throws (LCPKeychainPassphraseRepositoryError) -> [Passphrase] { + private func getAllPassphrases() async throws(LCPKeychainPassphraseRepositoryError) -> [Passphrase] { try getAllFromKeychain() .compactMap { _, data in guard let passphrase = try? decoder.decode(Passphrase.self, from: data) else { @@ -125,7 +134,7 @@ public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable } /// Gets a passphrase from the Keychain for the given license ID. - private func getPassphrase(for licenseID: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) -> Passphrase? { + private func getPassphrase(for licenseID: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) -> Passphrase? { guard let data = try getFromKeychain(id: licenseID) else { return nil } @@ -134,12 +143,12 @@ public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable } /// Adds a new passphrase to the Keychain. - private func addPassphrase(_ passphrase: Passphrase, for id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) { + private func addPassphrase(_ passphrase: Passphrase, for id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) { try addToKeychain(data: encode(passphrase), for: id) } /// Updates an existing passphrase in the Keychain. - private func updatePassphrase(_ passphrase: Passphrase, for id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) { + private func updatePassphrase(_ passphrase: Passphrase, for id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) { var passphrase = passphrase passphrase.updated = Date() let data = try encode(passphrase) @@ -148,7 +157,7 @@ public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable // MARK: - Low-Level Helpers - private func getFromKeychain(id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) -> Data? { + private func getFromKeychain(id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) -> Data? { do { return try keychain.load(forKey: id) } catch { @@ -156,7 +165,7 @@ public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable } } - private func getAllFromKeychain() throws (LCPKeychainPassphraseRepositoryError) -> [String: Data] { + private func getAllFromKeychain() throws(LCPKeychainPassphraseRepositoryError) -> [String: Data] { do { return try keychain.allItems() } catch { @@ -164,7 +173,7 @@ public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable } } - private func addToKeychain(data: Data, for id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) { + private func addToKeychain(data: Data, for id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) { do { try keychain.save(data: data, forKey: id) } catch { @@ -172,7 +181,7 @@ public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable } } - private func updateKeychain(data: Data, for id: LicenseDocument.ID) throws (LCPKeychainPassphraseRepositoryError) { + private func updateKeychain(data: Data, for id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) { do { try keychain.update(data: data, forKey: id) } catch { @@ -180,7 +189,7 @@ public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable } } - private func decode(_ data: Data) throws (LCPKeychainPassphraseRepositoryError) -> Passphrase { + private func decode(_ data: Data) throws(LCPKeychainPassphraseRepositoryError) -> Passphrase { do { return try decoder.decode(Passphrase.self, from: data) } catch { @@ -188,7 +197,7 @@ public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable } } - private func encode(_ passphrase: Passphrase) throws (LCPKeychainPassphraseRepositoryError) -> Data { + private func encode(_ passphrase: Passphrase) throws(LCPKeychainPassphraseRepositoryError) -> Data { do { return try encoder.encode(passphrase) } catch { diff --git a/Sources/LCP/Resources/fr.lproj/Localizable.strings b/Sources/LCP/Resources/fr.lproj/Localizable.strings index 2538448017..f231575c7b 100644 --- a/Sources/LCP/Resources/fr.lproj/Localizable.strings +++ b/Sources/LCP/Resources/fr.lproj/Localizable.strings @@ -1,2 +1,13 @@ // DO NOT EDIT. File generated automatically from the fr JSON strings of https://github.com/edrlab/thorium-locales/. +"readium.lcp.dialog.actions.cancel" = "Annuler"; +"readium.lcp.dialog.actions.continue" = "Continuer"; +"readium.lcp.dialog.actions.recoverPassphrase" = "Mot de passe oublié ?"; +"readium.lcp.dialog.errors.incorrectPassphrase" = "Mot de passe incorrect."; +"readium.lcp.dialog.info.body" = "Cette publication est protégée par LCP (Licensed Content Protection), une technologie DRM qui empêche la copie non autorisée tout en simplifiant votre expérience de lecture. LCP est un standard ouvert qui équilibre la facilité d'utilisation et les besoins des éditeurs."; +"readium.lcp.dialog.info.more" = "En savoir plus…"; +"readium.lcp.dialog.info.title" = "Qu'est-ce que LCP ?"; +"readium.lcp.dialog.message" = "Cette publication est protégée par LCP. Veuillez saisir le mot de passe fourni par votre bibliothèque ou votre libraire pour l'ouvrir."; +"readium.lcp.dialog.passphrase.hint" = "**Indice :** %1$@"; +"readium.lcp.dialog.passphrase.placeholder" = "Mot de passe"; +"readium.lcp.dialog.title" = "Saisir le mot de passe"; diff --git a/Sources/LCP/Resources/it.lproj/Localizable.strings b/Sources/LCP/Resources/it.lproj/Localizable.strings index 3982505447..459bf0b777 100644 --- a/Sources/LCP/Resources/it.lproj/Localizable.strings +++ b/Sources/LCP/Resources/it.lproj/Localizable.strings @@ -1,2 +1,13 @@ // DO NOT EDIT. File generated automatically from the it JSON strings of https://github.com/edrlab/thorium-locales/. +"readium.lcp.dialog.actions.cancel" = "Annulla"; +"readium.lcp.dialog.actions.continue" = "Continua"; +"readium.lcp.dialog.actions.recoverPassphrase" = "Hai dimenticato la password?"; +"readium.lcp.dialog.errors.incorrectPassphrase" = "Password errata."; +"readium.lcp.dialog.info.body" = "Questa pubblicazione è protetta con LCP (Licensed Content Protection), una tecnologia DRM che impedisce la riproduzione non autorizzata mantenendo semplice la tua esperienza di lettura. LCP è uno standard aperto che coniuga la facilità d'uso con le esigenze degli editori."; +"readium.lcp.dialog.info.more" = "Per saperne di più…"; +"readium.lcp.dialog.info.title" = "Che cos'è LCP?"; +"readium.lcp.dialog.message" = "Questa pubblicazione richiede una password LCP per essere aperta, fornita dalla tua biblioteca o dalla tua libreria online. Inseriscila e potrai iniziare a leggere su questa periferica."; +"readium.lcp.dialog.passphrase.hint" = "**Indizio:** %1$@"; +"readium.lcp.dialog.passphrase.placeholder" = "Password"; +"readium.lcp.dialog.title" = "Inserisci la password"; diff --git a/Sources/LCP/Services/CRLService.swift b/Sources/LCP/Services/CRLService.swift index 9f31844414..043a9413ed 100644 --- a/Sources/LCP/Services/CRLService.swift +++ b/Sources/LCP/Services/CRLService.swift @@ -9,7 +9,7 @@ import ReadiumShared /// Certificate Revocation List final class CRLService { - // Number of days before the CRL cache expires. + /// Number of days before the CRL cache expires. private static let expiration = 7 private static let crlKey = "org.readium.r2-lcp-swift.CRL" diff --git a/Sources/LCP/Services/DeviceService.swift b/Sources/LCP/Services/DeviceService.swift index ee5104ad44..f9d10e207a 100644 --- a/Sources/LCP/Services/DeviceService.swift +++ b/Sources/LCP/Services/DeviceService.swift @@ -38,7 +38,7 @@ final class DeviceService { self.httpClient = httpClient } - // Device ID and name as query parameters for HTTP requests. + /// Device ID and name as query parameters for HTTP requests. var asQueryParameters: [String: String] { [ "id": id, diff --git a/Sources/LCP/Services/LicensesService.swift b/Sources/LCP/Services/LicensesService.swift index f1c14d4d0b..303447acc0 100644 --- a/Sources/LCP/Services/LicensesService.swift +++ b/Sources/LCP/Services/LicensesService.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared final class LicensesService: Loggable { - // Mapping between an unprotected format to the matching LCP protected format. + /// Mapping between an unprotected format to the matching LCP protected format. private let mediaTypesMapping: [MediaType: MediaType] = [ .readiumAudiobook: .lcpProtectedAudiobook, .pdf: .lcpProtectedPDF, @@ -138,7 +138,7 @@ final class LicensesService: Loggable { _ license: LicenseDocument, in url: FileURL ) async throws { - let _ = try await injectLicenseAndGetFormat(license, in: url, mediaTypeHint: nil) + _ = try await injectLicenseAndGetFormat(license, in: url, mediaTypeHint: nil) } private func injectLicenseAndGetFormat( diff --git a/Sources/LCP/Toolkit/DataCompression.swift b/Sources/LCP/Toolkit/DataCompression.swift index 7808a6fa02..e6b93a33ab 100644 --- a/Sources/LCP/Toolkit/DataCompression.swift +++ b/Sources/LCP/Toolkit/DataCompression.swift @@ -1,29 +1,35 @@ -/// -/// DataCompression -/// -/// A libcompression wrapper as an extension for the `Data` type -/// (GZIP, ZLIB, LZFSE, LZMA, LZ4, deflate, RFC-1950, RFC-1951, RFC-1952) -/// -/// Created by Markus Wanke, 2016/12/05 -/// - -/// -/// Apache License, Version 2.0 -/// -/// Copyright 2016, Markus Wanke -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// +// +// 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. +// + +// +// DataCompression +// +// A libcompression wrapper as an extension for the `Data` type +// (GZIP, ZLIB, LZFSE, LZMA, LZ4, deflate, RFC-1950, RFC-1951, RFC-1952) +// +// Created by Markus Wanke, 2016/12/05 +// + +// +// Apache License, Version 2.0 +// +// Copyright 2016, Markus Wanke +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// import Compression import Foundation @@ -210,11 +216,15 @@ public extension Data { } } if has_fname { - while pos < limit, ptr[pos] != 0x0 { pos += 1 } + while pos < limit, ptr[pos] != 0x0 { + pos += 1 + } pos += 1 // skip null byte as well } if has_cmmnt { - while pos < limit, ptr[pos] != 0x0 { pos += 1 } + while pos < limit, ptr[pos] != 0x0 { + pos += 1 + } pos += 1 // skip null byte as well } if has_crc16 { @@ -255,7 +265,7 @@ public struct Crc32: CustomStringConvertible { public init() {} - // C convention function pointer type matching the signature of `libz::crc32` + /// C convention function pointer type matching the signature of `libz::crc32` private typealias ZLibCrc32FuncPtr = @convention(c) ( _ cks: UInt32, _ buf: UnsafePointer, @@ -344,7 +354,7 @@ public struct Adler32: CustomStringConvertible { public init() {} - // C convention function pointer type matching the signature of `libz::adler32` + /// C convention function pointer type matching the signature of `libz::adler32` private typealias ZLibAdler32FuncPtr = @convention(c) ( _ cks: UInt32, _ buf: UnsafePointer, @@ -398,8 +408,7 @@ public struct Adler32: CustomStringConvertible { } private extension Data { - func withUnsafeBytes(_ body: (UnsafePointer) throws -> ResultType) rethrows -> ResultType - { + func withUnsafeBytes(_ body: (UnsafePointer) throws -> ResultType) rethrows -> ResultType { try withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> ResultType in try body(rawBufferPointer.bindMemory(to: ContentType.self).baseAddress!) } @@ -419,8 +428,7 @@ private extension Data.CompressionAlgorithm { private typealias Config = (operation: compression_stream_operation, algorithm: compression_algorithm) -private func perform(_ config: Config, source: UnsafePointer, sourceSize: Int, preload: Data = Data()) -> Data? -{ +private func perform(_ config: Config, source: UnsafePointer, sourceSize: Int, preload: Data = Data()) -> Data? { guard config.operation == COMPRESSION_STREAM_ENCODE || sourceSize > 0 else { return nil } let streamBase = UnsafeMutablePointer.allocate(capacity: 1) diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 3e1e87fc0d..a7753ec62a 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -66,7 +66,9 @@ public struct MediaPlaybackInfo { public extension AudioNavigatorDelegate { func navigator(_ navigator: AudioNavigator, playbackDidChange info: MediaPlaybackInfo) {} - func navigator(_ navigator: AudioNavigator, shouldPlayNextResource info: MediaPlaybackInfo) -> Bool { true } + func navigator(_ navigator: AudioNavigator, shouldPlayNextResource info: MediaPlaybackInfo) -> Bool { + true + } func navigator(_ navigator: AudioNavigator, loadedTimeRangesDidChange ranges: [Range]) {} } @@ -112,7 +114,9 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo private let initialLocation: Locator? private let config: Configuration - public var audioConfiguration: AudioSession.Configuration { config.audioSession } + public var audioConfiguration: AudioSession.Configuration { + config.audioSession + } public init( publication: Publication, diff --git a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift index e49bd62977..0c918bb768 100644 --- a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift +++ b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift @@ -13,7 +13,7 @@ import ReadiumShared /// /// Useful for local resources or when you need to customize the way HTTP requests are sent. final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Loggable, @unchecked Sendable { - public enum AssetError: Error { + enum AssetError: Error { /// Can't produce an URL to create an AVAsset for the given HREF. case invalidHREF(String) } diff --git a/Sources/Navigator/Decorator/DiffableDecoration.swift b/Sources/Navigator/Decorator/DiffableDecoration.swift index 5c7a4f02c3..75ed4ee8f6 100644 --- a/Sources/Navigator/Decorator/DiffableDecoration.swift +++ b/Sources/Navigator/Decorator/DiffableDecoration.swift @@ -10,7 +10,9 @@ import ReadiumShared struct DiffableDecoration: Hashable, Differentiable { let decoration: Decoration - var differenceIdentifier: Decoration.Id { decoration.id } + var differenceIdentifier: Decoration.Id { + decoration.id + } } enum DecorationChange { diff --git a/Sources/Navigator/EPUB/CSS/CSSProperties.swift b/Sources/Navigator/EPUB/CSS/CSSProperties.swift index 84112e06cd..ec654d1dcd 100644 --- a/Sources/Navigator/EPUB/CSS/CSSProperties.swift +++ b/Sources/Navigator/EPUB/CSS/CSSProperties.swift @@ -162,7 +162,7 @@ public struct CSSUserProperties: CSSProperties { /// Requires: fontOverride public var a11yNormalize: Bool? - // Additional overrides for extensions and adjustments. + /// Additional overrides for extensions and adjustments. public var overrides: [String: String?] public init( @@ -394,7 +394,7 @@ public struct CSSRSProperties: CSSProperties { /// The value can be another variable e.g. var(-RS__monospaceTf). public var codeFontFamily: [String]? - // Additional overrides for extensions and adjustments. + /// Additional overrides for extensions and adjustments. public var overrides: [String: String?] public init( @@ -534,7 +534,9 @@ public enum CSSView: String, CSSConvertible { case paged = "readium-paged-on" case scroll = "readium-scroll-on" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } public enum CSSColCount: String, CSSConvertible { @@ -542,14 +544,18 @@ public enum CSSColCount: String, CSSConvertible { case one = "1" case two = "2" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } public enum CSSAppearance: String, CSSConvertible { case night = "readium-night-on" case sepia = "readium-sepia-on" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } public protocol CSSColor: CSSConvertible {} @@ -580,7 +586,9 @@ public struct CSSHexColor: CSSColor { self.color = color } - public func css() -> String? { color } + public func css() -> String? { + color + } } public struct CSSIntColor: CSSColor { @@ -607,7 +615,9 @@ public struct CSSCmLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "cm") } + public func css() -> String? { + value.css(unit: "cm") + } } /// Millimeters @@ -618,7 +628,9 @@ public struct CSSMmLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "mm") } + public func css() -> String? { + value.css(unit: "mm") + } } /// Inches @@ -629,7 +641,9 @@ public struct CSSInLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "in") } + public func css() -> String? { + value.css(unit: "in") + } } /// Pixels @@ -640,7 +654,9 @@ public struct CSSPxLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "px") } + public func css() -> String? { + value.css(unit: "px") + } } /// Points @@ -651,7 +667,9 @@ public struct CSSPtLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "pt") } + public func css() -> String? { + value.css(unit: "pt") + } } /// Picas @@ -662,7 +680,9 @@ public struct CSSPcLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "pc") } + public func css() -> String? { + value.css(unit: "pc") + } } public protocol CSSRelativeLength: CSSLength {} @@ -675,7 +695,9 @@ public struct CSSEmLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "em") } + public func css() -> String? { + value.css(unit: "em") + } } /// Relative to the width of the "0" (zero). @@ -686,7 +708,9 @@ public struct CSSChLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "ch") } + public func css() -> String? { + value.css(unit: "ch") + } } /// Relative to font-size of the root element. @@ -697,7 +721,9 @@ public struct CSSRemLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "rem") } + public func css() -> String? { + value.css(unit: "rem") + } } /// Relative to 1% of the width of the viewport. @@ -708,7 +734,9 @@ public struct CSSVwLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "vw") } + public func css() -> String? { + value.css(unit: "vw") + } } /// Relative to 1% of the height of the viewport. @@ -719,7 +747,9 @@ public struct CSSVhLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "vh") } + public func css() -> String? { + value.css(unit: "vh") + } } /// Relative to 1% of viewport's smaller dimension. @@ -730,7 +760,9 @@ public struct CSSVMinLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "vmin") } + public func css() -> String? { + value.css(unit: "vmin") + } } /// Relative to 1% of viewport's larger dimension. @@ -741,7 +773,9 @@ public struct CSSVMaxLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "vmax") } + public func css() -> String? { + value.css(unit: "vmax") + } } /// Relative to the parent element. @@ -752,7 +786,9 @@ public struct CSSPercentLength: CSSRelativeLength { self.value = value } - public func css() -> String? { (value * 100).css(unit: "%") } + public func css() -> String? { + (value * 100).css(unit: "%") + } } public enum CSSTextAlign: String, CSSConvertible { @@ -761,7 +797,9 @@ public enum CSSTextAlign: String, CSSConvertible { case right case justify - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } /// Line height supports unitless numbers. @@ -783,21 +821,27 @@ public enum CSSHyphens: String, CSSConvertible { case none case auto - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } public enum CSSLigatures: String, CSSConvertible { case none case common = "common-ligatures" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } public enum CSSBoxSizing: String, CSSConvertible { case contentBox = "content-box" case borderBox = "border-box" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } private extension Double { diff --git a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift index a5a1527e7d..493183e025 100644 --- a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift +++ b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift @@ -30,8 +30,13 @@ public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration { private let _alternates: () -> [FontFamily] private let _inject: (String, (FileURL) throws -> HTTPURL) throws -> String - public var fontFamily: FontFamily { _fontFamily() } - public var alternates: [FontFamily] { _alternates() } + public var fontFamily: FontFamily { + _fontFamily() + } + + public var alternates: [FontFamily] { + _alternates() + } public init(_ declaration: T) { _fontFamily = { declaration.fontFamily } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index a88c2e8908..1b9b636dd6 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -195,16 +195,19 @@ open class EPUBNavigatorViewController: InputObservableViewController, // All events are ignored when loading spreads, except for `loaded` and `load`. case (.loading, .loaded): self = .idle + case (.loading, _): return false case let (.idle, .jump(locator)): self = .jumping(pendingLocator: locator) + case let (.idle, .move(direction)): self = .moving(direction: direction) case (.jumping, .jumped): self = .idle + // Moving or jumping to another locator is not allowed during a pending jump. case (.jumping, .jump), (.jumping, .move): @@ -212,6 +215,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, case (.moving, .moved): self = .idle + // Moving or jumping to another locator is not allowed during a pending move. case (.moving, .jump), (.moving, .move): @@ -266,9 +270,13 @@ open class EPUBNavigatorViewController: InputObservableViewController, private var positionsByReadingOrder: [[Locator]] = [] private let viewModel: EPUBNavigatorViewModel - public var publication: Publication { viewModel.publication } + public var publication: Publication { + viewModel.publication + } - var config: Configuration { viewModel.config } + var config: Configuration { + viewModel.config + } /// Creates a new instance of `EPUBNavigatorViewController`. /// @@ -917,7 +925,9 @@ open class EPUBNavigatorViewController: InputObservableViewController, // MARK: - Configurable - public var settings: EPUBSettings { viewModel.settings } + public var settings: EPUBSettings { + viewModel.settings + } public func submitPreferences(_ preferences: EPUBPreferences) { viewModel.submitPreferences(preferences) diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 57e4c7e9de..423ae327ac 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -244,12 +244,29 @@ final class EPUBNavigatorViewModel: Loggable { ) } - var readingProgression: ReadingProgression { settings.readingProgression } - var theme: Theme { settings.theme } - var scroll: Bool { settings.scroll } - var verticalText: Bool { settings.verticalText } - var spread: Spread { settings.spread } - var offsetFirstPage: Bool? { settings.offsetFirstPage } + var readingProgression: ReadingProgression { + settings.readingProgression + } + + var theme: Theme { + settings.theme + } + + 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/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index df308ca1fe..1d58e5961c 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -197,7 +197,7 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { return true } - // Location to scroll to in the resource once the page is loaded. + /// Location to scroll to in the resource once the page is loaded. private var pendingLocation: PageLocation = .start override func go(to location: PageLocation) async { @@ -313,12 +313,12 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { // MARK: - Progression - // Current progression range in the page. + /// Current progression range in the page. private var progression: ClosedRange? - // To check if a progression change was cancelled or not. + /// To check if a progression change was cancelled or not. private var previousProgression: ClosedRange? - // Called by the javascript code to notify that scrolling ended. + /// Called by the javascript code to notify that scrolling ended. private func progressionDidChange(_ body: Any) { guard isSpreadLoaded, diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 2ee8e8f861..1c6ae6091e 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -51,7 +51,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { let webView: WebView - private var lastClick: ClickEvent? = nil + private var lastClick: ClickEvent? /// If YES, the content will be faded in once loaded. let animatedLoad: Bool @@ -393,9 +393,8 @@ class EPUBSpreadView: UIView, Loggable, PageView { let result = await evaluateScript("readium.findFirstVisibleLocator()") do { let link = spread.first.link - let locator = try Locator(json: result.get())? + return try Locator(json: result.get())? .copy(href: link.url(), mediaType: link.mediaType ?? .xhtml) - return locator } catch { log(.error, error) return nil @@ -444,7 +443,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { } } - // Removes message handlers (preventing strong reference cycle). + /// Removes message handlers (preventing strong reference cycle). private func disableJSMessages() { guard JSMessagesEnabled else { return @@ -763,7 +762,6 @@ private extension KeyEvent { key = .tab case "Space": key = .space - case "ArrowDown": key = .arrowDown case "ArrowLeft": @@ -772,7 +770,6 @@ private extension KeyEvent { key = .arrowRight case "ArrowUp": key = .arrowUp - case "End": key = .end case "Home": @@ -781,7 +778,6 @@ private extension KeyEvent { key = .pageDown case "PageUp": key = .pageUp - case "MetaLeft", "MetaRight": key = .command case "ControlLeft", "ControlRight": @@ -790,12 +786,10 @@ private extension KeyEvent { key = .option case "ShiftLeft", "ShiftRight": key = .shift - case "Backspace": key = .backspace case "Escape": key = .escape - default: guard let char = dict["key"] as? String else { return nil diff --git a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift index 85f08a3a00..637040cf03 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift @@ -134,8 +134,8 @@ public struct EPUBSettings: ConfigurableSettings { ?? defaults.scroll ?? false - /// We disable pagination with vertical text, because CSS columns don't support it properly. - /// See https://github.com/readium/swift-toolkit/discussions/370 + // We disable pagination with vertical text, because CSS columns don't support it properly. + // See https://github.com/readium/swift-toolkit/discussions/370 if verticalText { scroll = true } diff --git a/Sources/Navigator/Input/InputObservableViewController.swift b/Sources/Navigator/Input/InputObservableViewController.swift index a039ac1e35..47dd7ad4de 100644 --- a/Sources/Navigator/Input/InputObservableViewController.swift +++ b/Sources/Navigator/Input/InputObservableViewController.swift @@ -31,7 +31,9 @@ open class InputObservableViewController: UIViewController, InputObservable { // MARK: - UIResponder - override open var canBecomeFirstResponder: Bool { true } + override open var canBecomeFirstResponder: Bool { + true + } override open func resignFirstResponder() -> Bool { // Force end editing of the view to make sure any subview is also @@ -197,7 +199,6 @@ extension Key { self = .shift case .keyboardEscape: self = .escape - default: let character = key.charactersIgnoringModifiers guard character != "" else { diff --git a/Sources/Navigator/Input/Key/Key.swift b/Sources/Navigator/Input/Key/Key.swift index adcaa69310..8c5f5de167 100644 --- a/Sources/Navigator/Input/Key/Key.swift +++ b/Sources/Navigator/Input/Key/Key.swift @@ -8,7 +8,7 @@ import Foundation import UIKit public enum Key: Equatable, CustomStringConvertible { - // Printable character. + /// Printable character. case character(String) // Whitespace keys. diff --git a/Sources/Navigator/Navigator.swift b/Sources/Navigator/Navigator.swift index 376e8539ce..fb50bd5c26 100644 --- a/Sources/Navigator/Navigator.swift +++ b/Sources/Navigator/Navigator.swift @@ -25,7 +25,7 @@ public protocol Navigator: AnyObject { @discardableResult func go(to locator: Locator, options: NavigatorGoOptions) async -> Bool - /// Moves to the position in the publication targeted by the given link. + // Moves to the position in the publication targeted by the given link. /// - Returns: Whether the navigator is able to move to the locator. The /// completion block is only called if true was returned. @@ -59,7 +59,7 @@ public struct NavigatorGoOptions { set { otherOptionsJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite [String: Any] + /// Trick to keep the struct equatable despite [String: Any] private var otherOptionsJSON: JSONDictionary public init(animated: Bool = false, otherOptions: [String: Any] = [:]) { diff --git a/Sources/Navigator/PDF/PDFDocumentHolder.swift b/Sources/Navigator/PDF/PDFDocumentHolder.swift index fcfa9337f5..aaebce28a8 100644 --- a/Sources/Navigator/PDF/PDFDocumentHolder.swift +++ b/Sources/Navigator/PDF/PDFDocumentHolder.swift @@ -26,7 +26,7 @@ extension PDFDocumentHolder: ReadiumShared.PDFDocumentFactory { return document } - public func open(resource: Resource, at href: HREF, password: String?) async throws -> ReadiumShared.PDFDocument { + func open(resource: Resource, at href: HREF, password: String?) async throws -> ReadiumShared.PDFDocument { guard let document = document, self.href == href.anyURL else { throw PDFDocumentError.openFailed } diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 2efab2deea..96e77fb5d7 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -58,7 +58,9 @@ open class PDFNavigatorViewController: /// Whether the pages is always scaled to fit the screen, unless the user zoomed in. @available(*, unavailable, message: "This API is deprecated") - public var scalesDocumentToFit: Bool { true } + public var scalesDocumentToFit: Bool { + true + } public weak var delegate: PDFNavigatorDelegate? public private(set) var pdfView: PDFDocumentView? @@ -589,7 +591,9 @@ open class PDFNavigatorViewController: // MARK: - SelectableNavigator - public var currentSelection: Selection? { editingActions.selection } + public var currentSelection: Selection? { + editingActions.selection + } public func clearSelection() { pdfView?.clearSelection() @@ -658,7 +662,7 @@ open class PDFNavigatorViewController: public var currentLocation: Locator? { currentPosition?.copy(text: { [weak self] in - /// Adds some context for bookmarking + // Adds some context for bookmarking if let page = self?.pdfView?.currentPage { $0 = .init(highlight: String(page.string?.prefix(280) ?? "")) } diff --git a/Sources/Navigator/Preferences/Configurable.swift b/Sources/Navigator/Preferences/Configurable.swift index eb426b4b91..7eee624d9c 100644 --- a/Sources/Navigator/Preferences/Configurable.swift +++ b/Sources/Navigator/Preferences/Configurable.swift @@ -67,7 +67,9 @@ public class AnyConfigurable< _editor = configurable.editor(of:) } - public var settings: Settings { _settings() } + public var settings: Settings { + _settings() + } public func submitPreferences(_ preferences: Preferences) { _submitPreferences(preferences) diff --git a/Sources/Navigator/Preferences/MappedPreference.swift b/Sources/Navigator/Preferences/MappedPreference.swift index 6d58906680..c66514db3a 100644 --- a/Sources/Navigator/Preferences/MappedPreference.swift +++ b/Sources/Navigator/Preferences/MappedPreference.swift @@ -149,9 +149,17 @@ public class MappedPreference: Preference { self.to = to } - public var value: NewValue? { original.value.map(from) } - public var effectiveValue: NewValue { from(original.effectiveValue) } - public var isEffective: Bool { original.isEffective } + public var value: NewValue? { + original.value.map(from) + } + + public var effectiveValue: NewValue { + from(original.effectiveValue) + } + + public var isEffective: Bool { + original.isEffective + } public func set(_ value: NewValue?) { original.set(value.map(to)) diff --git a/Sources/Navigator/Preferences/Preference.swift b/Sources/Navigator/Preferences/Preference.swift index 63eb9ad4e3..85f11f3637 100644 --- a/Sources/Navigator/Preferences/Preference.swift +++ b/Sources/Navigator/Preferences/Preference.swift @@ -82,9 +82,17 @@ public extension Preference { /// A type-erasing `Preference` object. public class AnyPreference: Preference { - public var value: Value? { _value() } - public var effectiveValue: Value { _effectiveValue() } - public var isEffective: Bool { _isEffective() } + public var value: Value? { + _value() + } + + public var effectiveValue: Value { + _effectiveValue() + } + + public var isEffective: Bool { + _isEffective() + } private let _value: () -> Value? private let _effectiveValue: () -> Value @@ -112,7 +120,9 @@ public extension EnumPreference { /// A type-erasing `EnumPreference` object. public class AnyEnumPreference: AnyPreference, EnumPreference { - public var supportedValues: [Value] { _supportedValues() } + public var supportedValues: [Value] { + _supportedValues() + } private let _supportedValues: () -> [Value] @@ -131,7 +141,9 @@ public extension RangePreference { /// A type-erasing `Preference` object. public class AnyRangePreference: AnyPreference, RangePreference { - public var supportedRange: ClosedRange { _supportedRange() } + public var supportedRange: ClosedRange { + _supportedRange() + } private let _supportedRange: () -> ClosedRange private let _increment: () -> Void diff --git a/Sources/Navigator/Preferences/PreferencesEditor.swift b/Sources/Navigator/Preferences/PreferencesEditor.swift index 7b9a82f64b..407f7b890b 100644 --- a/Sources/Navigator/Preferences/PreferencesEditor.swift +++ b/Sources/Navigator/Preferences/PreferencesEditor.swift @@ -38,7 +38,9 @@ public class StatefulPreferencesEditor Bool { true } - func navigator(_ navigator: SelectableNavigator, canPerformAction action: EditingAction, for selection: Selection) -> Bool { true } + func navigator(_ navigator: SelectableNavigator, shouldShowMenuForSelection selection: Selection) -> Bool { + true + } + + func navigator(_ navigator: SelectableNavigator, canPerformAction action: EditingAction, for selection: Selection) -> Bool { + true + } } diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index c57ad11b11..51c20f59b2 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -156,7 +156,7 @@ public class PublicationSpeechSynthesizer: Loggable { ) } - private var currentTask: Task? = nil + private var currentTask: Task? private lazy var engine: TTSEngine = engineFactory() @@ -177,7 +177,7 @@ public class PublicationSpeechSynthesizer: Loggable { } /// Cache for the last requested voice, for performance. - private var lastUsedVoice: TTSVoice? = nil + private var lastUsedVoice: TTSVoice? /// (Re)starts the synthesizer from the given locator or the beginning of the publication. public func start(from startLocator: Locator? = nil) { @@ -245,7 +245,7 @@ public class PublicationSpeechSynthesizer: Loggable { } /// `Content.Iterator` used to iterate through the `publication`. - private var publicationIterator: ContentIterator? = nil { + private var publicationIterator: ContentIterator? { didSet { utterances = CursorList() } @@ -320,8 +320,7 @@ public class PublicationSpeechSynthesizer: Loggable { return .right(utterance.language ?? config.defaultLanguage ?? publication.metadata.language - ?? Language.current - ) + ?? Language.current) } } diff --git a/Sources/Navigator/TTS/TTSVoice.swift b/Sources/Navigator/TTS/TTSVoice.swift index a68a171eb7..d482ef0f4e 100644 --- a/Sources/Navigator/TTS/TTSVoice.swift +++ b/Sources/Navigator/TTS/TTSVoice.swift @@ -106,15 +106,13 @@ public extension [TTSVoice] { // 3. Add remaining regions ordered by localized name. ordered.append(contentsOf: regions.sorted { ($0.localizedName(in: displayLocale) ?? $0.code) < ($1.localizedName(in: displayLocale) ?? $1.code) - } - ) + }) ordered = ordered.removingDuplicates() // Assign priorities: lower Int = higher priority let priorities = Dictionary(uniqueKeysWithValues: - ordered.enumerated().map { idx, region in (region, idx) } - ) + ordered.enumerated().map { idx, region in (region, idx) }) return (language, priorities) }) @@ -132,11 +130,11 @@ public extension [TTSVoice] { if let region = voice.language.region, let regionPriorities = regionPrioritiesByLanguage[language] - { - regionPriorities[region] ?? .max - } else { - .max - } + { + regionPriorities[region] ?? .max + } else { + .max + } return ( language: language.localizedLanguage(in: displayLocale) ?? voice.language.code.bcp47, @@ -230,7 +228,7 @@ private let defaultRegionByLanguage: [Language.Code: Language.Region] = [ .bcp47("yue"): "HK", ] -// Quality order priority: higher to lower +/// Quality order priority: higher to lower private let qualityPriorities: [TTSVoice.Quality: Int] = [ .higher: 0, .high: 1, @@ -239,7 +237,7 @@ private let qualityPriorities: [TTSVoice.Quality: Int] = [ .lower: 4, ] -// Gender order priority: female > male > unspecified +/// Gender order priority: female > male > unspecified private let genderPriorities: [TTSVoice.Gender: Int] = [ .female: 0, .male: 1, diff --git a/Sources/Navigator/Toolkit/Extensions/UIView.swift b/Sources/Navigator/Toolkit/Extensions/UIView.swift index 4586ada069..72e6ba4d3e 100644 --- a/Sources/Navigator/Toolkit/Extensions/UIView.swift +++ b/Sources/Navigator/Toolkit/Extensions/UIView.swift @@ -8,9 +8,9 @@ import Foundation import UIKit extension UIView { - // Finds the first `UIScrollView` in the view hierarchy. - // - // https://medium.com/@wailord/the-particulars-of-the-safe-area-and-contentinsetadjustmentbehavior-in-ios-11-9b842018eeaa#077b + /// Finds the first `UIScrollView` in the view hierarchy. + /// + /// https://medium.com/@wailord/the-particulars-of-the-safe-area-and-contentinsetadjustmentbehavior-in-ios-11-9b842018eeaa#077b var firstScrollView: UIScrollView? { sequence(first: self) { $0.subviews.first } .first { $0 is UIScrollView } diff --git a/Sources/Navigator/Toolkit/HTMLInjection.swift b/Sources/Navigator/Toolkit/HTMLInjection.swift index 6f961db1e5..110ebc5166 100644 --- a/Sources/Navigator/Toolkit/HTMLInjection.swift +++ b/Sources/Navigator/Toolkit/HTMLInjection.swift @@ -17,7 +17,9 @@ protocol HTMLInjectable { } extension HTMLInjectable { - func willInject(in html: String) -> String { html } + func willInject(in html: String) -> String { + html + } /// Injects the receiver in the given `html` document. func inject(in html: String) throws -> String { diff --git a/Sources/Navigator/Toolkit/PaginationView.swift b/Sources/Navigator/Toolkit/PaginationView.swift index 3702adfd8c..7890a66344 100644 --- a/Sources/Navigator/Toolkit/PaginationView.swift +++ b/Sources/Navigator/Toolkit/PaginationView.swift @@ -130,11 +130,11 @@ final class PaginationView: UIView, Loggable { } @available(*, unavailable) - public required init?(coder aDecoder: NSCoder) { + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override public func layoutSubviews() { + override func layoutSubviews() { guard !loadedViews.isEmpty else { scrollView.contentSize = bounds.size return @@ -372,11 +372,11 @@ final class PaginationView: UIView, Loggable { } extension PaginationView: UIScrollViewDelegate { - /// We disable the scroll once the user releases the drag to prevent scrolling through more than 1 resource at a - /// time. Otherwise, because the pagination view's scroll view would have the focus during the scroll gesture, the - /// scrollable content of the resources would be skipped. - /// Note: using this approach might provide a better experience: - /// https://oleb.net/blog/2014/05/scrollviews-inside-scrollviews/ + // We disable the scroll once the user releases the drag to prevent scrolling through more than 1 resource at a + // time. Otherwise, because the pagination view's scroll view would have the focus during the scroll gesture, the + // scrollable content of the resources would be skipped. + // Note: using this approach might provide a better experience: + // https://oleb.net/blog/2014/05/scrollviews-inside-scrollviews/ func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { scrollView.isScrollEnabled = false @@ -392,7 +392,7 @@ extension PaginationView: UIScrollViewDelegate { } } - public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollView.isScrollEnabled = isScrollEnabled let currentOffset = (readingProgression == .rtl) diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index da0fe2a4ca..d9c3a96727 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -9,16 +9,16 @@ import ReadiumFuzi import ReadiumShared public enum OPDS1ParserError: Error { - // The title is missing from the feed. + /// The title is missing from the feed. case missingTitle - // Root is not found + /// Root is not found case rootNotFound } public enum OPDSParserOpenSearchHelperError: Error { - // Search link not found in feed + /// Search link not found in feed case searchLinkNotFound - // OpenSearch document is invalid + /// OpenSearch document is invalid case searchDocumentIsInvalid } @@ -301,7 +301,7 @@ public class OPDS1Parser: Loggable { } static func parseEntry(entry: ReadiumFuzi.XMLElement, feedURL: URL) -> Publication? { - // Shortcuts to get tag(s)' string value. + /// Shortcuts to get tag(s)' string value. func tag(_ name: String) -> String? { entry.firstChild(tag: name)?.stringValue } diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index 8b76eb713f..80a7f73224 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -5,7 +5,6 @@ // import Foundation - import ReadiumShared public enum OPDS2ParserError: Error { diff --git a/Sources/Shared/Logger/Logger.swift b/Sources/Shared/Logger/Logger.swift index c0514188a5..f64192c07e 100644 --- a/Sources/Shared/Logger/Logger.swift +++ b/Sources/Shared/Logger/Logger.swift @@ -28,10 +28,10 @@ public final class Logger { /// throughout the framework. There is a default implementation `StubLogger` /// available. You can define your own implementation by applying the /// `Loggable` protocol to your xLogger class. - internal var activeLogger: LoggerType? + var activeLogger: LoggerType? /// The minimum severity level for logs to be displayed. - internal var minimumSeverityLevel: SeverityLevel? + var minimumSeverityLevel: SeverityLevel? private(set) static var sharedInstance = Logger() @@ -63,7 +63,7 @@ public final class Logger { // MARK: - Internal methods. - internal func log(_ value: Any?, at level: SeverityLevel, file: String, line: Int) { + func log(_ value: Any?, at level: SeverityLevel, file: String, line: Int) { if let minimumSeverityLevel = minimumSeverityLevel { guard level.numericValue >= minimumSeverityLevel.numericValue else { return diff --git a/Sources/Shared/OPDS/Feed.swift b/Sources/Shared/OPDS/Feed.swift index 9cfb95977e..1f2f0a97cb 100644 --- a/Sources/Shared/OPDS/Feed.swift +++ b/Sources/Shared/OPDS/Feed.swift @@ -21,7 +21,7 @@ public class Feed { /// Return a String representing the URL of the searchLink of the feed. /// /// - Returns: The HREF value of the search link - internal func getSearchLinkHref() -> String? { + func getSearchLinkHref() -> String? { links.firstWithRel(.search)?.href } } diff --git a/Sources/Shared/OPDS/OPDSAcquisition.swift b/Sources/Shared/OPDS/OPDSAcquisition.swift index cce371695e..4152ab1760 100644 --- a/Sources/Shared/OPDS/OPDSAcquisition.swift +++ b/Sources/Shared/OPDS/OPDSAcquisition.swift @@ -13,7 +13,9 @@ public struct OPDSAcquisition: Equatable { public var type: String public var children: [OPDSAcquisition] = [] - public var mediaType: MediaType? { MediaType(type) } + public var mediaType: MediaType? { + MediaType(type) + } public init(type: String, children: [OPDSAcquisition] = []) { self.type = type diff --git a/Sources/Shared/OPDS/OPDSPrice.swift b/Sources/Shared/OPDS/OPDSPrice.swift index 1b45581d67..63f8a3256a 100644 --- a/Sources/Shared/OPDS/OPDSPrice.swift +++ b/Sources/Shared/OPDS/OPDSPrice.swift @@ -12,7 +12,7 @@ import ReadiumInternal public struct OPDSPrice: Equatable { public var currency: String // eg. EUR - // Should only be used for display purposes, because of precision issues inherent with Double and the JSON parsing. + /// Should only be used for display purposes, because of precision issues inherent with Double and the JSON parsing. public var value: Double public init(currency: String, value: Double) { diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift b/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift index 164ebcfed4..b5970ad504 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift @@ -4,7 +4,7 @@ // available in the top-level LICENSE file of the project. // -// DO NOT EDIT. File generated automatically from https://github.com/edrlab/thorium-locales/. +/// DO NOT EDIT. File generated automatically from https://github.com/edrlab/thorium-locales/. public extension AccessibilityDisplayString { static let accessibilitySummaryNoMetadata: Self = "readium.a11y.accessibility-summary-no-metadata" static let accessibilitySummaryPublisherContact: Self = "readium.a11y.accessibility-summary-publisher-contact" diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift index 4e05332cf1..a5c2fca739 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift @@ -110,7 +110,9 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .waysOfReadingTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } /// "Ways of reading" should be rendered even if there is no metadata. public let shouldDisplay: Bool = true @@ -267,7 +269,9 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public struct Navigation: AccessibilityDisplayField { /// Indicates whether no information about navigation features is /// available. - public var noMetadata: Bool { !tableOfContents && !index && !headings && !page } + public var noMetadata: Bool { + !tableOfContents && !index && !headings && !page + } /// Table of contents to all chapters of the text via links. public var tableOfContents: Bool @@ -283,9 +287,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .navigationTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -377,9 +385,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .richContentTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -507,9 +519,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .additionalAccessibilityInformationTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -649,9 +665,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .hazardsTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -766,7 +786,9 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .conformanceTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } /// "Conformance" should be rendered even if there is no metadata. public let shouldDisplay: Bool = true @@ -818,7 +840,9 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#legal-considerations public struct Legal: AccessibilityDisplayField { /// No information is available. - public var noMetadata: Bool { !exemption } + public var noMetadata: Bool { + !exemption + } /// This publication claims an accessibility exemption in some /// jurisdictions. @@ -826,9 +850,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .legalConsiderationsTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -865,9 +893,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .accessibilitySummaryTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { summary != nil } + public var shouldDisplay: Bool { + summary != nil + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -966,7 +998,7 @@ public struct AccessibilityDisplayStatement: Sendable, Equatable, Identifiable { /// /// See https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/draft/localizations/ public struct AccessibilityDisplayString: RawRepresentable, ExpressibleByStringLiteral, Sendable, Hashable { - // Special key for the provided summary, which is not localized. + /// Special key for the provided summary, which is not localized. static let accessibilitySummary: Self = "readium.a11y.accessibility-summary" public let rawValue: String @@ -1008,22 +1040,11 @@ public struct AccessibilityDisplayString: RawRepresentable, ExpressibleByStringL } private func bundleString(_ key: String, _ values: CVarArg...) -> String { - bundleString(key, in: Bundle.module, table: "W3CAccessibilityMetadataDisplayGuide", values) - } - - /// Returns the localized string in the main bundle, or fallback on the given - /// bundle if not found. - private func bundleString(_ key: String, in bundle: Bundle, table: String? = nil, _ values: [CVarArg]) -> String { - let defaultValue = bundle.localizedString(forKey: key, value: nil, table: table) - var string = Bundle.main.localizedString(forKey: key, value: defaultValue, table: nil) - if !values.isEmpty { - string = String(format: string, locale: .current, arguments: values) - } - return string + ReadiumLocalizedString(key, in: Bundle.module, table: "W3CAccessibilityMetadataDisplayGuide", values) } } -// Syntactic sugar +/// Syntactic sugar private extension Array where Element == AccessibilityDisplayStatement { mutating func append(_ string: AccessibilityDisplayString) { append(AccessibilityDisplayStatement(string: string)) @@ -1034,8 +1055,12 @@ private extension Array where Element == AccessibilityDisplayStatement { public extension AccessibilityDisplayString { @available(*, deprecated, renamed: "richContentExtendedDescriptions") - static var richContentExtended: Self { richContentExtendedDescriptions } + static var richContentExtended: Self { + richContentExtendedDescriptions + } @available(*, deprecated, renamed: "richContentMathAsMathml") - static var richContentAccessibleMathAsMathml: Self { richContentMathAsMathml } + static var richContentAccessibleMathAsMathml: Self { + richContentMathAsMathml + } } diff --git a/Sources/Shared/Publication/Contributor.swift b/Sources/Shared/Publication/Contributor.swift index fadcb8d096..ac8b94c2d4 100644 --- a/Sources/Shared/Publication/Contributor.swift +++ b/Sources/Shared/Publication/Contributor.swift @@ -11,7 +11,9 @@ import ReadiumInternal public struct Contributor: Hashable, Sendable { /// The name of the contributor. public var localizedName: LocalizedString - public var name: String { localizedName.string } + public var name: String { + localizedName.string + } /// An unambiguous reference to this contributor. public var identifier: String? diff --git a/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift b/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift index b920665a2d..247ad2a6b1 100644 --- a/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift +++ b/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift @@ -9,5 +9,7 @@ import Foundation /// Presentation extensions for `Metadata`. public extension Metadata { @available(*, unavailable, message: "This was removed from RWPM. You can still use the EPUB extensibility to access the original values.") - var presentation: Presentation { fatalError() } + var presentation: Presentation { + fatalError() + } } diff --git a/Sources/Shared/Publication/LinkRelation.swift b/Sources/Shared/Publication/LinkRelation.swift index 813672f8c9..91b46f4309 100644 --- a/Sources/Shared/Publication/LinkRelation.swift +++ b/Sources/Shared/Publication/LinkRelation.swift @@ -124,16 +124,16 @@ public struct LinkRelation: Sendable { // Authentication for OPDS – https://drafts.opds.io/authentication-for-opds-1.0.html - // Location where a client can authenticate the user with OAuth. + /// Location where a client can authenticate the user with OAuth. public static let opdsAuthenticate = LinkRelation("authenticate") - // Location where a client can refresh the Access Token by sending a Refresh Token. + /// Location where a client can refresh the Access Token by sending a Refresh Token. public static let opdsRefresh = LinkRelation("refresh") - // Logo associated to the Catalog provider. + /// Logo associated to the Catalog provider. public static let opdsLogo = LinkRelation("logo") - // Location where a user can register. + /// Location where a user can register. public static let opdsRegister = LinkRelation("register") - // Support resources for the user (either a website, an email or a telephone number). + /// Support resources for the user (either a website, an email or a telephone number). public static let opdsHelp = LinkRelation("help") } diff --git a/Sources/Shared/Publication/LocalizedString.swift b/Sources/Shared/Publication/LocalizedString.swift index aff8825929..ab257db16c 100644 --- a/Sources/Shared/Publication/LocalizedString.swift +++ b/Sources/Shared/Publication/LocalizedString.swift @@ -86,7 +86,9 @@ public enum LocalizedString: Hashable, Sendable { } extension LocalizedString: CustomStringConvertible { - public var description: String { string } + public var description: String { + string + } } /// Provides syntactic sugar when initializing a LocalizedString from a regular String (nonlocalized) or a [String: String] (localized). @@ -107,5 +109,7 @@ extension LocalizedString: LocalizedStringConvertible { } extension Dictionary: LocalizedStringConvertible where Key == String, Value == String { - public var localizedString: LocalizedString { .localized(self) } + public var localizedString: LocalizedString { + .localized(self) + } } diff --git a/Sources/Shared/Publication/Locator.swift b/Sources/Shared/Publication/Locator.swift index 2481f3f0dd..353bd4faab 100644 --- a/Sources/Shared/Publication/Locator.swift +++ b/Sources/Shared/Publication/Locator.swift @@ -169,7 +169,7 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { set { otherLocationsJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite [String: Any] + /// Trick to keep the struct equatable despite [String: Any] private var otherLocationsJSON: JSONDictionary public init(fragments: [String] = [], progression: Double? = nil, totalProgression: Double? = nil, position: Int? = nil, otherLocations: JSONDictionary.Wrapped = [:]) { @@ -212,7 +212,9 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { } } - public var isEmpty: Bool { json.isEmpty } + public var isEmpty: Bool { + json.isEmpty + } public var json: JSONDictionary.Wrapped { makeJSON([ @@ -223,11 +225,15 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { ], additional: otherLocations) } - public var jsonString: String? { serializeJSONString(json) } + public var jsonString: String? { + serializeJSONString(json) + } /// Syntactic sugar to access the `otherLocations` values by subscripting `Locations` directly. /// locations["cssSelector"] == locations.otherLocations["cssSelector"] - public subscript(key: String) -> Any? { otherLocations[key] } + public subscript(key: String) -> Any? { + otherLocations[key] + } } public struct Text: Hashable, Loggable, Sendable { @@ -275,7 +281,9 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { ]) } - public var jsonString: String? { serializeJSONString(json) } + public var jsonString: String? { + serializeJSONString(json) + } /// Returns a copy of this text after sanitizing its content for user display. public func sanitized() -> Locator.Text { @@ -376,7 +384,9 @@ public struct LocatorCollection: Hashable { /// Holds the metadata of a `LocatorCollection`. public struct Metadata: Hashable { public var localizedTitle: LocalizedString? - public var title: String? { localizedTitle?.string } + public var title: String? { + localizedTitle?.string + } /// Indicates the total number of locators in the collection. public var numberOfItems: Int? @@ -387,7 +397,7 @@ public struct LocatorCollection: Hashable { set { otherMetadataJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite [String: Any] + /// Trick to keep the struct equatable despite [String: Any] private var otherMetadataJSON: JSONDictionary public init( diff --git a/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift b/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift index f5e7d06f91..a5ae62e004 100644 --- a/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift +++ b/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift @@ -6,7 +6,7 @@ import Foundation -/// The publicly accessible struct. +// The publicly accessible struct. /// Clip is the representation of a MediaOverlay file fragment. A clip represent /// the synchronized audio for a piece of text, it has a file where its data @@ -22,7 +22,7 @@ public struct Clip { /// End time in seconds. public var end: Double! /// Total clip duration in seconds (end - start). -// @available(iOS, deprecated: 9.0, message: "Don't use it when the value is negative, because some information is missing in the original SMIL file. Try to get the duration from file system or APIs in Fetcher, then minus the start value.") + /// @available(iOS, deprecated: 9.0, message: "Don't use it when the value is negative, because some information is missing in the original SMIL file. Try to get the duration from file system or APIs in Fetcher, then minus the start value.") public var duration: Double! public init() {} diff --git a/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift b/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift index 913a28e6c2..aeda6e15c4 100644 --- a/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift +++ b/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift @@ -151,7 +151,7 @@ public class MediaOverlays { // For each node of the current scope.. for node in nodes { guard !previousNodeFoundFlag else { - /// If the node is a section, we get the first non section child. + // If the node is a section, we get the first non section child. if node.role.contains("section") { if let validChild = getFirstNonSectionChild(of: node) { return (validChild, false) @@ -160,7 +160,7 @@ public class MediaOverlays { continue } } - /// Else we just return it. + // Else we just return it. return (node, false) } // If the node is a "section" ( sequence element).. diff --git a/Sources/Shared/Publication/Metadata.swift b/Sources/Shared/Publication/Metadata.swift index b43d9640fd..27edf4d3b1 100644 --- a/Sources/Shared/Publication/Metadata.swift +++ b/Sources/Shared/Publication/Metadata.swift @@ -18,10 +18,14 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable { public var conformsTo: [Publication.Profile] public var localizedTitle: LocalizedString? - public var title: String? { localizedTitle?.string } + public var title: String? { + localizedTitle?.string + } public var localizedSubtitle: LocalizedString? - public var subtitle: String? { localizedSubtitle?.string } + public var subtitle: String? { + localizedSubtitle?.string + } public var accessibility: Accessibility? public var modified: Date? @@ -67,7 +71,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable { set { otherMetadataJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite [String: Any] + /// Trick to keep the struct equatable despite [String: Any] private var otherMetadataJSON: JSONDictionary public init( diff --git a/Sources/Shared/Publication/Properties.swift b/Sources/Shared/Publication/Properties.swift index 7a7cf433be..a44b7f6df0 100644 --- a/Sources/Shared/Publication/Properties.swift +++ b/Sources/Shared/Publication/Properties.swift @@ -16,7 +16,7 @@ public struct Properties: Hashable, Loggable, WarningLogger, Sendable { set { otherPropertiesJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite JSONDictionary.Wrapped + /// Trick to keep the struct equatable despite JSONDictionary.Wrapped private var otherPropertiesJSON: JSONDictionary public init(_ otherProperties: JSONDictionary.Wrapped = [:]) { @@ -54,7 +54,9 @@ public struct Properties: Hashable, Loggable, WarningLogger, Sendable { /// /// https://github.com/readium/webpub-manifest/blob/master/properties.md#core-properties public extension Properties { - private static var pageKey: String { "page" } + private static var pageKey: String { + "page" + } /// Indicates how the linked resource should be displayed in a reading /// environment that displays synthetic spreads. diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index 8eb50124e2..bc8e844596 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -14,14 +14,31 @@ public class Publication: Closeable, Loggable { private let container: Container private let services: [PublicationService] - public var context: [String] { manifest.context } - public var metadata: Metadata { manifest.metadata } - public var links: [Link] { manifest.links } + public var context: [String] { + manifest.context + } + + public var metadata: Metadata { + manifest.metadata + } + + public var links: [Link] { + manifest.links + } + /// Identifies a list of resources in reading order for the publication. - public var readingOrder: [Link] { manifest.readingOrder } + public var readingOrder: [Link] { + manifest.readingOrder + } + /// Identifies resources that are necessary for rendering the publication. - public var resources: [Link] { manifest.resources } - public var subcollections: [String: [PublicationCollection]] { manifest.subcollections } + public var resources: [Link] { + manifest.resources + } + + public var subcollections: [String: [PublicationCollection]] { + manifest.subcollections + } public init( manifest: Manifest, @@ -66,7 +83,9 @@ public class Publication: Closeable, Loggable { /// The URL where this publication is served, computed from the `Link` with `self` relation. /// /// e.g. https://provider.com/pub1293/manifest.json gives https://provider.com/pub1293/ - public var baseURL: HTTPURL? { manifest.baseURL } + public var baseURL: HTTPURL? { + manifest.baseURL + } /// Finds the first Link having the given `href` in the publication's links. public func linkWithHREF(_ href: T) -> Link? { diff --git a/Sources/Shared/Publication/PublicationCollection.swift b/Sources/Shared/Publication/PublicationCollection.swift index 86b187a798..0de2406f76 100644 --- a/Sources/Shared/Publication/PublicationCollection.swift +++ b/Sources/Shared/Publication/PublicationCollection.swift @@ -21,7 +21,7 @@ public struct PublicationCollection: JSONEquatable, Hashable, Sendable { /// Subcollections indexed by their role in this collection. public var subcollections: [String: [PublicationCollection]] - // Trick to keep the struct hashable despite [String: Any] + /// Trick to keep the struct hashable despite [String: Any] private var metadataJSON: JSONDictionary public init(metadata: [String: Any] = [:], links: [Link], subcollections: [String: [PublicationCollection]] = [:]) { diff --git a/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift b/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift index 6464318c11..2993eb2401 100644 --- a/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift +++ b/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift @@ -37,8 +37,13 @@ public protocol ContentProtectionService: PublicationService { } public extension ContentProtectionService { - var credentials: String? { nil } - var rights: UserRights { UnrestrictedUserRights() } + var credentials: String? { + nil + } + + var rights: UserRights { + UnrestrictedUserRights() + } } // MARK: Publication Helpers diff --git a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift index 43198edf72..1a247a0536 100644 --- a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift +++ b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift @@ -38,20 +38,40 @@ public protocol UserRights { public class UnrestrictedUserRights: UserRights { public init() {} - public func canCopy(text: String) async -> Bool { true } - public func copy(text: String) async -> Bool { true } + public func canCopy(text: String) async -> Bool { + true + } - public func canPrint(pageCount: Int) async -> Bool { true } - public func print(pageCount: Int) async -> Bool { true } + public func copy(text: String) async -> Bool { + true + } + + public func canPrint(pageCount: Int) async -> Bool { + true + } + + public func print(pageCount: Int) async -> Bool { + true + } } /// A `UserRights` which forbids all rights. public class AllRestrictedUserRights: UserRights { public init() {} - public func canCopy(text: String) async -> Bool { false } - public func copy(text: String) async -> Bool { false } + public func canCopy(text: String) async -> Bool { + false + } + + public func copy(text: String) async -> Bool { + false + } + + public func canPrint(pageCount: Int) async -> Bool { + false + } - public func canPrint(pageCount: Int) async -> Bool { false } - public func print(pageCount: Int) async -> Bool { false } + public func print(pageCount: Int) async -> Bool { + false + } } diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index b06d606518..ff1e497991 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -63,9 +63,13 @@ public struct AnyEquatableContentElement: Equatable, ContentElement { self.element = element } - public var locator: Locator { element.locator } + public var locator: Locator { + element.locator + } - public var attributes: [ContentAttribute] { element.attributes } + public var attributes: [ContentAttribute] { + element.attributes + } public func isEqualTo(_ other: ContentElement) -> Bool { element.isEqualTo(other) @@ -85,7 +89,9 @@ public protocol TextualContentElement: ContentElement { } public extension TextualContentElement { - var text: String? { accessibilityLabel } + var text: String? { + accessibilityLabel + } } /// An element referencing an embedded external resource. @@ -200,8 +206,13 @@ public struct TextContentElement: Hashable, TextualContentElement { /// /// The `V` phantom type is there to perform static type checking when requesting an attribute. public struct ContentAttributeKey: Hashable { - public static var accessibilityLabel: ContentAttributeKey { .init("accessibilityLabel") } - public static var language: ContentAttributeKey { .init("language") } + public static var accessibilityLabel: ContentAttributeKey { + .init("accessibilityLabel") + } + + public static var language: ContentAttributeKey { + .init("language") + } public let key: String public init(_ key: String) { @@ -231,8 +242,13 @@ public protocol ContentAttributesHolder { } public extension ContentAttributesHolder { - var language: Language? { self[.language] } - var accessibilityLabel: String? { self[.accessibilityLabel] } + var language: Language? { + self[.language] + } + + var accessibilityLabel: String? { + self[.accessibilityLabel] + } /// Gets the first attribute with the given `key`. subscript(_ key: ContentAttributeKey) -> T? { diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift index 49ef83e2e1..7430628381 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift @@ -227,7 +227,7 @@ public class HTMLResourceContentIterator: ContentIterator { } } - public func head(_ node: Node, _ depth: Int) throws { + func head(_ node: Node, _ depth: Int) throws { if let node = node as? Element { let parent = ParentElement(element: node) if node.isBlock() { diff --git a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift index ce9b10ad70..cca40733a3 100644 --- a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift @@ -41,9 +41,11 @@ public final class GeneratedCoverService: CoverService { await cachedCover().map { $0 as UIImage? } } - public var links: [Link] { [coverLink] } + public var links: [Link] { + [coverLink] + } - public func get(_ href: T) -> (any Resource)? where T: URLConvertible { + public func get(_ href: T) -> (any Resource)? { guard href.anyURL.isEquivalentTo(coverLink.url()) else { return nil } @@ -62,7 +64,7 @@ public final class GeneratedCoverService: CoverService { private class CoverResource: Resource { private let cover: () async -> ReadResult - public init(cover: @escaping () async -> ReadResult) { + init(cover: @escaping () async -> ReadResult) { self.cover = cover } diff --git a/Sources/Shared/Publication/Services/Locator/LocatorService.swift b/Sources/Shared/Publication/Services/Locator/LocatorService.swift index 28a242d11f..aad19b0362 100644 --- a/Sources/Shared/Publication/Services/Locator/LocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/LocatorService.swift @@ -27,9 +27,17 @@ public protocol LocatorService: PublicationService { } public extension LocatorService { - func locate(_ locator: Locator) async -> Locator? { nil } - func locate(_ link: Link) async -> Locator? { nil } - func locate(progression: Double) async -> Locator? { nil } + func locate(_ locator: Locator) async -> Locator? { + nil + } + + func locate(_ link: Link) async -> Locator? { + nil + } + + func locate(progression: Double) async -> Locator? { + nil + } } // MARK: Publication Helpers diff --git a/Sources/Shared/Publication/Services/Positions/PositionsService.swift b/Sources/Shared/Publication/Services/Positions/PositionsService.swift index 5fb0adaf38..cdbba9e563 100644 --- a/Sources/Shared/Publication/Services/Positions/PositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PositionsService.swift @@ -31,9 +31,11 @@ private let positionsLink = Link( ) public extension PositionsService { - var links: [Link] { [positionsLink] } + var links: [Link] { + [positionsLink] + } - func get(_ href: T) -> (any Resource)? where T: URLConvertible { + func get(_ href: T) -> (any Resource)? { guard href.anyURL.isEquivalentTo(positionsLink.url()) else { return nil } diff --git a/Sources/Shared/Publication/Services/PublicationService.swift b/Sources/Shared/Publication/Services/PublicationService.swift index c38e7cc9a0..ac90a932b2 100644 --- a/Sources/Shared/Publication/Services/PublicationService.swift +++ b/Sources/Shared/Publication/Services/PublicationService.swift @@ -37,9 +37,13 @@ public protocol PublicationService: Closeable { } public extension PublicationService { - var links: [Link] { [] } + var links: [Link] { + [] + } - func get(_ href: T) -> Resource? { nil } + func get(_ href: T) -> Resource? { + nil + } } /// Factory used to create a `PublicationService`. diff --git a/Sources/Shared/Publication/Services/Search/SearchService.swift b/Sources/Shared/Publication/Services/Search/SearchService.swift index 9a88c7d3fd..d16689af34 100644 --- a/Sources/Shared/Publication/Services/Search/SearchService.swift +++ b/Sources/Shared/Publication/Services/Search/SearchService.swift @@ -124,7 +124,9 @@ public enum SearchError: Error { // MARK: Publication Helpers public extension Publication { - private var searchService: SearchService? { findService(SearchService.self) } + private var searchService: SearchService? { + findService(SearchService.self) + } /// Indicates whether the content of this publication can be searched. var isSearchable: Bool { diff --git a/Sources/Shared/Publication/Subject.swift b/Sources/Shared/Publication/Subject.swift index 73876aef47..1f8733ad15 100644 --- a/Sources/Shared/Publication/Subject.swift +++ b/Sources/Shared/Publication/Subject.swift @@ -7,10 +7,13 @@ import Foundation import ReadiumInternal -// https://github.com/readium/webpub-manifest/tree/master/contexts/default#subjects +/// https://github.com/readium/webpub-manifest/tree/master/contexts/default#subjects public struct Subject: Hashable, Sendable { public var localizedName: LocalizedString - public var name: String { localizedName.string } + public var name: String { + localizedName.string + } + public var sortAs: String? public var scheme: String? // URI public var code: String? diff --git a/Sources/Shared/Toolkit/Data/Container/Container.swift b/Sources/Shared/Toolkit/Data/Container/Container.swift index 244c78fb49..e6a18fd906 100644 --- a/Sources/Shared/Toolkit/Data/Container/Container.swift +++ b/Sources/Shared/Toolkit/Data/Container/Container.swift @@ -34,7 +34,9 @@ public struct EmptyContainer: Container { public let sourceURL: AbsoluteURL? = nil public let entries: Set = Set() - public subscript(url: any URLConvertible) -> Resource? { nil } + public subscript(url: any URLConvertible) -> Resource? { + nil + } } /// Concatenates several containers. diff --git a/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift b/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift index 8cc915a671..4b37c828cd 100644 --- a/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift +++ b/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift @@ -11,8 +11,13 @@ public class SingleResourceContainer: Container { public let entry: AnyURL private let resource: Resource - public var sourceURL: AbsoluteURL? { resource.sourceURL } - public var entries: Set { [entry] } + public var sourceURL: AbsoluteURL? { + resource.sourceURL + } + + public var entries: Set { + [entry] + } public init(resource: Resource, at entry: AnyURL) { self.resource = resource diff --git a/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift b/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift index 45a61b1815..44c0e1c211 100644 --- a/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift +++ b/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift @@ -29,7 +29,9 @@ public final class TransformingContainer: Container { } public let sourceURL: AbsoluteURL? = nil - public var entries: Set { container.entries } + public var entries: Set { + container.entries + } public subscript(url: any URLConvertible) -> Resource? { let url = url.anyURL diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index e9da665e5c..9db0cd274a 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -38,7 +38,9 @@ public actor BufferingResource: Resource, Loggable { self.init(resource: resource, bufferSize: Int(bufferSize)) } - public nonisolated var sourceURL: AbsoluteURL? { resource.sourceURL } + public nonisolated var sourceURL: AbsoluteURL? { + resource.sourceURL + } public func properties() async -> ReadResult { await resource.properties() diff --git a/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift index f49db5bf1a..f310bb078b 100644 --- a/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift @@ -28,7 +28,9 @@ public actor CachingResource: Resource { return data! } - public nonisolated var sourceURL: AbsoluteURL? { resource.sourceURL } + public nonisolated var sourceURL: AbsoluteURL? { + resource.sourceURL + } public func properties() async -> ReadResult { await resource.properties() diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift index f5f14faaed..0b9c6b3f28 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift @@ -65,10 +65,10 @@ class _HTMLResourceContentExtractor: _ResourceContentExtractor { } } - // Parse the HTML resource as a strict XML document. - // - // This is much more efficient than using SwiftSoup, but will fail when encountering - // invalid HTML documents. + /// Parse the HTML resource as a strict XML document. + /// + /// This is much more efficient than using SwiftSoup, but will fail when encountering + /// invalid HTML documents. private func parse(xml: String) async -> String? { guard let document = try? await xmlFactory.open(string: xml, namespaces: [.xhtml]) else { return nil @@ -77,9 +77,9 @@ class _HTMLResourceContentExtractor: _ResourceContentExtractor { return document.first("/xhtml:html/xhtml:body")?.textContent } - // Parse the HTML resource with SwiftSoup. - // - // This may be slow but will recover from broken HTML documents. + /// Parse the HTML resource with SwiftSoup. + /// + /// This may be slow but will recover from broken HTML documents. private func parse(html: String) -> String? { try? SwiftSoup.parse(html).body()?.text() } diff --git a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift index e31d6c9bce..fb64c17420 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift @@ -21,7 +21,9 @@ actor TailCachingResource: Resource, Loggable { self.cacheFromOffset = cacheFromOffset } - nonisolated var sourceURL: AbsoluteURL? { resource.sourceURL } + nonisolated var sourceURL: AbsoluteURL? { + resource.sourceURL + } func properties() async -> ReadResult { await resource.properties() @@ -61,7 +63,7 @@ actor TailCachingResource: Resource, Loggable { } } - private var cache: ReadResult? = nil + private var cache: ReadResult? private func cachedTail() async -> ReadResult { if let cache = cache { diff --git a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift index d5d605ac87..505c7ebb06 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift @@ -29,8 +29,8 @@ open class TransformingResource: Resource { await _transform!(data) } - // As the resource is transformed, we can't use the original source URL - // as reference. + /// As the resource is transformed, we can't use the original source URL + /// as reference. public let sourceURL: AbsoluteURL? = nil open func estimatedLength() async -> ReadResult { diff --git a/Sources/Shared/Toolkit/DocumentTypes.swift b/Sources/Shared/Toolkit/DocumentTypes.swift index b90ad6d32c..bf98d7de71 100644 --- a/Sources/Shared/Toolkit/DocumentTypes.swift +++ b/Sources/Shared/Toolkit/DocumentTypes.swift @@ -91,18 +91,18 @@ public struct DocumentTypes { /// Metadata about a Document Type declared in `CFBundleDocumentTypes`. public struct DocumentType: Equatable, Loggable { - // Abstract name for the document type, used to refer to the type. + /// Abstract name for the document type, used to refer to the type. public let name: String - // Uniform Type Identifiers supported by this document type. + /// Uniform Type Identifiers supported by this document type. public let utis: [String] - // The preferred media type used to identify this document type. + /// The preferred media type used to identify this document type. public let preferredMediaType: MediaType? - // Media (MIME) types recognized by this document type. + /// Media (MIME) types recognized by this document type. public let mediaTypes: [MediaType] - // File extensions recognized by this document type. + /// File extensions recognized by this document type. public let fileExtensions: [String] init( diff --git a/Sources/Shared/Toolkit/File/DirectoryContainer.swift b/Sources/Shared/Toolkit/File/DirectoryContainer.swift index ad5e3c93cc..478661bbfe 100644 --- a/Sources/Shared/Toolkit/File/DirectoryContainer.swift +++ b/Sources/Shared/Toolkit/File/DirectoryContainer.swift @@ -11,7 +11,10 @@ public struct DirectoryContainer: Container, Loggable { public struct NotADirectoryError: Error {} private let directoryURL: FileURL - public var sourceURL: AbsoluteURL? { directoryURL } + public var sourceURL: AbsoluteURL? { + directoryURL + } + public let entries: Set /// Creates a ``DirectoryContainer`` at `directory` serving only the given diff --git a/Sources/Shared/Toolkit/File/FileResource.swift b/Sources/Shared/Toolkit/File/FileResource.swift index 61c0354907..3d5baac213 100644 --- a/Sources/Shared/Toolkit/File/FileResource.swift +++ b/Sources/Shared/Toolkit/File/FileResource.swift @@ -14,7 +14,9 @@ public actor FileResource: Resource, Loggable { fileURL = file } - public nonisolated var sourceURL: AbsoluteURL? { fileURL } + public nonisolated var sourceURL: AbsoluteURL? { + fileURL + } private var _length: ReadResult? diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index 0ad3be2a13..b20c7e63e3 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -293,7 +293,9 @@ public struct MediaType: Hashable, Loggable, Sendable { } extension MediaType: RawRepresentable { - public var rawValue: String { string } + public var rawValue: String { + string + } public init?(rawValue: String) { self.init(rawValue) diff --git a/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift index 5fb871721e..dfd5bdac27 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift @@ -70,7 +70,7 @@ public struct ZABFormatSniffer: FormatSniffer { return nil } - public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult where C: Container { + public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult { let entries = container.entries .filter { $0.lastPathSegment?.hasPrefix(".") == false && diff --git a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift index 1972d49727..0cbe03383d 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift @@ -69,7 +69,7 @@ public struct ComicFormatSniffer: FormatSniffer { return nil } - public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult where C: Container { + public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult { let entries = container.entries .filter { $0.lastPathSegment?.hasPrefix(".") == false && diff --git a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift index 94f752efb4..617c670af0 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift @@ -31,7 +31,7 @@ public struct EPUBFormatSniffer: FormatSniffer { return nil } - public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult where C: Container { + public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult { guard let resource = container[AnyURL(path: "mimetype")!] else { return .success(nil) } diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift index 59a0151073..0a65324db0 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift @@ -26,7 +26,7 @@ public struct RPFFormatSniffer: FormatSniffer { return nil } - public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult where C: Container { + public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult { guard let resource = container[AnyURL(path: "manifest.json")!] else { return .success(nil) } diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index f31ca9a714..a7b2c6c64c 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -151,7 +151,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { self.init(configuration: config, userAgent: userAgent, delegate: delegate) } - public weak var delegate: DefaultHTTPClientDelegate? = nil + public weak var delegate: DefaultHTTPClientDelegate? private let tasks: HTTPTaskManager private let session: URLSession @@ -290,7 +290,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { // MARK: - URLSessionDataDelegate - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { guard let task = findTask(for: dataTask) else { completionHandler(.cancel) return @@ -298,11 +298,11 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { task.urlSession(session, didReceive: response, completionHandler: completionHandler) } - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { findTask(for: dataTask)?.urlSession(session, didReceive: data) } - public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { findTask(for: task)?.urlSession(session, didCompleteWithError: error) } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift index 5b9ab5639e..1422579289 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift @@ -17,7 +17,9 @@ public actor HTTPResource: Resource { self.client = client } - public nonisolated var sourceURL: AbsoluteURL? { url } + public nonisolated var sourceURL: AbsoluteURL? { + url + } public func properties() async -> ReadResult { await headResponse() diff --git a/Sources/Shared/Toolkit/Language.swift b/Sources/Shared/Toolkit/Language.swift index 55b659a6c0..402873befe 100644 --- a/Sources/Shared/Toolkit/Language.swift +++ b/Sources/Shared/Toolkit/Language.swift @@ -53,7 +53,9 @@ public struct Language: Hashable, Sendable { locale.regionCode.flatMap { Region(code: $0) } } - public var locale: Locale { Locale(identifier: code.bcp47) } + public var locale: Locale { + Locale(identifier: code.bcp47) + } public func localizedDescription(in locale: Locale = Locale.current) -> String { locale.localizedString(forIdentifier: code.bcp47) diff --git a/Sources/Shared/Toolkit/Logging/WarningLogger.swift b/Sources/Shared/Toolkit/Logging/WarningLogger.swift index 768656ac79..73e5837612 100644 --- a/Sources/Shared/Toolkit/Logging/WarningLogger.swift +++ b/Sources/Shared/Toolkit/Logging/WarningLogger.swift @@ -49,7 +49,9 @@ public struct JSONWarning: Warning { /// Source JSON object. public let source: Any? public let severity: WarningSeverityLevel - public var tag: String { "json" } + public var tag: String { + "json" + } public var message: String { "JSON \(modelType): \(reason)" diff --git a/Sources/Shared/Toolkit/Media/AudioSession.swift b/Sources/Shared/Toolkit/Media/AudioSession.swift index 9808849e97..cd1f33bfd6 100644 --- a/Sources/Shared/Toolkit/Media/AudioSession.swift +++ b/Sources/Shared/Toolkit/Media/AudioSession.swift @@ -19,7 +19,9 @@ public protocol AudioSessionUser: AnyObject { } public extension AudioSessionUser { - var audioConfiguration: AudioSession.Configuration { .init() } + var audioConfiguration: AudioSession.Configuration { + .init() + } } /// Manages an activated `AVAudioSession`. diff --git a/Sources/Shared/Toolkit/PDF/PDFKit.swift b/Sources/Shared/Toolkit/PDF/PDFKit.swift index b2645ea9b0..156f4fe567 100644 --- a/Sources/Shared/Toolkit/PDF/PDFKit.swift +++ b/Sources/Shared/Toolkit/PDF/PDFKit.swift @@ -14,23 +14,41 @@ import PDFKit /// /// Use `PDFKitPDFDocumentFactory` to create a `PDFDocument` from a `Resource`. extension PDFKit.PDFDocument: PDFDocument { - public func pageCount() async throws -> Int { pageCount } + public func pageCount() async throws -> Int { + pageCount + } - public func identifier() async throws -> String? { try await documentRef?.identifier() } + public func identifier() async throws -> String? { + try await documentRef?.identifier() + } - public func cover() async throws -> UIImage? { try await documentRef?.cover() } + public func cover() async throws -> UIImage? { + try await documentRef?.cover() + } - public func readingProgression() async throws -> ReadingProgression? { try await documentRef?.readingProgression() } + public func readingProgression() async throws -> ReadingProgression? { + try await documentRef?.readingProgression() + } - public func title() async throws -> String? { try await documentRef?.title() } + public func title() async throws -> String? { + try await documentRef?.title() + } - public func author() async throws -> String? { try await documentRef?.author() } + public func author() async throws -> String? { + try await documentRef?.author() + } - public func subject() async throws -> String? { try await documentRef?.subject() } + public func subject() async throws -> String? { + try await documentRef?.subject() + } - public func keywords() async throws -> [String] { try await documentRef?.keywords() ?? [] } + public func keywords() async throws -> [String] { + try await documentRef?.keywords() ?? [] + } - public func tableOfContents() async throws -> [PDFOutlineNode] { try await documentRef?.tableOfContents() ?? [] } + public func tableOfContents() async throws -> [PDFOutlineNode] { + try await documentRef?.tableOfContents() ?? [] + } } /// Creates a `PDFDocument` using PDFKit. diff --git a/Sources/Shared/Toolkit/ReadiumLocalizedString.swift b/Sources/Shared/Toolkit/ReadiumLocalizedString.swift index 9c76a887dd..8b11b587fd 100644 --- a/Sources/Shared/Toolkit/ReadiumLocalizedString.swift +++ b/Sources/Shared/Toolkit/ReadiumLocalizedString.swift @@ -6,26 +6,57 @@ import Foundation -/// Returns the localized string in the main bundle, or fallback on the given bundle if not found. -/// Can be used to override framework localized strings in the host app. -public func ReadiumLocalizedString(_ key: String, in bundle: Bundle, _ values: [CVarArg]) -> String { - let defaultValue = bundle.localizedString(forKey: key, value: nil, table: nil) - var string = Bundle.main.localizedString(forKey: key, value: defaultValue, table: nil) - if !values.isEmpty { - string = String(format: string, locale: Locale.current, arguments: values) - } - return string +/// Returns the localized string for `key`. +public func ReadiumLocalizedString( + _ key: String, + in bundle: Bundle, + table: String? = nil, + _ values: CVarArg... +) -> String { + ReadiumLocalizedString(key, in: bundle, table: table, values) } -public func ReadiumLocalizedString(_ key: String, in bundleID: String, _ values: [CVarArg]) -> String { - let defaultValue = Bundle(identifier: bundleID)?.localizedString(forKey: key, value: nil, table: nil) - var string = Bundle.main.localizedString(forKey: key, value: defaultValue, table: nil) +/// Returns the localized string for `key` with the following lookup order: +/// +/// 1. The host app's main bundle (allows the app to override any Readium +/// string). +/// 2. The given module `bundle` in the user's preferred language. +/// 3. The English localization inside the module `bundle` as a last resort. +public func ReadiumLocalizedString( + _ key: String, + in bundle: Bundle, + table: String? = nil, + _ values: [CVarArg] +) -> String { + let defaultValue = localizedString(forKey: key, in: bundle, table: table) + var string = Bundle.main.localizedString(forKey: key, value: defaultValue, table: table) if !values.isEmpty { - string = String(format: string, locale: Locale.current, arguments: values) + let locale = bundle.preferredLocalizations.first.map(Locale.init(identifier:)) ?? .current + string = String(format: string, locale: locale, arguments: values) } return string } -public func ReadiumLocalizedString(_ key: String, in bundleID: String, _ values: CVarArg...) -> String { - ReadiumLocalizedString(key, in: bundleID, values) +/// Looks up `key` in `bundle` for the user's preferred language, falling +/// back to the English localization when no translation is found. +private func localizedString(forKey key: String, in bundle: Bundle, table: String?) -> String { + let value = bundle.localizedString(forKey: key, value: nil, table: table) + + // `localizedString` returns the key itself when no translation exists. + if value != key { + return value + } + + // Fall back to the English localization bundled with the module. + if + let enPath = bundle.path(forResource: "en", ofType: "lproj"), + let enBundle = Bundle(path: enPath) + { + let enValue = enBundle.localizedString(forKey: key, value: nil, table: table) + if enValue != key { + return enValue + } + } + + return key } diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift index 66ea5a4241..7b93f7bbbb 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift @@ -82,7 +82,9 @@ public extension AbsoluteURL { /// Implements ``URLConvertible``. public extension AbsoluteURL { - var anyURL: AnyURL { .absolute(self) } + var anyURL: AnyURL { + .absolute(self) + } } /// A URL scheme, e.g. http or file. @@ -100,5 +102,7 @@ public struct URLScheme: RawRepresentable, CustomStringConvertible, Hashable, Se self.rawValue = rawValue.lowercased() } - public var description: String { rawValue } + public var description: String { + rawValue + } } diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift index 7e5b81131e..5ca29a03e0 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift @@ -31,7 +31,7 @@ struct UnknownAbsoluteURL: AbsoluteURL, Hashable { /// To ignore this warning, compare `UnknownAbsoluteURL.string` instead of /// `UnknownAbsoluteURL` itself. @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") - public static func == (lhs: UnknownAbsoluteURL, rhs: UnknownAbsoluteURL) -> Bool { + static func == (lhs: UnknownAbsoluteURL, rhs: UnknownAbsoluteURL) -> Bool { lhs.string == rhs.string } } diff --git a/Sources/Shared/Toolkit/URL/AnyURL.swift b/Sources/Shared/Toolkit/URL/AnyURL.swift index 8ad56f5bba..66d863f7b9 100644 --- a/Sources/Shared/Toolkit/URL/AnyURL.swift +++ b/Sources/Shared/Toolkit/URL/AnyURL.swift @@ -84,7 +84,9 @@ public enum AnyURL: URLProtocol { } /// Returns a foundation URL for this ``AnyURL``. - public var url: URL { wrapped.url } + public var url: URL { + wrapped.url + } /// Resolves the `other` URL to this URL, if possible. /// @@ -119,7 +121,9 @@ public enum AnyURL: URLProtocol { /// Implements `URLConvertible`. extension AnyURL: URLConvertible { - public var anyURL: AnyURL { self } + public var anyURL: AnyURL { + self + } } /// Implements `Hashable` and `Equatable`. diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index e362c61e0c..e31c7be7cd 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -124,7 +124,9 @@ public struct RelativeURL: URLProtocol, Hashable { /// Implements `URLConvertible`. extension RelativeURL: URLConvertible { - public var anyURL: AnyURL { .relative(self) } + public var anyURL: AnyURL { + .relative(self) + } } public extension RelativeURL { diff --git a/Sources/Shared/Toolkit/URL/URITemplate.swift b/Sources/Shared/Toolkit/URL/URITemplate.swift index 94f6b072a8..625f65150b 100644 --- a/Sources/Shared/Toolkit/URL/URITemplate.swift +++ b/Sources/Shared/Toolkit/URL/URITemplate.swift @@ -69,5 +69,7 @@ public struct URITemplate: CustomStringConvertible { // MARK: CustomStringConvertible - public var description: String { uri } + public var description: String { + uri + } } diff --git a/Sources/Shared/Toolkit/URL/URLProtocol.swift b/Sources/Shared/Toolkit/URL/URLProtocol.swift index 1a976769c4..392c23d2fe 100644 --- a/Sources/Shared/Toolkit/URL/URLProtocol.swift +++ b/Sources/Shared/Toolkit/URL/URLProtocol.swift @@ -25,7 +25,9 @@ public extension URLProtocol { } /// Returns the string representation for this URL. - var string: String { url.absoluteString } + var string: String { + url.absoluteString + } /// Normalizes the URL using a subset of the RFC-3986 rules. /// https://datatracker.ietf.org/doc/html/rfc3986#section-6 @@ -104,7 +106,9 @@ public extension URLProtocol { /// Returns the decoded query parameters present in this URL, in the order /// they appear. - var query: URLQuery? { URLQuery(url: url) } + var query: URLQuery? { + URLQuery(url: url) + } /// Creates a copy of this URL after removing its query portion. func removingQuery() -> Self { @@ -140,7 +144,9 @@ public extension URLProtocol { /// Implements `CustomStringConvertible` public extension URLProtocol { - var description: String { string } + var description: String { + string + } } private extension String { diff --git a/Sources/Shared/Toolkit/Weak.swift b/Sources/Shared/Toolkit/Weak.swift index 0d8adf5c4c..87d5970b9a 100644 --- a/Sources/Shared/Toolkit/Weak.swift +++ b/Sources/Shared/Toolkit/Weak.swift @@ -12,7 +12,7 @@ import Foundation /// Conveniently, the reference can be reset by setting the `ref` property. @dynamicCallable public class Weak { - // Weakly held reference. + /// Weakly held reference. public weak var ref: T? public init(_ ref: T? = nil) { diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift index 7761bb99e6..3d30e9d6e3 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift @@ -49,8 +49,11 @@ final class MinizipContainer: Container, Loggable { private let file: FileURL private let entriesMetadata: [RelativeURL: MinizipEntryMetadata] - public var sourceURL: AbsoluteURL? { file } - public let entries: Set + var sourceURL: AbsoluteURL? { + file + } + + let entries: Set private init(file: FileURL, entries: [RelativeURL: MinizipEntryMetadata]) { self.file = file @@ -98,7 +101,7 @@ private actor MinizipResource: Resource, Loggable { } } - public let sourceURL: AbsoluteURL? = nil + let sourceURL: AbsoluteURL? = nil func estimatedLength() async -> ReadResult { .success(metadata.length) @@ -149,7 +152,7 @@ private final class MinizipFile { case readFailed } - // Holds an entry's metadata. + /// Holds an entry's metadata. enum Entry { case file(String, length: UInt64, compressedLength: UInt64?) case directory(String) @@ -160,7 +163,9 @@ private final class MinizipFile { /// Information about the currently opened entry. private(set) var openedEntry: (path: String, offset: UInt64)? /// Length of the buffer used when reading an entry's data. - private var bufferLength: Int { 1024 * 32 } + private var bufferLength: Int { + 1024 * 32 + } init?(url: URL) { guard let file = unzOpen64(url.path) else { diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift index 7ab6d3aac4..b762ef786f 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift @@ -42,8 +42,11 @@ final class ZIPFoundationContainer: Container, Loggable { private let archiveFactory: ZIPFoundationArchiveFactory private let entriesByPath: [RelativeURL: Entry] - public var sourceURL: AbsoluteURL? { archiveFactory.sourceURL } - public let entries: Set + var sourceURL: AbsoluteURL? { + archiveFactory.sourceURL + } + + let entries: Set private init( archiveFactory: ZIPFoundationArchiveFactory, @@ -85,7 +88,7 @@ private actor ZIPFoundationResource: Resource, Loggable { self.entry = entry } - public let sourceURL: AbsoluteURL? = nil + let sourceURL: AbsoluteURL? = nil func estimatedLength() async -> ReadResult { .success(entry.uncompressedSize) diff --git a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift index 147104c7ba..051920701b 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift @@ -140,16 +140,14 @@ final class EPUBMetadataParser: Loggable { /// Maps between an element ID and its `display-seq` refine, if there's any. /// eg. 1 - private lazy var displaySeqs: [String: String] = { - metas["display-seq"] - .reduce([:]) { displaySeqs, meta in - var displaySeqs = displaySeqs - if let id = meta.refines { - displaySeqs[id] = meta.content - } - return displaySeqs + private lazy var displaySeqs: [String: String] = metas["display-seq"] + .reduce([:]) { displaySeqs, meta in + var displaySeqs = displaySeqs + if let id = meta.refines { + displaySeqs[id] = meta.content } - }() + return displaySeqs + } private lazy var mainTitleElement: ReadiumFuzi.XMLElement? = titleElements(ofType: .main).first ?? metas["title", in: .dcterms].first?.element @@ -435,17 +433,15 @@ final class EPUBMetadataParser: Loggable { }() /// https://github.com/readium/architecture/blob/master/streamer/parser/metadata.md#collections-and-series - private lazy var belongsToCollections: [Metadata.Collection] = { - metas["belongs-to-collection"] - // `collection-type` should not be "series" - .filter { meta in - if let id = meta.id { - return metas["collection-type", refining: id].first?.content != "series" - } - return true + private lazy var belongsToCollections: [Metadata.Collection] = metas["belongs-to-collection"] + // `collection-type` should not be "series" + .filter { meta in + if let id = meta.id { + return metas["collection-type", refining: id].first?.content != "series" } - .compactMap(collection(from:)) - }() + return true + } + .compactMap(collection(from:)) /// https://github.com/readium/architecture/blob/master/streamer/parser/metadata.md#collections-and-series private lazy var belongsToSeries: [Metadata.Collection] = { @@ -464,7 +460,7 @@ final class EPUBMetadataParser: Loggable { return calibreSeries } - let epub3Series = metas["belongs-to-collection"] + return metas["belongs-to-collection"] // `collection-type` should be "series" .filter { meta in guard let id = meta.id else { @@ -473,8 +469,6 @@ final class EPUBMetadataParser: Loggable { return metas["collection-type", refining: id].first?.content == "series" } .compactMap(collection(from:)) - - return epub3Series }() private func collection(from meta: OPFMeta) -> Metadata.Collection? { diff --git a/Sources/Streamer/Parser/EPUB/OPFMeta.swift b/Sources/Streamer/Parser/EPUB/OPFMeta.swift index f536b99926..1acc4a0fb5 100644 --- a/Sources/Streamer/Parser/EPUB/OPFMeta.swift +++ b/Sources/Streamer/Parser/EPUB/OPFMeta.swift @@ -11,18 +11,18 @@ import ReadiumShared /// Package vocabularies used for `property`, `properties`, `scheme` and `rel`. /// http://www.idpf.org/epub/301/spec/epub-publications.html#sec-metadata-assoc enum OPFVocabulary: String { - // Fallback prefixes for metadata's properties and links' rels. + /// Fallback prefixes for metadata's properties and links' rels. case defaultMetadata, defaultLinkRel - // Reserved prefixes - // https://idpf.github.io/epub-prefixes/packages/ + /// Reserved prefixes + /// https://idpf.github.io/epub-prefixes/packages/ case a11y, dcterms, epubsc, marc, media, onix, rendition, schema, xsd - // Additional prefixes used in the streamer. + /// Additional prefixes used in the streamer. case calibre - // New TDM Reservation Protocol - // https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/ + /// New TDM Reservation Protocol + /// https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/ case tdm var uri: String { diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index f39a31b05b..8062eb8250 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -8,8 +8,8 @@ import Foundation import ReadiumFuzi import ReadiumShared -// http://www.idpf.org/epub/30/spec/epub30-publications.html#title-type -// the six basic values of the "title-type" property specified by EPUB 3: +/// http://www.idpf.org/epub/30/spec/epub30-publications.html#title-type +/// the six basic values of the "title-type" property specified by EPUB 3: public enum EPUBTitleType: String { case main case subtitle diff --git a/Sources/Streamer/Parser/Image/ComicInfoParser.swift b/Sources/Streamer/Parser/Image/ComicInfoParser.swift index 310d042c3c..7df383522f 100644 --- a/Sources/Streamer/Parser/Image/ComicInfoParser.swift +++ b/Sources/Streamer/Parser/Image/ComicInfoParser.swift @@ -13,7 +13,7 @@ import ReadiumShared /// ComicInfo.xml is a metadata format originating from the ComicRack /// application. /// See: https://anansi-project.github.io/docs/comicinfo/documentation -struct ComicInfoParser { +enum ComicInfoParser { /// Parses ComicInfo.xml data and returns the parsed metadata. static func parse(data: Data, warnings: WarningLogger?) -> ComicInfo? { guard let document = try? XMLDocument(data: data) else { @@ -33,8 +33,13 @@ struct ComicInfoParser { /// Warning raised when parsing a ComicInfo.xml file. struct ComicInfoWarning: Warning { let message: String - var severity: WarningSeverityLevel { .minor } - var tag: String { "comicinfo" } + var severity: WarningSeverityLevel { + .minor + } + + var tag: String { + "comicinfo" + } } /// Parsed representation of ComicInfo.xml data. @@ -165,7 +170,6 @@ struct ComicInfo { case "Summary": summary = value case "Title": title = value case "Year": year = Int(value) - // Contributors case "Colorist": colorists = value.splitComma() case "CoverArtist": coverArtists = value.splitComma() @@ -175,7 +179,6 @@ struct ComicInfo { case "Penciller": pencillers = value.splitComma() case "Translator": translators = value.splitComma() case "Writer": writers = value.splitComma() - // Everything else goes to otherMetadata default: otherMetadata[tag] = value } diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index 5d0dc667a2..f2ae7f371e 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -10,13 +10,13 @@ import ReadiumShared /// Errors thrown during the parsing of the PDF. public enum PDFParserError: Error { - // The file at 'path' is missing from the container. + /// The file at 'path' is missing from the container. case missingFile(path: String) - // Failed to open the PDF + /// Failed to open the PDF case openFailed - // The PDF is encrypted with a password. This is not supported right now. + /// The PDF is encrypted with a password. This is not supported right now. case fileEncryptedWithPassword - // The LCP for PDF Package is malformed. + /// The LCP for PDF Package is malformed. case invalidLCPDF } diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index ea8a9439d4..5e93587d84 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -179,5 +179,7 @@ public struct RWPMWarning: Warning { public let message: String public let severity: WarningSeverityLevel - public var tag: String { "rwpm" } + public var tag: String { + "rwpm" + } } diff --git a/Sources/Streamer/Toolkit/DataCompression.swift b/Sources/Streamer/Toolkit/DataCompression.swift index 7808a6fa02..e6b93a33ab 100644 --- a/Sources/Streamer/Toolkit/DataCompression.swift +++ b/Sources/Streamer/Toolkit/DataCompression.swift @@ -1,29 +1,35 @@ -/// -/// DataCompression -/// -/// A libcompression wrapper as an extension for the `Data` type -/// (GZIP, ZLIB, LZFSE, LZMA, LZ4, deflate, RFC-1950, RFC-1951, RFC-1952) -/// -/// Created by Markus Wanke, 2016/12/05 -/// - -/// -/// Apache License, Version 2.0 -/// -/// Copyright 2016, Markus Wanke -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// +// +// 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. +// + +// +// DataCompression +// +// A libcompression wrapper as an extension for the `Data` type +// (GZIP, ZLIB, LZFSE, LZMA, LZ4, deflate, RFC-1950, RFC-1951, RFC-1952) +// +// Created by Markus Wanke, 2016/12/05 +// + +// +// Apache License, Version 2.0 +// +// Copyright 2016, Markus Wanke +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// import Compression import Foundation @@ -210,11 +216,15 @@ public extension Data { } } if has_fname { - while pos < limit, ptr[pos] != 0x0 { pos += 1 } + while pos < limit, ptr[pos] != 0x0 { + pos += 1 + } pos += 1 // skip null byte as well } if has_cmmnt { - while pos < limit, ptr[pos] != 0x0 { pos += 1 } + while pos < limit, ptr[pos] != 0x0 { + pos += 1 + } pos += 1 // skip null byte as well } if has_crc16 { @@ -255,7 +265,7 @@ public struct Crc32: CustomStringConvertible { public init() {} - // C convention function pointer type matching the signature of `libz::crc32` + /// C convention function pointer type matching the signature of `libz::crc32` private typealias ZLibCrc32FuncPtr = @convention(c) ( _ cks: UInt32, _ buf: UnsafePointer, @@ -344,7 +354,7 @@ public struct Adler32: CustomStringConvertible { public init() {} - // C convention function pointer type matching the signature of `libz::adler32` + /// C convention function pointer type matching the signature of `libz::adler32` private typealias ZLibAdler32FuncPtr = @convention(c) ( _ cks: UInt32, _ buf: UnsafePointer, @@ -398,8 +408,7 @@ public struct Adler32: CustomStringConvertible { } private extension Data { - func withUnsafeBytes(_ body: (UnsafePointer) throws -> ResultType) rethrows -> ResultType - { + func withUnsafeBytes(_ body: (UnsafePointer) throws -> ResultType) rethrows -> ResultType { try withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> ResultType in try body(rawBufferPointer.bindMemory(to: ContentType.self).baseAddress!) } @@ -419,8 +428,7 @@ private extension Data.CompressionAlgorithm { private typealias Config = (operation: compression_stream_operation, algorithm: compression_algorithm) -private func perform(_ config: Config, source: UnsafePointer, sourceSize: Int, preload: Data = Data()) -> Data? -{ +private func perform(_ config: Config, source: UnsafePointer, sourceSize: Int, preload: Data = Data()) -> Data? { guard config.operation == COMPRESSION_STREAM_ENCODE || sourceSize > 0 else { return nil } let streamBase = UnsafeMutablePointer.allocate(capacity: 1) diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index a7c6eba878..b8989a75b5 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -239,6 +239,7 @@ extension ReadiumNavigator.TTSError: UserErrorConvertible { switch error { case let .cancelled(date): return "lcp_error_status_cancelled".localized(dateFormatter.string(from: date)) + case let .returned(date): return "lcp_error_status_returned".localized(dateFormatter.string(from: date)) diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index 44d90ac125..8c3888cbc5 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -8,7 +8,7 @@ import Combine import ReadiumShared import UIKit -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? diff --git a/TestApp/Sources/Common/UserError.swift b/TestApp/Sources/Common/UserError.swift index 20f7ece207..4797e5f01e 100644 --- a/TestApp/Sources/Common/UserError.swift +++ b/TestApp/Sources/Common/UserError.swift @@ -38,7 +38,9 @@ struct UserError: LocalizedError { self.init(message(), cause: cause) } - var errorDescription: String? { message } + var errorDescription: String? { + message + } } /// Convenience protocol for an object (usually an ``Error``) that can be converted diff --git a/TestApp/Sources/Data/Book.swift b/TestApp/Sources/Data/Book.swift index 2929de5d2f..aaac6c1157 100644 --- a/TestApp/Sources/Data/Book.swift +++ b/TestApp/Sources/Data/Book.swift @@ -39,7 +39,9 @@ struct Book: Codable { /// reading progression, spreads). var preferencesJSON: String? - var mediaType: MediaType { MediaType(type) ?? .binary } + var mediaType: MediaType { + MediaType(type) ?? .binary + } init( id: Id? = nil, diff --git a/TestApp/Sources/Data/Bookmark.swift b/TestApp/Sources/Data/Bookmark.swift index 1bc5ba0c06..6c0040cac8 100644 --- a/TestApp/Sources/Data/Bookmark.swift +++ b/TestApp/Sources/Data/Bookmark.swift @@ -66,5 +66,5 @@ final class BookmarkRepository { } } -// for the default SwiftUI support +/// for the default SwiftUI support extension Bookmark: Hashable {} diff --git a/TestApp/Sources/Data/Database.swift b/TestApp/Sources/Data/Database.swift index 79a7c7025b..0d49400581 100644 --- a/TestApp/Sources/Data/Database.swift +++ b/TestApp/Sources/Data/Database.swift @@ -76,12 +76,11 @@ final class Database { @discardableResult func write(_ updates: @escaping (GRDB.Database) throws -> T) async throws -> T { try await withCheckedThrowingContinuation { cont in - writer.asyncWrite( - { try updates($0) }, - completion: { _, result in - cont.resume(with: result) - } - ) + writer.asyncWrite { + try updates($0) + } completion: { _, result in + cont.resume(with: result) + } } } @@ -137,7 +136,9 @@ extension EntityId { // MARK: - DatabaseValueConvertible - var databaseValue: DatabaseValue { rawValue.databaseValue } + var databaseValue: DatabaseValue { + rawValue.databaseValue + } static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Self? { Int64.fromDatabaseValue(dbValue).map(Self.init) diff --git a/TestApp/Sources/Data/Highlight.swift b/TestApp/Sources/Data/Highlight.swift index 4bdb1d96a4..06b4aeb436 100644 --- a/TestApp/Sources/Data/Highlight.swift +++ b/TestApp/Sources/Data/Highlight.swift @@ -118,5 +118,5 @@ final class HighlightRepository { } } -// for the default SwiftUI support +/// for the default SwiftUI support extension Highlight: Hashable {} diff --git a/TestApp/Sources/Library/LibraryViewController.swift b/TestApp/Sources/Library/LibraryViewController.swift index 0bde055d36..a5149b304e 100644 --- a/TestApp/Sources/Library/LibraryViewController.swift +++ b/TestApp/Sources/Library/LibraryViewController.swift @@ -199,11 +199,11 @@ extension LibraryViewController { // MARK: - UIDocumentPickerDelegate. extension LibraryViewController: UIDocumentPickerDelegate { - public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { importFiles(at: urls) } - public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { importFiles(at: [url]) } @@ -266,7 +266,7 @@ extension LibraryViewController: UICollectionViewDelegateFlowLayout, UICollectio return cell } - internal func defaultCover(layout: UICollectionViewFlowLayout?, description: String) -> UITextView { + func defaultCover(layout: UICollectionViewFlowLayout?, description: String) -> UITextView { let width = layout?.itemSize.width ?? 0 let height = layout?.itemSize.height ?? 0 let titleTextView = UITextView(frame: CGRect(x: 0, y: 0, width: width, height: height)) @@ -352,8 +352,8 @@ extension LibraryViewController: PublicationCollectionViewCellDelegate { } } - // Used to reset ui of the last flipped cell, we must not have two cells - // flipped at the same time + /// Used to reset ui of the last flipped cell, we must not have two cells + /// flipped at the same time func cellFlipped(_ cell: PublicationCollectionViewCell) { lastFlippedCell?.flipMenu() lastFlippedCell = cell diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift index 62c0097d88..6669f10ff0 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift @@ -70,7 +70,6 @@ struct OPDSFeedView: View { } } - @ViewBuilder private var mainContent: some View { Group { // If the feed is only publications, show a grid. @@ -107,7 +106,6 @@ struct OPDSFeedView: View { } } - @ViewBuilder private func buildFacetView() -> some View { OPDSFacetView(facets: viewModel.feed?.facets ?? []) { link in if let url = URL(string: link.href) { @@ -118,7 +116,6 @@ struct OPDSFeedView: View { // MARK: - List View Builders - @ViewBuilder private func buildListView() -> some View { ScrollView { LazyVStack(spacing: 0) { @@ -212,7 +209,6 @@ struct OPDSFeedView: View { buildNavigationList(navigation, isRootList: true) } - @ViewBuilder private func buildGroupsSection(_ groups: [ReadiumShared.Group]) -> some View { ForEach(Array(groups.enumerated()), id: \.element.metadata.title) { _, group in HStack { @@ -254,7 +250,6 @@ struct OPDSFeedView: View { } } - @ViewBuilder private func buildNavigationList(_ navigation: [ReadiumShared.Link], isRootList: Bool) -> some View { ForEach(navigation.indices, id: \.self) { index in let link = navigation[index] diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift index a0f69868ce..885cfa91f1 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift @@ -15,7 +15,6 @@ struct OPDSNavigationRow: View { rowContent } - @ViewBuilder private var rowContent: some View { HStack { Text(link.title ?? "Untitled") diff --git a/TestApp/Sources/OPDS/OPDSPlaceholderView.swift b/TestApp/Sources/OPDS/OPDSPlaceholderView.swift index a07bcac19e..fc513eaffa 100644 --- a/TestApp/Sources/OPDS/OPDSPlaceholderView.swift +++ b/TestApp/Sources/OPDS/OPDSPlaceholderView.swift @@ -42,7 +42,7 @@ class OPDSPlaceholderListView: OPDSPlaceholderView, Placeholder {} // MARK: - Placeholder protocol specific to publication screen extension OPDSPlaceholderPublicationView { - public func add(to imageView: KFCrossPlatformImageView) { + func add(to imageView: KFCrossPlatformImageView) { imageView.addSubview(self) translatesAutoresizingMaskIntoConstraints = false diff --git a/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift b/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift index 9baa50b959..3b76ffd8f7 100644 --- a/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift +++ b/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift @@ -29,7 +29,7 @@ import UIKit @IBOutlet var renewButton: UIButton! @IBOutlet var returnButton: UIButton! - public var viewModel: LCPViewModel! + var viewModel: LCPViewModel! weak var moduleDelegate: ReaderModuleDelegate? @@ -99,7 +99,7 @@ import UIKit present(alert, animated: true) } - internal func reload() { + func reload() { typeLabel.text = "Readium LCP" stateLabel.text = viewModel.state providerLabel.text = viewModel.provider diff --git a/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift b/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift index b0cbef7d81..64fe706967 100644 --- a/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift +++ b/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift @@ -24,7 +24,7 @@ struct OutlineTableView: View { @ObservedObject private var highlightsModel: HighlightsViewModel @State private var selectedSection: OutlineSection = .tableOfContents - // Outlines (list of links) to display for each section. + /// Outlines (list of links) to display for each section. @State private var outlines: [OutlineSection: [(level: Int, link: ReadiumShared.Link)]] = [:] init(publication: Publication, bookId: Book.Id, bookmarkRepository: BookmarkRepository, highlightRepository: HighlightRepository) { @@ -78,6 +78,7 @@ struct OutlineTableView: View { } } .onAppear { bookmarksModel.loadIfNeeded() } + case .highlights: List(highlightsModel.highlights, id: \.self) { highlight in HighlightCellView(highlight: highlight) diff --git a/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift b/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift index 6d6eeab416..7222369086 100644 --- a/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift +++ b/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift @@ -85,7 +85,7 @@ private protocol OutlineViewModelLoaderDelegate: AnyObject { func setLoadedValues(_ values: [T]) } -// This loader contains a state enum which can be used for expressive UI (loading progress, error handling etc). For this, status overlay view can be used (see https://stackoverflow.com/a/61858358/2567725). +/// This loader contains a state enum which can be used for expressive UI (loading progress, error handling etc). For this, status overlay view can be used (see https://stackoverflow.com/a/61858358/2567725). private final class OutlineViewModelLoader { weak var delegate: Delegate! private var state = State.ready diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index 6121a49db9..cf8a090dbc 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -72,7 +72,7 @@ struct UserPreferences< userPreferences(editor: model.editor, commit: model.commit) } - @ViewBuilder func userPreferences(editor: PE, commit: @escaping () -> Void) -> some View { + func userPreferences(editor: PE, commit: @escaping () -> Void) -> some View { NavigationView { List { switch editor { @@ -608,7 +608,7 @@ struct UserPreferences< } /// User preferences screen for an audiobook. - @ViewBuilder func audioUserPreferences( + func audioUserPreferences( commit: @escaping () -> Void, volume: AnyRangePreference? = nil, speed: AnyRangePreference? = nil @@ -633,7 +633,7 @@ struct UserPreferences< } /// Component for a boolean `Preference` switchable with a `Toggle` button. - @ViewBuilder func toggleRow( + func toggleRow( title: String, preference: AnyPreference, commit: @escaping () -> Void @@ -647,7 +647,7 @@ struct UserPreferences< } /// Component for a boolean `Preference` switchable with a `Toggle` button. - @ViewBuilder func toggleRow( + func toggleRow( title: String, value: Binding, isActive: Bool, @@ -663,7 +663,7 @@ struct UserPreferences< /// Component for a nullable boolean `Preference` displayed in a `Picker` view /// with three options: Auto, Yes, No. - @ViewBuilder func nullableBoolPickerRow( + func nullableBoolPickerRow( title: String, preference: AnyPreference, commit: @escaping () -> Void @@ -684,7 +684,7 @@ struct UserPreferences< } /// Component for an `EnumPreference` displayed in a `Picker` view. - @ViewBuilder func pickerRow( + func pickerRow( title: String, preference: AnyEnumPreference, commit: @escaping () -> Void, @@ -701,7 +701,7 @@ struct UserPreferences< } /// Component for an `EnumPreference` displayed in a `Picker` view. - @ViewBuilder func pickerRow( + func pickerRow( title: String, value: Binding, values: [V], @@ -722,7 +722,7 @@ struct UserPreferences< } /// Component for a `RangePreference` modifiable by a `Stepper` view. - @ViewBuilder func stepperRow( + func stepperRow( title: String, preference: AnyRangePreference, commit: @escaping () -> Void @@ -738,7 +738,7 @@ struct UserPreferences< } /// Component for a `RangePreference` modifiable by a `Stepper` view. - @ViewBuilder func stepperRow( + func stepperRow( title: String, value: String, isActive: Bool, @@ -762,7 +762,7 @@ struct UserPreferences< } /// Component for a `Preference` holding a `Language` value. - @ViewBuilder func languageRow( + func languageRow( title: String, preference: AnyPreference, commit: @escaping () -> Void @@ -783,7 +783,7 @@ struct UserPreferences< } /// Component for a `Preference` holding a `Color` value. - @ViewBuilder func colorRow( + func colorRow( title: String, preference: AnyPreference, commit: @escaping () -> Void @@ -803,7 +803,7 @@ struct UserPreferences< } /// Component for a `Preference` holding a `Color` value. - @ViewBuilder func colorRow( + func colorRow( title: String, value: Binding, isActive: Bool, @@ -820,7 +820,7 @@ struct UserPreferences< } /// Layout for a preference row. - @ViewBuilder func preferenceRow( + func preferenceRow( isActive: Bool, onClear: @escaping () -> Void, content: @escaping () -> V diff --git a/TestApp/Sources/Reader/Common/ReaderViewController.swift b/TestApp/Sources/Reader/Common/ReaderViewController.swift index 735cd42154..921045f8ef 100644 --- a/TestApp/Sources/Reader/Common/ReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/ReaderViewController.swift @@ -227,7 +227,7 @@ class ReaderViewController: UIViewController, // MARK: - UIPopoverPresentationControllerDelegate - // Prevent the popOver to be presented fullscreen on iPhones. + /// Prevent the popOver to be presented fullscreen on iPhones. func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { .none } diff --git a/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift b/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift index a7ab9724b5..fd2de49d27 100644 --- a/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift +++ b/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift @@ -8,21 +8,21 @@ import Foundation import ReadiumOPDS import ReadiumShared -// See https://github.com/readium/r2-testapp-swift/discussions/402 +/// See https://github.com/readium/r2-testapp-swift/discussions/402 @MainActor final class SearchViewModel: ObservableObject { enum State { - // Empty state / waiting for a search query + /// Empty state / waiting for a search query case empty - // Starting a new search, after calling `publication.search(...)` + /// Starting a new search, after calling `publication.search(...)` case starting - // Waiting state after receiving a SearchIterator and waiting for a next() call + /// Waiting state after receiving a SearchIterator and waiting for a next() call case idle(SearchIterator) - // Loading the next page of result + /// Loading the next page of result case loadingNext(SearchIterator, Task) - // We reached the end of the search results + /// We reached the end of the search results case end - // An error occurred, we need to show it to the user + /// An error occurred, we need to show it to the user case failure(SearchError) } diff --git a/TestApp/Sources/Reader/Common/TTS/TTSView.swift b/TestApp/Sources/Reader/Common/TTS/TTSView.swift index 3b9bb9df6f..43f00b81c1 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSView.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSView.swift @@ -95,7 +95,7 @@ struct TTSSettings: View { .navigationViewStyle(.stack) } - @ViewBuilder private func picker( + private func picker( caption: String, for keyPath: WritableKeyPath, choices: [T], diff --git a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift index 3a0635594c..e8da6f9a3e 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift @@ -174,7 +174,7 @@ final class TTSViewModel: ObservableObject, Loggable { } extension TTSViewModel: PublicationSpeechSynthesizerDelegate { - public func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, stateDidChange synthesizerState: PublicationSpeechSynthesizer.State) { + func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, stateDidChange synthesizerState: PublicationSpeechSynthesizer.State) { switch synthesizerState { case .stopped: state.showControls = false @@ -197,7 +197,7 @@ extension TTSViewModel: PublicationSpeechSynthesizerDelegate { } } - public func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, utterance: PublicationSpeechSynthesizer.Utterance, didFailWithError error: PublicationSpeechSynthesizer.Error) { + func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, utterance: PublicationSpeechSynthesizer.Utterance, didFailWithError error: PublicationSpeechSynthesizer.Error) { // FIXME: log(.error, error) } diff --git a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift index 7a0cb2ef48..747f52ebec 100644 --- a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift @@ -76,11 +76,11 @@ class VisualReaderViewController: ReaderViewCon // return false // }) - /// This adapter will automatically turn pages when the user taps the - /// screen edges or press arrow keys. - /// - /// Bind it to the navigator before adding your own observers to prevent - /// triggering your actions when turning pages. + // This adapter will automatically turn pages when the user taps the + // screen edges or press arrow keys. + // + // Bind it to the navigator before adding your own observers to prevent + // triggering your actions when turning pages. DirectionalNavigationAdapter( pointerPolicy: .init(types: [.mouse, .touch]) ).bind(to: navigator) diff --git a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift index 63713d0403..1e90d2b4c0 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift @@ -12,7 +12,7 @@ import UIKit import WebKit public extension FontFamily { - // Example of adding a custom font embedded in the application. + /// Example of adding a custom font embedded in the application. static let literata: FontFamily = "Literata" } diff --git a/TestApp/Sources/Reader/ReaderFactory.swift b/TestApp/Sources/Reader/ReaderFactory.swift index 70b62be01e..147cfa9bc8 100644 --- a/TestApp/Sources/Reader/ReaderFactory.swift +++ b/TestApp/Sources/Reader/ReaderFactory.swift @@ -47,7 +47,7 @@ extension ReaderFactory: OutlineTableViewControllerFactory { /// This is a wrapper for the "OutlineTableView" to encapsulate the "Cancel" button behaviour class OutlineHostingController: UIHostingController { - override public init(rootView: OutlineTableView) { + override init(rootView: OutlineTableView) { super.init(rootView: rootView) navigationItem.setLeftBarButton(UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed)), animated: true) } diff --git a/Tests/InternalTests/Extensions/URLTests.swift b/Tests/InternalTests/Extensions/URLTests.swift index 25bc250099..ea30e49309 100644 --- a/Tests/InternalTests/Extensions/URLTests.swift +++ b/Tests/InternalTests/Extensions/URLTests.swift @@ -10,12 +10,12 @@ import XCTest class URLTests: XCTestCase { func testAddingSchemeWhenMissing() { XCTAssertEqual( - URL(string: "//www.google.com/path")!.addingSchemeWhenMissing("test"), - URL(string: "test://www.google.com/path")! + URL(string: "//www.google.com/path")?.addingSchemeWhenMissing("test"), + URL(string: "test://www.google.com/path") ) XCTAssertEqual( - URL(string: "http://www.google.com/path")!.addingSchemeWhenMissing("test"), - URL(string: "http://www.google.com/path")! + URL(string: "http://www.google.com/path")?.addingSchemeWhenMissing("test"), + URL(string: "http://www.google.com/path") ) } } diff --git a/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift b/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift index d65405fa5f..d163169677 100644 --- a/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift +++ b/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift @@ -34,7 +34,7 @@ class LCPDecryptorTests: XCTestCase { } /// Checks that we can decrypt the full content successfully. - func testDecryptFull() throws { + func testDecryptFull() { retrieveLicense(path: "daisy.lcpdf", passphrase: "test") { license in let decryptedResource = LCPDecryptor(license: license).decrypt(resource: self.encryptedResource) @@ -43,7 +43,7 @@ class LCPDecryptorTests: XCTestCase { } /// Checks that we can decrypt various ranges successfully. - func testDecryptRanges() throws { + func testDecryptRanges() { retrieveLicense(path: "daisy.lcpdf", passphrase: "test") { license in let decryptedResource = LCPDecryptor(license: license).decrypt(resource: self.encryptedResource) diff --git a/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift b/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift index 5152fa7dd7..94340e3018 100644 --- a/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift +++ b/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift @@ -381,6 +381,33 @@ import Testing #expect(rights.print == 90) } + // MARK: - Clear Tests + + @Test func clearRemovesAllLicenses() async throws { + defer { try? cleanupAllTestData() } + + let license1 = try createTestLicenseDocument(id: "clear-1") + let license2 = try createTestLicenseDocument(id: "clear-2") + let license3 = try createTestLicenseDocument(id: "clear-3") + + try await repository.addLicense(license1) + try await repository.addLicense(license2) + try await repository.addLicense(license3) + + try await repository.clear() + + // Verify all licenses are gone + #expect(try await repository.license(for: "clear-1") == nil) + #expect(try await repository.license(for: "clear-2") == nil) + #expect(try await repository.license(for: "clear-3") == nil) + } + + @Test func clearOnEmptyRepositorySucceeds() async throws { + defer { try? cleanupAllTestData() } + + try await repository.clear() + } + // MARK: - Multiple License Tests @Test func multipleLicenses() async throws { diff --git a/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift b/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift index bc1f8c42c1..e4fc09ec44 100644 --- a/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift +++ b/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift @@ -263,6 +263,39 @@ import Testing } } + // MARK: - Clear Tests + + @Test func clearRemovesAllPassphrases() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-1", + for: "license-clear-1", + userID: "user-1", + provider: "https://provider.com" + ) + try await repository.addPassphrase( + "hash-2", + for: "license-clear-2", + userID: "user-2", + provider: "https://provider.com" + ) + + try await repository.clear() + + // Verify all passphrases are gone + #expect(try await repository.passphrase(for: "license-clear-1") == nil) + #expect(try await repository.passphrase(for: "license-clear-2") == nil) + let all = try await repository.passphrases() + #expect(all.isEmpty) + } + + @Test func clearOnEmptyRepositorySucceeds() async throws { + defer { try? cleanupAllTestData() } + + try await repository.clear() + } + // MARK: - Special Characters Tests @Test func passphraseWithSpecialCharacters() async throws { diff --git a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift index 21a52d7bb5..1a2d62a0e9 100644 --- a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift +++ b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift @@ -9,22 +9,22 @@ import XCTest class PublicationMediaLoaderTests: XCTestCase { func testURLToHREF() { - XCTAssertEqual(URL(string: "readium:relative/file.mp3")!.audioHREF!.string, "relative/file.mp3") - XCTAssertEqual(URL(string: "readium:/absolute/file.mp3")!.audioHREF!.string, "/absolute/file.mp3") - XCTAssertEqual(URL(string: "readiumfile:///directory/file.mp3")!.audioHREF!.string, "file:///directory/file.mp3") - XCTAssertEqual(URL(string: "readiumhttp:///domain.com/file.mp3")!.audioHREF!.string, "http:///domain.com/file.mp3") - XCTAssertEqual(URL(string: "readiumhttps:///domain.com/file.mp3")!.audioHREF!.string, "https:///domain.com/file.mp3") + XCTAssertEqual(URL(string: "readium:relative/file.mp3")?.audioHREF?.string, "relative/file.mp3") + XCTAssertEqual(URL(string: "readium:/absolute/file.mp3")?.audioHREF?.string, "/absolute/file.mp3") + XCTAssertEqual(URL(string: "readiumfile:///directory/file.mp3")?.audioHREF?.string, "file:///directory/file.mp3") + XCTAssertEqual(URL(string: "readiumhttp:///domain.com/file.mp3")?.audioHREF?.string, "http:///domain.com/file.mp3") + XCTAssertEqual(URL(string: "readiumhttps:///domain.com/file.mp3")?.audioHREF?.string, "https:///domain.com/file.mp3") // Encoded characters - XCTAssertEqual(URL(string: "readium:relative/a%20file.mp3")!.audioHREF!.string, "relative/a%20file.mp3") - XCTAssertEqual(URL(string: "readium:/absolute/a%20file.mp3")!.audioHREF!.string, "/absolute/a%20file.mp3") - XCTAssertEqual(URL(string: "readiumfile:///directory/a%20file.mp3")!.audioHREF!.string, "file:///directory/a%20file.mp3") - XCTAssertEqual(URL(string: "readiumhttp:///domain.com/a%20file.mp3")!.audioHREF!.string, "http:///domain.com/a%20file.mp3") - XCTAssertEqual(URL(string: "readiumhttps:///domain.com/a%20file.mp3")!.audioHREF!.string, "https:///domain.com/a%20file.mp3") + XCTAssertEqual(URL(string: "readium:relative/a%20file.mp3")?.audioHREF?.string, "relative/a%20file.mp3") + XCTAssertEqual(URL(string: "readium:/absolute/a%20file.mp3")?.audioHREF?.string, "/absolute/a%20file.mp3") + XCTAssertEqual(URL(string: "readiumfile:///directory/a%20file.mp3")?.audioHREF?.string, "file:///directory/a%20file.mp3") + XCTAssertEqual(URL(string: "readiumhttp:///domain.com/a%20file.mp3")?.audioHREF?.string, "http:///domain.com/a%20file.mp3") + XCTAssertEqual(URL(string: "readiumhttps:///domain.com/a%20file.mp3")?.audioHREF?.string, "https:///domain.com/a%20file.mp3") // Ignores if the r2 prefix is missing. - XCTAssertNil(URL(string: "relative/file.mp3")!.audioHREF) - XCTAssertNil(URL(string: "file:///directory/file.mp3")!.audioHREF) - XCTAssertNil(URL(string: "http:///domain.com/file.mp3")!.audioHREF) + XCTAssertNil(URL(string: "relative/file.mp3")?.audioHREF) + XCTAssertNil(URL(string: "file:///directory/file.mp3")?.audioHREF) + XCTAssertNil(URL(string: "http:///domain.com/file.mp3")?.audioHREF) } } diff --git a/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift b/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift index b292acf638..874a38304b 100644 --- a/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift +++ b/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift @@ -27,7 +27,7 @@ class HTMLElementTests: XCTestCase { XCTAssertEqual(body.locate(.start, in: html), nil) } - func testLocateStart() { + func testLocateStart() throws { let html = """ @@ -37,12 +37,12 @@ class HTMLElementTests: XCTestCase { """ - let target = html.firstIndex(of: "📍")! + let target = try XCTUnwrap(html.firstIndex(of: "📍")) XCTAssertEqual(body.locate(.start, in: html), target) } - func testLocateStartIsCaseInsensitive() { + func testLocateStartIsCaseInsensitive() throws { let html = """ @@ -52,12 +52,12 @@ class HTMLElementTests: XCTestCase { """ - let target = html.firstIndex(of: "📍")! + let target = try XCTUnwrap(html.firstIndex(of: "📍")) XCTAssertEqual(body.locate(.start, in: html), target) } - func testLocateStartIgnoresAttributesAndNewlines() { + func testLocateStartIgnoresAttributesAndNewlines() throws { let html = """ @@ -69,12 +69,12 @@ class HTMLElementTests: XCTestCase { """ - let target = html.firstIndex(of: "📍")! + let target = try XCTUnwrap(html.firstIndex(of: "📍")) XCTAssertEqual(body.locate(.start, in: html), target) } - func testLocateEnd() { + func testLocateEnd() throws { let html = """ @@ -84,13 +84,13 @@ class HTMLElementTests: XCTestCase { 📍 """ - let target = html.firstIndex(of: "📍") - .map { html.index($0, offsetBy: 1) }! + let target = try XCTUnwrap(html.firstIndex(of: "📍") + .map { html.index($0, offsetBy: 1) }) XCTAssertEqual(body.locate(.end, in: html), target) } - func testLocateEndIsCaseInsensitive() { + func testLocateEndIsCaseInsensitive() throws { let html = """ @@ -100,13 +100,13 @@ class HTMLElementTests: XCTestCase { 📍 """ - let target = html.firstIndex(of: "📍") - .map { html.index($0, offsetBy: 1) }! + let target = try XCTUnwrap(html.firstIndex(of: "📍") + .map { html.index($0, offsetBy: 1) }) XCTAssertEqual(body.locate(.end, in: html), target) } - func testLocateEndIgnoresWhitespaces() { + func testLocateEndIgnoresWhitespaces() throws { let html = """ @@ -117,8 +117,8 @@ class HTMLElementTests: XCTestCase { > """ - let target = html.firstIndex(of: "📍") - .map { html.index($0, offsetBy: 1) }! + let target = try XCTUnwrap(html.firstIndex(of: "📍") + .map { html.index($0, offsetBy: 1) }) XCTAssertEqual(body.locate(.end, in: html), target) } diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift index 43320747bc..2e014ac04c 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift @@ -39,7 +39,9 @@ struct ReaderView: View { } @MainActor final class ReaderViewModel: ObservableObject, Identifiable { - nonisolated var id: ObjectIdentifier { ObjectIdentifier(self) } + nonisolated var id: ObjectIdentifier { + ObjectIdentifier(self) + } let navigator: VisualNavigator & UIViewController diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift index e84842b068..118b3f728a 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift @@ -36,10 +36,6 @@ class ReaderViewController: UIViewController { struct ReaderViewControllerWrapper: UIViewControllerRepresentable { let navigator: VisualNavigator & UIViewController - init(navigator: VisualNavigator & UIViewController) { - self.navigator = navigator - } - func makeUIViewController(context: Context) -> ReaderViewController { ReaderViewController(navigator: navigator) } diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift index 089e7eb2f7..a320ae2c64 100644 --- a/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift @@ -21,25 +21,25 @@ final class MemoryLeakTests: XCTestCase { app.launch() } - func testEPUBNavigatorDeallocatesAfterClosing() throws { + func testEPUBNavigatorDeallocatesAfterClosing() { app .open(.childrensLiteratureEPUB, waitUntilReady: true) .close(assertMemoryDeallocated: true) } - func testEPUBNavigatorDeallocatesAfterClosingBeforeReady() throws { + func testEPUBNavigatorDeallocatesAfterClosingBeforeReady() { app .open(.childrensLiteratureEPUB, waitUntilReady: false) .close(assertMemoryDeallocated: true) } - func testPDFNavigatorDeallocatesAfterClosing() throws { + func testPDFNavigatorDeallocatesAfterClosing() { app .open(.daisyPDF, waitUntilReady: true) .close(assertMemoryDeallocated: true) } - func testPDFNavigatorDeallocatesAfterClosingBeforeReady() throws { + func testPDFNavigatorDeallocatesAfterClosingBeforeReady() { app .open(.daisyPDF, waitUntilReady: false) .close(assertMemoryDeallocated: true) diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift index 5ae97ea7bd..4ab5ca6803 100644 --- a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift @@ -42,10 +42,6 @@ extension XCUIApplication { struct ReaderUI { let app: XCUIApplication - init(app: XCUIApplication) { - self.app = app - } - /// Activates the Close button. @discardableResult func close(assertMemoryDeallocated: Bool = true) -> XCUIApplication { diff --git a/Tests/OPDSTests/readium_opds1_1_test.swift b/Tests/OPDSTests/readium_opds1_1_test.swift index 556c74c4ce..93fc63c967 100644 --- a/Tests/OPDSTests/readium_opds1_1_test.swift +++ b/Tests/OPDSTests/readium_opds1_1_test.swift @@ -4,11 +4,9 @@ // available in the top-level LICENSE file of the project. // -import XCTest - -import ReadiumShared - @testable import ReadiumOPDS +import ReadiumShared +import XCTest #if !SWIFT_PACKAGE extension Bundle { @@ -46,45 +44,45 @@ class readium_opds1_1_test: XCTestCase { } func testMetadata() { - XCTAssert(feed!.metadata.identifier == "urn:uuid:433a5d6a-0b8c-4933-af65-4ca4f02763eb") - XCTAssert(feed!.metadata.title == "Unpopular Publications") + XCTAssert(feed?.metadata.identifier == "urn:uuid:433a5d6a-0b8c-4933-af65-4ca4f02763eb") + XCTAssert(feed?.metadata.title == "Unpopular Publications") // TODO: add more tests... } - func testLinks() { + func testLinks() throws { XCTAssertEqual(feed.links.count, 5) // Has a "related" link - let expectedRelatedLink = Link( + let expectedRelatedLink = try Link( href: "http://test.com/opds-catalogs/vampire.farming.xml", - mediaType: MediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")!, + mediaType: XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")), rels: ["related"] ) let relatedLink = feed?.links.first { $0.rels.contains("related") } XCTAssertEqual(relatedLink, expectedRelatedLink) // Has a "self" link - let expectedSelfLink = Link( + let expectedSelfLink = try Link( href: "http://test.com/opds-catalogs/unpopular.xml", - mediaType: MediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")!, + mediaType: XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")), rels: ["self"] ) let selfLink = feed?.links.first { $0.rels.contains("self") } XCTAssertEqual(selfLink, expectedSelfLink) // Has a "start" link - let expectedStartLink = Link( + let expectedStartLink = try Link( href: "http://test.com/opds-catalogs/root.xml", - mediaType: MediaType("application/atom+xml;profile=opds-catalog;kind=navigation")!, + mediaType: XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog;kind=navigation")), rels: ["start"] ) let startLink = feed?.links.first { $0.rels.contains("start") } XCTAssertEqual(startLink, expectedStartLink) // Has an "up" link - let expectedUpLink = Link( + let expectedUpLink = try Link( href: "http://test.com/opds-catalogs/root.xml", - mediaType: MediaType("application/atom+xml;profile=opds-catalog;kind=navigation")!, + mediaType: XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog;kind=navigation")), rels: ["up"] ) let upLink = feed?.links.first { $0.rels.contains("up") } diff --git a/Tests/OPDSTests/readium_opds2_0_test.swift b/Tests/OPDSTests/readium_opds2_0_test.swift index c8a1802f98..854ae153a1 100644 --- a/Tests/OPDSTests/readium_opds2_0_test.swift +++ b/Tests/OPDSTests/readium_opds2_0_test.swift @@ -4,11 +4,10 @@ // available in the top-level LICENSE file of the project. // +@testable import ReadiumOPDS import ReadiumShared import XCTest -@testable import ReadiumOPDS - class readium_opds2_0_test: XCTestCase { var feed: Feed? diff --git a/Tests/SharedTests/Publication/HREFNormalizerTests.swift b/Tests/SharedTests/Publication/HREFNormalizerTests.swift index 1011b350c3..116f594171 100644 --- a/Tests/SharedTests/Publication/HREFNormalizerTests.swift +++ b/Tests/SharedTests/Publication/HREFNormalizerTests.swift @@ -85,7 +85,7 @@ class HREFNormalizerTests: XCTestCase { func testNormalizeManifestHREFsToBaseURL() throws { var sut = manifest - try sut.normalizeHREFs(to: AnyURL(string: "https://other/dir/")!) + try sut.normalizeHREFs(to: XCTUnwrap(AnyURL(string: "https://other/dir/"))) XCTAssertEqual( sut, @@ -149,7 +149,7 @@ class HREFNormalizerTests: XCTestCase { ), ] ) - try sut.normalizeHREFs(to: AnyURL(string: "https://other/dir/")!) + try sut.normalizeHREFs(to: XCTUnwrap(AnyURL(string: "https://other/dir/"))) XCTAssertEqual( sut, diff --git a/Tests/SharedTests/Publication/LinkArrayTests.swift b/Tests/SharedTests/Publication/LinkArrayTests.swift index 383841b5f4..d8cc24fc5a 100644 --- a/Tests/SharedTests/Publication/LinkArrayTests.swift +++ b/Tests/SharedTests/Publication/LinkArrayTests.swift @@ -49,37 +49,37 @@ class LinkArrayTests: XCTestCase { } /// Finds the first `Link` with given `href`. - func testFirstWithHREF() { + func testFirstWithHREF() throws { let links = [ Link(href: "l1"), Link(href: "l2"), Link(href: "l2", rel: "test"), ] - XCTAssertEqual(links.firstWithHREF(AnyURL(string: "l2")!), Link(href: "l2")) + XCTAssertEqual(try links.firstWithHREF(XCTUnwrap(AnyURL(string: "l2"))), Link(href: "l2")) } /// Finds the first `Link` with given `href` when none is found. - func testFirstWithHREFNotFound() { + func testFirstWithHREFNotFound() throws { let links = [Link(href: "l1")] - XCTAssertNil(links.firstWithHREF(AnyURL(string: "unknown")!)) + XCTAssertNil(try links.firstWithHREF(XCTUnwrap(AnyURL(string: "unknown")))) } /// Finds the index of the first `Link` with given `href`. - func testFirstIndexWithHREF() { + func testFirstIndexWithHREF() throws { let links = [ Link(href: "l1"), Link(href: "l2"), Link(href: "l2", rel: "test"), ] - XCTAssertEqual(links.firstIndexWithHREF(AnyURL(string: "l2")!), 1) + XCTAssertEqual(try links.firstIndexWithHREF(XCTUnwrap(AnyURL(string: "l2"))), 1) } /// Finds the index of the first `Link` with given `href` when none is found. - func testFirstIndexWithHREFNotFound() { + func testFirstIndexWithHREFNotFound() throws { let links = [Link(href: "l1")] - XCTAssertNil(links.firstIndexWithHREF(AnyURL(string: "unknown")!)) + XCTAssertNil(try links.firstIndexWithHREF(XCTUnwrap(AnyURL(string: "unknown")))) } /// Finds the first `Link` with a `type` matching the given `mediaType`. @@ -95,9 +95,9 @@ class LinkArrayTests: XCTestCase { /// Finds the first `Link` with a `type` matching the given `mediaType`, even if the `type` has /// extra parameters. - func testFirstWithMediaTypeWithExtraParameter() { - let links = [ - Link(href: "l1", mediaType: MediaType("text/html;charset=utf-8")!), + func testFirstWithMediaTypeWithExtraParameter() throws { + let links = try [ + Link(href: "l1", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), ] XCTAssertEqual(links.firstWithMediaType(.html)?.href, "l1") @@ -125,16 +125,16 @@ class LinkArrayTests: XCTestCase { /// Finds all the `Link` with a `type` matching the given `mediaType`, even if the `type` has /// extra parameters. - func testFilterByMediaTypeWithExtraParameter() { - let links = [ + func testFilterByMediaTypeWithExtraParameter() throws { + let links = try [ Link(href: "l1", mediaType: .css), Link(href: "l2", mediaType: .html), - Link(href: "l1", mediaType: MediaType("text/html;charset=utf-8")!), + Link(href: "l1", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), ] - XCTAssertEqual(links.filterByMediaType(.html), [ + XCTAssertEqual(links.filterByMediaType(.html), try [ Link(href: "l2", mediaType: .html), - Link(href: "l1", mediaType: MediaType("text/html;charset=utf-8")!), + Link(href: "l1", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), ]) } @@ -145,15 +145,15 @@ class LinkArrayTests: XCTestCase { } /// Finds all the `Link` with a `type` matching any of the given `mediaTypes`. - func testFilterByMediaTypes() { - let links = [ + func testFilterByMediaTypes() throws { + let links = try [ Link(href: "l1", mediaType: .css), - Link(href: "l2", mediaType: MediaType("text/html;charset=utf-8")!), + Link(href: "l2", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), Link(href: "l3", mediaType: .xml), ] - XCTAssertEqual(links.filterByMediaTypes([.html, .xml]), [ - Link(href: "l2", mediaType: MediaType("text/html;charset=utf-8")!), + XCTAssertEqual(links.filterByMediaTypes([.html, .xml]), try [ + Link(href: "l2", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), Link(href: "l3", mediaType: .xml), ]) } @@ -199,9 +199,9 @@ class LinkArrayTests: XCTestCase { } /// Checks if all the links are video clips. - func testAllAreVideo() { - let links = [ - Link(href: "l1", mediaType: MediaType("video/mp4")!), + func testAllAreVideo() throws { + let links = try [ + Link(href: "l1", mediaType: XCTUnwrap(MediaType("video/mp4"))), Link(href: "l2", mediaType: .webmVideo), ] @@ -239,10 +239,10 @@ class LinkArrayTests: XCTestCase { } /// Checks if all the links match the given media type. - func testAllMatchesMediaType() { - let links = [ + func testAllMatchesMediaType() throws { + let links = try [ Link(href: "l1", mediaType: .css), - Link(href: "l2", mediaType: MediaType("text/css;charset=utf-8")!), + Link(href: "l2", mediaType: XCTUnwrap(MediaType("text/css;charset=utf-8"))), ] XCTAssertTrue(links.allMatchingMediaType(.css)) diff --git a/Tests/SharedTests/Publication/LinkTests.swift b/Tests/SharedTests/Publication/LinkTests.swift index f0d0b39d68..611426d7f7 100644 --- a/Tests/SharedTests/Publication/LinkTests.swift +++ b/Tests/SharedTests/Publication/LinkTests.swift @@ -224,33 +224,33 @@ class LinkTests: XCTestCase { func testURLRelativeToBaseURL() throws { XCTAssertEqual( - Link(href: "folder/file.html").url(relativeTo: AnyURL(string: "http://host/")!), - AnyURL(string: "http://host/folder/file.html")! + try Link(href: "folder/file.html").url(relativeTo: XCTUnwrap(AnyURL(string: "http://host/"))), + AnyURL(string: "http://host/folder/file.html") ) } func testURLRelativeToBaseURLWithRootPrefix() throws { XCTAssertEqual( - Link(href: "file.html").url(relativeTo: AnyURL(string: "http://host/folder/")!), - AnyURL(string: "http://host/folder/file.html")! + try Link(href: "file.html").url(relativeTo: XCTUnwrap(AnyURL(string: "http://host/folder/"))), + AnyURL(string: "http://host/folder/file.html") ) } - func testURLRelativeToNil() throws { + func testURLRelativeToNil() { XCTAssertEqual( Link(href: "http://example.com/folder/file.html").url(), - AnyURL(string: "http://example.com/folder/file.html")! + AnyURL(string: "http://example.com/folder/file.html") ) XCTAssertEqual( Link(href: "folder/file.html").url(), - AnyURL(string: "folder/file.html")! + AnyURL(string: "folder/file.html") ) } func testURLWithAbsoluteHREF() throws { XCTAssertEqual( - Link(href: "http://test.com/folder/file.html").url(relativeTo: AnyURL(string: "http://host/")!), - AnyURL(string: "http://test.com/folder/file.html")! + try Link(href: "http://test.com/folder/file.html").url(relativeTo: XCTUnwrap(AnyURL(string: "http://host/"))), + AnyURL(string: "http://test.com/folder/file.html") ) } diff --git a/Tests/SharedTests/Publication/LocatorTests.swift b/Tests/SharedTests/Publication/LocatorTests.swift index 8fb6b87c0c..34a4d24099 100644 --- a/Tests/SharedTests/Publication/LocatorTests.swift +++ b/Tests/SharedTests/Publication/LocatorTests.swift @@ -340,7 +340,7 @@ class LocatorTextTests: XCTestCase { ) } - func testSubstringFromRange() { + func testSubstringFromRange() throws { let highlight = "highlight" let text = Locator.Text( after: "after", @@ -349,7 +349,7 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - text[highlight.range(of: "h")!], + try text[XCTUnwrap(highlight.range(of: "h"))], Locator.Text( after: "ighlightafter", before: "before", @@ -358,7 +358,7 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - text[highlight.range(of: "lig")!], + try text[XCTUnwrap(highlight.range(of: "lig"))], Locator.Text( after: "htafter", before: "beforehigh", @@ -367,7 +367,7 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - text[highlight.range(of: "highlight")!], + try text[XCTUnwrap(highlight.range(of: "highlight"))], Locator.Text( after: "after", before: "before", @@ -376,7 +376,7 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - text[highlight.range(of: "ght")!], + try text[XCTUnwrap(highlight.range(of: "ght"))], Locator.Text( after: "after", before: "beforehighli", @@ -405,15 +405,15 @@ class LocatorTextTests: XCTestCase { ) } - func testSubstringFromARangeWithNilComponents() { + func testSubstringFromARangeWithNilComponents() throws { let highlight = "highlight" XCTAssertEqual( - Locator.Text( + try Locator.Text( after: nil, before: nil, highlight: highlight - )[highlight.range(of: "ghl")!], + )[XCTUnwrap(highlight.range(of: "ghl"))], Locator.Text( after: "ight", before: "hi", @@ -422,11 +422,11 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - Locator.Text( + try Locator.Text( after: "after", before: nil, highlight: highlight - )[highlight.range(of: "hig")!], + )[XCTUnwrap(highlight.range(of: "hig"))], Locator.Text( after: "hlightafter", before: nil, @@ -435,11 +435,11 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - Locator.Text( + try Locator.Text( after: nil, before: "before", highlight: highlight - )[highlight.range(of: "light")!], + )[XCTUnwrap(highlight.range(of: "light"))], Locator.Text( after: nil, before: "beforehigh", @@ -457,7 +457,7 @@ class LocatorCollectionTests: XCTestCase { ) } - func testParseFullJSON() { + func testParseFullJSON() throws { XCTAssertEqual( LocatorCollection(json: [ "metadata": [ @@ -505,7 +505,7 @@ class LocatorCollectionTests: XCTestCase { ], ], ] as [String: Any]), - LocatorCollection( + try LocatorCollection( metadata: LocatorCollection.Metadata( title: LocalizedString.localized([ "en": "Searching in Alice in Wonderlands - Page 1", @@ -517,8 +517,8 @@ class LocatorCollectionTests: XCTestCase { ] ), links: [ - Link(href: "/978-1503222687/search?query=apple", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "self"), - Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "next"), + Link(href: "/978-1503222687/search?query=apple", mediaType: XCTUnwrap(MediaType("application/vnd.readium.locators+json")), rel: "self"), + Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: XCTUnwrap(MediaType("application/vnd.readium.locators+json")), rel: "next"), ], locators: [ Locator( @@ -576,8 +576,8 @@ class LocatorCollectionTests: XCTestCase { ) } - func testGetFullJSON() { - AssertJSONEqual( + func testGetFullJSON() throws { + try AssertJSONEqual( LocatorCollection( metadata: LocatorCollection.Metadata( title: LocalizedString.localized([ @@ -590,8 +590,8 @@ class LocatorCollectionTests: XCTestCase { ] ), links: [ - Link(href: "/978-1503222687/search?query=apple", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "self"), - Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "next"), + Link(href: "/978-1503222687/search?query=apple", mediaType: XCTUnwrap(MediaType("application/vnd.readium.locators+json")), rel: "self"), + Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: XCTUnwrap(MediaType("application/vnd.readium.locators+json")), rel: "next"), ], locators: [ Locator( diff --git a/Tests/SharedTests/Publication/PublicationTests.swift b/Tests/SharedTests/Publication/PublicationTests.swift index 305613d28a..b131568908 100644 --- a/Tests/SharedTests/Publication/PublicationTests.swift +++ b/Tests/SharedTests/Publication/PublicationTests.swift @@ -91,80 +91,80 @@ class PublicationTests: XCTestCase { ) } - func testLinkWithHREFInReadingOrder() { + func testLinkWithHREFInReadingOrder() throws { XCTAssertEqual( - makePublication(readingOrder: [ + try makePublication(readingOrder: [ Link(href: "l1"), Link(href: "l2"), - ]).linkWithHREF(AnyURL(string: "l2")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l2")))?.href, "l2" ) } - func testLinkWithHREFInLinks() { + func testLinkWithHREFInLinks() throws { XCTAssertEqual( - makePublication(links: [ + try makePublication(links: [ Link(href: "l1"), Link(href: "l2"), - ]).linkWithHREF(AnyURL(string: "l2")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l2")))?.href, "l2" ) } - func testLinkWithHREFInResources() { + func testLinkWithHREFInResources() throws { XCTAssertEqual( - makePublication(resources: [ + try makePublication(resources: [ Link(href: "l1"), Link(href: "l2"), - ]).linkWithHREF(AnyURL(string: "l2")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l2")))?.href, "l2" ) } - func testLinkWithHREFInAlternate() { + func testLinkWithHREFInAlternate() throws { XCTAssertEqual( - makePublication(resources: [ + try makePublication(resources: [ Link(href: "l1", alternates: [ Link(href: "l2", alternates: [ Link(href: "l3"), ]), ]), - ]).linkWithHREF(AnyURL(string: "l3")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l3")))?.href, "l3" ) } - func testLinkWithHREFInChildren() { + func testLinkWithHREFInChildren() throws { XCTAssertEqual( - makePublication(resources: [ + try makePublication(resources: [ Link(href: "l1", children: [ Link(href: "l2", children: [ Link(href: "l3"), ]), ]), - ]).linkWithHREF(AnyURL(string: "l3")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l3")))?.href, "l3" ) } - func testLinkWithHREFIgnoresQuery() { + func testLinkWithHREFIgnoresQuery() throws { let publication = makePublication(links: [ Link(href: "l1?q=a"), Link(href: "l2"), ]) - XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l1?q=a")!)?.href, "l1?q=a") - XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l2?q=b")!)?.href, "l2") + XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l1?q=a")))?.href, "l1?q=a") + XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l2?q=b")))?.href, "l2") } - func testLinkWithHREFIgnoresAnchor() { + func testLinkWithHREFIgnoresAnchor() throws { let publication = makePublication(links: [ Link(href: "l1#a"), Link(href: "l2"), ]) - XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l1#a")!)?.href, "l1#a") - XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l2#b")!)?.href, "l2") + XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l1#a")))?.href, "l1#a") + XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l2#b")))?.href, "l2") } func testLinkWithRelInReadingOrder() { diff --git a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift index 968c162ba0..ef5b48022a 100644 --- a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift @@ -8,17 +8,17 @@ import XCTest class ContentProtectionServiceTests: XCTestCase { - func testGetUnknown() { + func testGetUnknown() throws { let service = TestContentProtectionService() - let resource = service.get(AnyURL(string: "/unknown")!) + let resource = try service.get(XCTUnwrap(AnyURL(string: "/unknown"))) XCTAssertNil(resource) } /// The Publication helpers will use the `ContentProtectionService` if there's one. - func testPublicationHelpers() async { - let scheme = ContentProtectionScheme(rawValue: HTTPURL(string: "https://domain.com/drm")!) + func testPublicationHelpers() async throws { + let scheme = try ContentProtectionScheme(rawValue: XCTUnwrap(HTTPURL(string: "https://domain.com/drm"))) let publication = makePublication(service: { _ in TestContentProtectionService( scheme: scheme, @@ -87,10 +87,10 @@ class ContentProtectionServiceTests: XCTestCase { struct TestContentProtectionService: ContentProtectionService { var scheme: ContentProtectionScheme = .init(rawValue: HTTPURL(string: "https://domain.com/drm")!) var isRestricted: Bool = false - var error: Error? = nil - var credentials: String? = nil + var error: Error? + var credentials: String? var rights: UserRights = UnrestrictedUserRights() - var name: LocalizedString? = nil + var name: LocalizedString? func getCopy(text: String, peek: Bool) throws -> Resource { try XCTUnwrap(get(AnyURL(string: "~readium/rights/copy?text=\(text)&peek=\(peek)")!)) diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift index 367a9db33c..6349c97bf6 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift @@ -440,7 +440,7 @@ class HTMLResourceContentIteratorTest: XCTestCase { """ - let expectedElements: [AnyEquatableContentElement] = [ + let expectedElements: [AnyEquatableContentElement] = try [ VideoContentElement( locator: locator(progression: 0.0, selector: "html > body > video:nth-child(1)"), embeddedLink: Link(href: "dir/video.mp4"), @@ -450,8 +450,8 @@ class HTMLResourceContentIteratorTest: XCTestCase { locator: locator(progression: 0.5, selector: "html > body > video:nth-child(2)"), embeddedLink: Link( href: "dir/video.mp4", - mediaType: MediaType("video/mp4")!, - alternates: [Link(href: "dir/video.m4v", mediaType: MediaType("video/x-m4v")!)] + mediaType: XCTUnwrap(MediaType("video/mp4")), + alternates: [Link(href: "dir/video.m4v", mediaType: XCTUnwrap(MediaType("video/x-m4v")))] ), attributes: [] ).equatable(), diff --git a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift index b833cba9d2..f61bebe6c1 100644 --- a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift @@ -89,8 +89,8 @@ class CoverServiceTests: XCTestCase { } /// `ResourceCoverService` prioritizes explicit `.cover` links over first reading order item. - func testResourceCoverServicePrioritizesExplicitCoverLink() async { - let publication = Publication( + func testResourceCoverServicePrioritizesExplicitCoverLink() async throws { + let publication = try Publication( manifest: Manifest( metadata: Metadata(title: "title"), readingOrder: [ @@ -103,11 +103,11 @@ class CoverServiceTests: XCTestCase { container: CompositeContainer( SingleResourceContainer( resource: FileResource(file: fixtures.url(for: "cover.jpg")), - at: AnyURL(string: "page1.jpg")! + at: XCTUnwrap(AnyURL(string: "page1.jpg")) ), SingleResourceContainer( resource: FileResource(file: fixtures.url(for: "cover2.jpg")), - at: AnyURL(string: "cover2.jpg")! + at: XCTUnwrap(AnyURL(string: "cover2.jpg")) ) ) ) diff --git a/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift b/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift index 0fca96d6f2..53edec3049 100644 --- a/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift @@ -29,7 +29,7 @@ class GeneratedCoverServiceTests: XCTestCase { GeneratedCoverService(cover: cover), GeneratedCoverService(makeCover: { .success(self.cover) }), ] { - let resource = try XCTUnwrap(service.get(AnyURL(string: "~readium/cover")!)) + let resource = try XCTUnwrap(try service.get(XCTUnwrap(AnyURL(string: "~readium/cover")))) let result = await resource.read().map(UIImage.init) AssertImageEqual(result, .success(cover)) } diff --git a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift index 41817cbba6..f7a7a19883 100644 --- a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift @@ -8,7 +8,7 @@ import XCTest class DefaultLocatorServiceTests: XCTestCase { - // locate(Locator) checks that the href exists. + /// locate(Locator) checks that the href exists. func testFromLocator() async { let service = makeService(readingOrder: [ Link(href: "chap1", mediaType: .xml), @@ -164,12 +164,12 @@ class DefaultLocatorServiceTests: XCTestCase { ) } - func testFromLinkWithFragment() async { + func testFromLinkWithFragment() async throws { let service = makeService(readingOrder: [ Link(href: "/href", mediaType: .html, title: "Resource"), ]) - let result = await service.locate(Link(href: "/href#page=42", mediaType: MediaType("text/xml")!, title: "My link")) + let result = try await service.locate(Link(href: "/href#page=42", mediaType: XCTUnwrap(MediaType("text/xml")), title: "My link")) XCTAssertEqual( result, Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(fragments: ["page=42"])) diff --git a/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift b/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift index 0d35295b3d..e076662e3b 100644 --- a/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift @@ -72,17 +72,17 @@ class PerResourcePositionsServiceTests: XCTestCase { ])) } - func testFallsBackOnGivenMediaType() async { - let services = PerResourcePositionsService( + func testFallsBackOnGivenMediaType() async throws { + let services = try PerResourcePositionsService( readingOrder: [Link(href: "res")], - fallbackMediaType: MediaType("image/*")! + fallbackMediaType: XCTUnwrap(MediaType("image/*")) ) let result = await services.positionsByReadingOrder() - XCTAssertEqual(result, .success([[ + XCTAssertEqual(result, try .success([[ Locator( href: "res", - mediaType: MediaType("image/*")!, + mediaType: XCTUnwrap(MediaType("image/*")), locations: Locator.Locations( totalProgression: 0.0, position: 1 diff --git a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift index 457fce0bc4..6a795bfe39 100644 --- a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift @@ -123,7 +123,7 @@ class PositionsServiceTests: XCTestCase { func testGetPositions() async throws { let service = TestPositionsService(positions) - let resource = service.get(AnyURL(string: "~readium/positions")!) + let resource = try service.get(XCTUnwrap(AnyURL(string: "~readium/positions"))) let result = try await resource?.readAsString().get() XCTAssertEqual( @@ -134,10 +134,10 @@ class PositionsServiceTests: XCTestCase { ) } - func testGetUnknown() { + func testGetUnknown() throws { let service = TestPositionsService(positions) - let resource = service.get(AnyURL(string: "/unknown")!) + let resource = try service.get(XCTUnwrap(AnyURL(string: "/unknown"))) XCTAssertNil(resource) } diff --git a/Tests/SharedTests/Toolkit/DocumentTypesTests.swift b/Tests/SharedTests/Toolkit/DocumentTypesTests.swift index 53e4310e34..895cc4c86d 100644 --- a/Tests/SharedTests/Toolkit/DocumentTypesTests.swift +++ b/Tests/SharedTests/Toolkit/DocumentTypesTests.swift @@ -19,31 +19,31 @@ class DocumentTypesTests: XCTestCase { let all = sut.all XCTAssertEqual(all.count, 3) - XCTAssertEqual(all[0], DocumentType( + XCTAssertEqual(all[0], try DocumentType( name: "Foo Format", utis: [], - preferredMediaType: MediaType("application/vnd.bar")!, + preferredMediaType: XCTUnwrap(MediaType("application/vnd.bar")), mediaTypes: [ - MediaType("application/vnd.bar")!, - MediaType("application/vnd.bar2")!, + XCTUnwrap(MediaType("application/vnd.bar")), + XCTUnwrap(MediaType("application/vnd.bar2")), ], fileExtensions: ["foo", "foo2"] )) - XCTAssertEqual(all[1], DocumentType( + XCTAssertEqual(all[1], try DocumentType( name: "PDF Publication", utis: [], - preferredMediaType: MediaType("application/pdf")!, + preferredMediaType: XCTUnwrap(MediaType("application/pdf")), mediaTypes: [ - MediaType("application/pdf")!, + XCTUnwrap(MediaType("application/pdf")), ], fileExtensions: ["pdff"] )) - XCTAssertEqual(all[2], DocumentType( + XCTAssertEqual(all[2], try DocumentType( name: "EPUB Publication", utis: ["org.idpf.epub-container"], - preferredMediaType: MediaType("application/epub+zip")!, + preferredMediaType: XCTUnwrap(MediaType("application/epub+zip")), mediaTypes: [ - MediaType("application/epub+zip")!, + XCTUnwrap(MediaType("application/epub+zip")), ], fileExtensions: ["epub", "epub2"] )) @@ -53,12 +53,12 @@ class DocumentTypesTests: XCTestCase { XCTAssertEqual(sut.supportedUTIs, ["org.idpf.epub-container"]) } - func testSupportedMediaTypes() { - XCTAssertEqual(sut.supportedMediaTypes, [ - MediaType("application/epub+zip")!, - MediaType("application/vnd.bar")!, - MediaType("application/vnd.bar2")!, - MediaType("application/pdf")!, + func testSupportedMediaTypes() throws { + XCTAssertEqual(sut.supportedMediaTypes, try [ + XCTUnwrap(MediaType("application/epub+zip")), + XCTUnwrap(MediaType("application/vnd.bar")), + XCTUnwrap(MediaType("application/vnd.bar2")), + XCTUnwrap(MediaType("application/pdf")), ]) } diff --git a/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift b/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift index 7cd069aaa0..d0555604a8 100644 --- a/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift +++ b/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift @@ -31,25 +31,25 @@ class DirectoryContainerTests: XCTestCase { func testGetNonExistingEntry() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - XCTAssertNil(container[AnyURL(path: "unknown")!]) + XCTAssertNil(try container[XCTUnwrap(AnyURL(path: "unknown"))]) } func testEntries() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - XCTAssertEqual(container.entries, Set([ - AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!, - AnyURL(path: "A folder/Sub.folder%/file.txt")!, - AnyURL(path: "A folder/wasteland-cover.jpg")!, - AnyURL(path: "root.txt")!, - AnyURL(path: "uncompressed.jpg")!, - AnyURL(path: "uncompressed.txt")!, + XCTAssertEqual(container.entries, try Set([ + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt")), + XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg")), + XCTUnwrap(AnyURL(path: "root.txt")), + XCTUnwrap(AnyURL(path: "uncompressed.jpg")), + XCTUnwrap(AnyURL(path: "uncompressed.txt")), ])) } func testHiddenEntries() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded"), options: []) - XCTAssertTrue(container.entries.contains(AnyURL(path: ".hidden")!)) + XCTAssertTrue(try container.entries.contains(XCTUnwrap(AnyURL(path: ".hidden")))) } func testResources() async throws { @@ -65,12 +65,12 @@ class DirectoryContainerTests: XCTestCase { func testCantGetEntryOutsideRoot() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - XCTAssertNil(container[AnyURL(path: "../test.zip")!]) + XCTAssertNil(try container[XCTUnwrap(AnyURL(path: "../test.zip"))]) } func testReadFullEntry() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read().get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -80,7 +80,7 @@ class DirectoryContainerTests: XCTestCase { func testReadRange() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), diff --git a/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift b/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift index c7e541d9ad..a83c6b45d4 100644 --- a/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift +++ b/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift @@ -11,9 +11,9 @@ class FormatSniffersTests: XCTestCase { let fixtures = Fixtures(path: "Format") let sut = DefaultFormatSniffer() - func testSniffHintsUnknown() { + func testSniffHintsUnknown() throws { XCTAssertNil(sut.sniffHints(fileExtension: "unknown")) - XCTAssertNil(sut.sniffHints(mediaType: MediaType("application/unknown+zip")!)) + XCTAssertNil(try sut.sniffHints(mediaType: XCTUnwrap(MediaType("application/unknown+zip")))) } func testSniffHintsIgnoresExtensionCase() { @@ -180,7 +180,7 @@ class FormatSniffersTests: XCTestCase { XCTAssertEqual(sut.sniffHints(mediaType: "image/webp"), webp) } - func testSniffCBR() async { + func testSniffCBR() { let cbr = Format(specifications: .rar, .informalComic, mediaType: .cbr, fileExtension: "cbr") XCTAssertEqual(sut.sniffHints(mediaType: "application/vnd.comicbook-rar"), cbr) XCTAssertEqual(sut.sniffHints(mediaType: "application/x-cbr"), cbr) @@ -374,12 +374,12 @@ class FormatSniffersTests: XCTestCase { XCTAssertEqual(result, .success(.rwpmAudiobook)) } - func testSniffRAR() async { + func testSniffRAR() throws { let rar = Format(specifications: .rar, mediaType: .rar, fileExtension: "rar") XCTAssertEqual(sut.sniffHints(mediaType: .rar), rar) - XCTAssertEqual(sut.sniffHints(mediaType: MediaType("application/x-rar")!), rar) - XCTAssertEqual(sut.sniffHints(mediaType: MediaType("application/x-rar-compressed")!), rar) + XCTAssertEqual(try sut.sniffHints(mediaType: XCTUnwrap(MediaType("application/x-rar"))), rar) + XCTAssertEqual(try sut.sniffHints(mediaType: XCTUnwrap(MediaType("application/x-rar-compressed"))), rar) XCTAssertEqual(sut.sniffHints(fileExtension: "rar"), rar) } @@ -392,7 +392,7 @@ class FormatSniffersTests: XCTestCase { XCTAssertEqual(result, .success(.xhtml)) } - func testSniffXML() async { + func testSniffXML() { XCTAssertEqual(sut.sniffHints(mediaType: .xml), .xml) XCTAssertEqual(sut.sniffHints(fileExtension: "xml"), .xml) } diff --git a/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift b/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift index b655fa25ed..b542fff76e 100644 --- a/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift +++ b/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift @@ -15,46 +15,46 @@ class MediaTypeTests: XCTestCase { func testGetString() { XCTAssertEqual( - MediaType("application/atom+xml;profile=opds-catalog")!.string, + MediaType("application/atom+xml;profile=opds-catalog")?.string, "application/atom+xml;profile=opds-catalog" ) } func testGetStringNormalizes() { XCTAssertEqual( - MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG ; a=0")!.string, + MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG ; a=0")?.string, "application/atom+xml;a=0;profile=OPDS-CATALOG" ) // Parameters are sorted by name XCTAssertEqual( - MediaType("application/atom+xml;a=0;b=1")!.string, + MediaType("application/atom+xml;a=0;b=1")?.string, "application/atom+xml;a=0;b=1" ) XCTAssertEqual( - MediaType("application/atom+xml;b=1;a=0")!.string, + MediaType("application/atom+xml;b=1;a=0")?.string, "application/atom+xml;a=0;b=1" ) } func testGetType() { XCTAssertEqual( - MediaType("application/atom+xml;profile=opds-catalog")!.type, + MediaType("application/atom+xml;profile=opds-catalog")?.type, "application" ) - XCTAssertEqual(MediaType("*/jpeg")!.type, "*") + XCTAssertEqual(MediaType("*/jpeg")?.type, "*") } func testGetSubtype() { XCTAssertEqual( - MediaType("application/atom+xml;profile=opds-catalog")!.subtype, + MediaType("application/atom+xml;profile=opds-catalog")?.subtype, "atom+xml" ) - XCTAssertEqual(MediaType("image/*")!.subtype, "*") + XCTAssertEqual(MediaType("image/*")?.subtype, "*") } func testGetParameters() { XCTAssertEqual( - MediaType("application/atom+xml;type=entry;profile=opds-catalog")!.parameters, + MediaType("application/atom+xml;type=entry;profile=opds-catalog")?.parameters, [ "type": "entry", "profile": "opds-catalog", @@ -62,13 +62,13 @@ class MediaTypeTests: XCTestCase { ) } - func testGetEmptyParameters() { - XCTAssertTrue(MediaType("application/atom+xml")!.parameters.isEmpty) + func testGetEmptyParameters() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("application/atom+xml")?.parameters.isEmpty)) } func testGetParametersWithWhitespaces() { XCTAssertEqual( - MediaType("application/atom+xml ; type=entry ; profile=opds-catalog ")!.parameters, + MediaType("application/atom+xml ; type=entry ; profile=opds-catalog ")?.parameters, [ "type": "entry", "profile": "opds-catalog", @@ -77,144 +77,144 @@ class MediaTypeTests: XCTestCase { } func testGetStructuredSyntaxSuffix() { - XCTAssertNil(MediaType("foo/bar")!.structuredSyntaxSuffix) - XCTAssertNil(MediaType("application/zip")!.structuredSyntaxSuffix) - XCTAssertEqual(MediaType("application/epub+zip")!.structuredSyntaxSuffix, "+zip") - XCTAssertEqual(MediaType("foo/bar+json+zip")!.structuredSyntaxSuffix, "+zip") + XCTAssertNil(MediaType("foo/bar")?.structuredSyntaxSuffix) + XCTAssertNil(MediaType("application/zip")?.structuredSyntaxSuffix) + XCTAssertEqual(MediaType("application/epub+zip")?.structuredSyntaxSuffix, "+zip") + XCTAssertEqual(MediaType("foo/bar+json+zip")?.structuredSyntaxSuffix, "+zip") } func testGetEncoding() { - XCTAssertNil(MediaType("text/html")!.encoding) - XCTAssertEqual(MediaType("text/html;charset=utf-8")!.encoding, .utf8) + XCTAssertNil(MediaType("text/html")?.encoding) + XCTAssertEqual(MediaType("text/html;charset=utf-8")?.encoding, .utf8) } - func testTypeSubtypeAndParameterNamesAreLowercased() { - let mediaType = MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")! + func testTypeSubtypeAndParameterNamesAreLowercased() throws { + let mediaType = try XCTUnwrap(MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")) XCTAssertEqual(mediaType.type, "application") XCTAssertEqual(mediaType.subtype, "atom+xml") XCTAssertEqual(mediaType.parameters, ["profile": "OPDS-CATALOG"]) } func testCharsetValueIsUppercased() { - XCTAssertEqual(MediaType("text/html;charset=utf-8")!.parameters["charset"], "UTF-8") + XCTAssertEqual(MediaType("text/html;charset=utf-8")?.parameters["charset"], "UTF-8") } - func testEquals() { - XCTAssertEqual(MediaType("application/atom+xml")!, MediaType("application/atom+xml")!) - XCTAssertEqual(MediaType("application/atom+xml;profile=opds-catalog")!, MediaType("application/atom+xml;profile=opds-catalog")!) - XCTAssertNotEqual(MediaType("application/atom+xml")!, MediaType("application/atom")!) - XCTAssertNotEqual(MediaType("application/atom+xml")!, MediaType("text/atom+xml")!) - XCTAssertNotEqual(MediaType("application/atom+xml;profile=opds-catalog")!, MediaType("application/atom+xml")!) + func testEquals() throws { + XCTAssertEqual(MediaType("application/atom+xml"), MediaType("application/atom+xml")) + XCTAssertEqual(MediaType("application/atom+xml;profile=opds-catalog"), MediaType("application/atom+xml;profile=opds-catalog")) + XCTAssertNotEqual(try XCTUnwrap(MediaType("application/atom+xml")), try XCTUnwrap(MediaType("application/atom"))) + XCTAssertNotEqual(try XCTUnwrap(MediaType("application/atom+xml")), try XCTUnwrap(MediaType("text/atom+xml"))) + XCTAssertNotEqual(try XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog")), try XCTUnwrap(MediaType("application/atom+xml"))) } - func testEqualsIgnoresCaseOfTypeSubtypeAndParameterNames() { + func testEqualsIgnoresCaseOfTypeSubtypeAndParameterNames() throws { XCTAssertEqual( - MediaType("application/atom+xml;profile=opds-catalog")!, - MediaType("APPLICATION/ATOM+XML;PROFILE=opds-catalog")! + MediaType("application/atom+xml;profile=opds-catalog"), + MediaType("APPLICATION/ATOM+XML;PROFILE=opds-catalog") ) XCTAssertNotEqual( - MediaType("application/atom+xml;profile=opds-catalog")!, - MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")! + try XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog")), + try XCTUnwrap(MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")) ) } func testEqualsIgnoresParametersOrder() { XCTAssertEqual( - MediaType("application/atom+xml;type=entry;profile=opds-catalog")!, - MediaType("application/atom+xml;profile=opds-catalog;type=entry")! + MediaType("application/atom+xml;type=entry;profile=opds-catalog"), + MediaType("application/atom+xml;profile=opds-catalog;type=entry") ) } func testEqualsIgnoresCharsetCase() { XCTAssertEqual( - MediaType("application/atom+xml;charset=utf-8")!, + MediaType("application/atom+xml;charset=utf-8"), MediaType("application/atom+xml;charset=UTF-8") ) } - func testContainsEqualMediaType() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! - .contains(MediaType("text/html;charset=utf-8")!)) + func testContainsEqualMediaType() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .contains(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testContainsMustMatchParameters() { - XCTAssertFalse(MediaType("text/html;charset=utf-8")! - .contains(MediaType("text/html;charset=ascii")!)) - XCTAssertFalse(MediaType("text/html;charset=utf-8")! - .contains(MediaType("text/html")!)) + func testContainsMustMatchParameters() throws { + XCTAssertFalse(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .contains(XCTUnwrap(MediaType("text/html;charset=ascii"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .contains(XCTUnwrap(MediaType("text/html"))))) } - func testContainsIgnoresParametersOrder() { - XCTAssertTrue(MediaType("text/html;charset=utf-8;type=entry")! - .contains(MediaType("text/html;type=entry;charset=utf-8")!)) + func testContainsIgnoresParametersOrder() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8;type=entry")? + .contains(XCTUnwrap(MediaType("text/html;type=entry;charset=utf-8"))))) } - func testContainsIgnoresExtraParameters() { - XCTAssertTrue(MediaType("text/html")! - .contains(MediaType("text/html;charset=utf-8")!)) + func testContainsIgnoresExtraParameters() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html")? + .contains(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testContainsSupportsWildcards() { - XCTAssertTrue(MediaType("*/*")! - .contains(MediaType("text/html;charset=utf-8")!)) - XCTAssertTrue(MediaType("text/*")! - .contains(MediaType("text/html;charset=utf-8")!)) - XCTAssertFalse(MediaType("text/*")! - .contains(MediaType("application/zip")!)) + func testContainsSupportsWildcards() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("*/*")? + .contains(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("text/*")? + .contains(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("text/*")? + .contains(XCTUnwrap(MediaType("application/zip"))))) } - func testContainsFromString() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! - .contains("text/html;charset=utf-8")) + func testContainsFromString() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")? + .contains("text/html;charset=utf-8"))) } - func testMatchesEqualMediaType() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! - .matches(MediaType("text/html;charset=utf-8")!)) + func testMatchesEqualMediaType() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testMatchesMustMatchParameters() { - XCTAssertFalse(MediaType("text/html;charset=ascii")! - .matches(MediaType("text/html;charset=utf-8")!)) + func testMatchesMustMatchParameters() throws { + XCTAssertFalse(try XCTUnwrap(try MediaType("text/html;charset=ascii")? + .matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testMatchesIgnoresParametersOrder() { - XCTAssertTrue(MediaType("text/html;charset=utf-8;type=entry")! - .matches(MediaType("text/html;type=entry;charset=utf-8")!)) + func testMatchesIgnoresParametersOrder() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8;type=entry")? + .matches(XCTUnwrap(MediaType("text/html;type=entry;charset=utf-8"))))) } - func testMatchesIgnoresExtraParameters() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! - .matches(MediaType("text/html;charset=utf-8;extra=param")!)) - XCTAssertTrue(MediaType("text/html;charset=utf-8;extra=param")! - .matches(MediaType("text/html;charset=utf-8")!)) + func testMatchesIgnoresExtraParameters() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .matches(XCTUnwrap(MediaType("text/html;charset=utf-8;extra=param"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8;extra=param")? + .matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testMatchesSupportsWildcards() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")!.matches(MediaType("*/*")!)) - XCTAssertTrue(MediaType("text/html;charset=utf-8")!.matches(MediaType("text/*")!)) - XCTAssertFalse(MediaType("application/zip")!.matches(MediaType("text/*")!)) - XCTAssertTrue(MediaType("*/*")!.matches(MediaType("text/html;charset=utf-8")!)) - XCTAssertTrue(MediaType("text/*")!.matches(MediaType("text/html;charset=utf-8")!)) - XCTAssertFalse(MediaType("text/*")!.matches(MediaType("application/zip")!)) + func testMatchesSupportsWildcards() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")?.matches(XCTUnwrap(MediaType("*/*"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")?.matches(XCTUnwrap(MediaType("text/*"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("application/zip")?.matches(XCTUnwrap(MediaType("text/*"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("*/*")?.matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("text/*")?.matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("text/*")?.matches(XCTUnwrap(MediaType("application/zip"))))) } - func testMatchesFromString() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")!.matches("text/html;charset=utf-8")) + func testMatchesFromString() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")?.matches("text/html;charset=utf-8"))) } - func testMatchesAnyMediaTypes() { - XCTAssertTrue(MediaType("text/html")! - .matchesAny(MediaType("application/zip")!, MediaType("text/html;charset=utf-8")!)) - XCTAssertFalse(MediaType("text/html")! - .matchesAny(MediaType("application/zip")!, MediaType("text/plain;charset=utf-8")!)) - XCTAssertTrue(MediaType("text/html")! - .matchesAny("application/zip", "text/html;charset=utf-8")) - XCTAssertFalse(MediaType("text/html")! - .matchesAny("application/zip", "text/plain;charset=utf-8")) + func testMatchesAnyMediaTypes() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html")? + .matchesAny(XCTUnwrap(MediaType("application/zip")), XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("text/html")? + .matchesAny(XCTUnwrap(MediaType("application/zip")), XCTUnwrap(MediaType("text/plain;charset=utf-8"))))) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html")? + .matchesAny("application/zip", "text/html;charset=utf-8"))) + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")? + .matchesAny("application/zip", "text/plain;charset=utf-8"))) } - func testPatternMatch() { + func testPatternMatch() throws { let mediaType: MediaType? = .json XCTAssertTrue(.json ~= mediaType) XCTAssertTrue(.json ~= MediaType("application/json")!) @@ -222,108 +222,108 @@ class MediaTypeTests: XCTestCase { XCTAssertFalse(.json ~= MediaType("application/opds+json")!) XCTAssertFalse(MediaType.json ~= nil) XCTAssertTrue(mediaType ~= .json) - XCTAssertTrue(MediaType("application/json")! ~= .json) - XCTAssertTrue(MediaType("application/json;charset=utf-8")! ~= .json) - XCTAssertFalse(MediaType("application/opds+json")! ~= .json) + XCTAssertTrue(try XCTUnwrap(MediaType("application/json")) ~= .json) + XCTAssertTrue(try XCTUnwrap(MediaType("application/json;charset=utf-8")) ~= .json) + XCTAssertFalse(try XCTUnwrap(MediaType("application/opds+json") ~= .json)) } - func testPatternMatchEqualMediaType() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! + func testPatternMatchEqualMediaType() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/html;charset=utf-8")!) } - func testPatternMatchNil() { - XCTAssertFalse(MediaType("text/html;charset=utf-8")! ~= nil) + func testPatternMatchNil() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= nil) } - func testPatternMatchMustMatchParameters() { - XCTAssertFalse(MediaType("text/html;charset=utf-8")! + func testPatternMatchMustMatchParameters() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/html;charset=ascii")!) - XCTAssertTrue(MediaType("text/html;charset=utf-8")! ~= MediaType("text/html;charset=utf-8")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/html;charset=utf-8")!) } - func testPatternMatchIgnoresParametersOrder() { - XCTAssertTrue(MediaType("text/html;charset=utf-8;type=entry")! + func testPatternMatchIgnoresParametersOrder() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8;type=entry")) ~= MediaType("text/html;type=entry;charset=utf-8")!) } - func testPatternMatchIgnoresExtraParameters() { - XCTAssertTrue(MediaType("text/html")! ~= MediaType("text/html;charset=utf-8")!) - XCTAssertTrue(MediaType("text/html;charset=utf-8")! ~= MediaType("text/html")!) + func testPatternMatchIgnoresExtraParameters() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html")) ~= MediaType("text/html;charset=utf-8")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/html")!) } - func testPatternMatchSupportsWildcards() { - XCTAssertTrue(MediaType("*/*")! ~= MediaType("text/html;charset=utf-8")!) - XCTAssertTrue(MediaType("text/*")! ~= MediaType("text/html;charset=utf-8")!) - XCTAssertFalse(MediaType("text/*")! ~= MediaType("application/zip")!) - XCTAssertTrue(MediaType("text/html;charset=utf-8")! ~= MediaType("*/*")!) - XCTAssertTrue(MediaType("text/html;charset=utf-8")! ~= MediaType("text/*")!) - XCTAssertFalse(MediaType("application/zip")! ~= MediaType("text/*")!) + func testPatternMatchSupportsWildcards() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("*/*")) ~= MediaType("text/html;charset=utf-8")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/*")) ~= MediaType("text/html;charset=utf-8")!) + XCTAssertFalse(try XCTUnwrap(MediaType("text/*")) ~= MediaType("application/zip")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("*/*")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/*")!) + XCTAssertFalse(try XCTUnwrap(MediaType("application/zip")) ~= MediaType("text/*")!) } - func testIsZIP() { - XCTAssertFalse(MediaType("text/plain")!.isZIP) - XCTAssertTrue(MediaType("application/zip")!.isZIP) - XCTAssertTrue(MediaType("application/zip;charset=utf-8")!.isZIP) - XCTAssertTrue(MediaType("application/epub+zip")!.isZIP) + func testIsZIP() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/plain")?.isZIP)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/zip")?.isZIP)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/zip;charset=utf-8")?.isZIP)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/epub+zip")?.isZIP)) // These media types must be explicitely matched since they don't have any ZIP hint - XCTAssertTrue(MediaType("application/audiobook+lcp")!.isZIP) - XCTAssertTrue(MediaType("application/pdf+lcp")!.isZIP) + XCTAssertTrue(try XCTUnwrap(MediaType("application/audiobook+lcp")?.isZIP)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/pdf+lcp")?.isZIP)) } - func testIsJSON() { - XCTAssertFalse(MediaType("text/plain")!.isJSON) - XCTAssertTrue(MediaType("application/json")!.isJSON) - XCTAssertTrue(MediaType("application/json;charset=utf-8")!.isJSON) - XCTAssertTrue(MediaType("application/opds+json")!.isJSON) + func testIsJSON() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/plain")?.isJSON)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/json")?.isJSON)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/json;charset=utf-8")?.isJSON)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds+json")?.isJSON)) } - func testIsOPDS() { - XCTAssertFalse(MediaType("text/html")!.isOPDS) - XCTAssertTrue(MediaType("application/atom+xml;profile=opds-catalog")!.isOPDS) - XCTAssertTrue(MediaType("application/atom+xml;type=entry;profile=opds-catalog")!.isOPDS) - XCTAssertTrue(MediaType("application/opds+json")!.isOPDS) - XCTAssertTrue(MediaType("application/opds-publication+json")!.isOPDS) - XCTAssertTrue(MediaType("application/opds+json;charset=utf-8")!.isOPDS) - XCTAssertTrue(MediaType("application/opds-authentication+json")!.isOPDS) + func testIsOPDS() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/atom+xml;type=entry;profile=opds-catalog")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds+json")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds-publication+json")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds+json;charset=utf-8")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds-authentication+json")?.isOPDS)) } - func testIsHTML() { - XCTAssertFalse(MediaType("application/opds+json")!.isHTML) - XCTAssertTrue(MediaType("text/html")!.isHTML) - XCTAssertTrue(MediaType("application/xhtml+xml")!.isHTML) - XCTAssertTrue(MediaType("text/html;charset=utf-8")!.isHTML) + func testIsHTML() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("application/opds+json")?.isHTML)) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html")?.isHTML)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/xhtml+xml")?.isHTML)) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")?.isHTML)) } - func testIsBitmap() { - XCTAssertFalse(MediaType("text/html")!.isBitmap) - XCTAssertTrue(MediaType("image/bmp")!.isBitmap) - XCTAssertTrue(MediaType("image/gif")!.isBitmap) - XCTAssertTrue(MediaType("image/jpeg")!.isBitmap) - XCTAssertTrue(MediaType("image/jxl")!.isBitmap) - XCTAssertTrue(MediaType("image/png")!.isBitmap) - XCTAssertTrue(MediaType("image/tiff")!.isBitmap) - XCTAssertTrue(MediaType("image/webp")!.isBitmap) - XCTAssertTrue(MediaType("image/tiff;charset=utf-8")!.isBitmap) + func testIsBitmap() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/bmp")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/gif")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/jpeg")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/jxl")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/png")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/tiff")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/webp")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/tiff;charset=utf-8")?.isBitmap)) } - func testIsAudio() { - XCTAssertFalse(MediaType("text/html")!.isAudio) - XCTAssertTrue(MediaType("audio/unknown")!.isAudio) - XCTAssertTrue(MediaType("audio/mpeg;param=value")!.isAudio) + func testIsAudio() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isAudio)) + XCTAssertTrue(try XCTUnwrap(MediaType("audio/unknown")?.isAudio)) + XCTAssertTrue(try XCTUnwrap(MediaType("audio/mpeg;param=value")?.isAudio)) } - func testIsVideo() { - XCTAssertFalse(MediaType("text/html")!.isVideo) - XCTAssertTrue(MediaType("video/unknown")!.isVideo) - XCTAssertTrue(MediaType("video/mpeg;param=value")!.isVideo) + func testIsVideo() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isVideo)) + XCTAssertTrue(try XCTUnwrap(MediaType("video/unknown")?.isVideo)) + XCTAssertTrue(try XCTUnwrap(MediaType("video/mpeg;param=value")?.isVideo)) } - func testIsRWPM() { - XCTAssertFalse(MediaType("text/html")!.isRWPM) - XCTAssertTrue(MediaType("application/audiobook+json")!.isRWPM) - XCTAssertTrue(MediaType("application/divina+json")!.isRWPM) - XCTAssertTrue(MediaType("application/webpub+json")!.isRWPM) - XCTAssertTrue(MediaType("application/webpub+json;charset=utf-8")!.isRWPM) + func testIsRWPM() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isRWPM)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/audiobook+json")?.isRWPM)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/divina+json")?.isRWPM)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/webpub+json")?.isRWPM)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/webpub+json;charset=utf-8")?.isRWPM)) } } diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift index 5141ea005d..5ab8993eb9 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift @@ -9,34 +9,34 @@ import Foundation import XCTest class FileURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - FileURL(string: "file:///foo/bar")!, - FileURL(string: "file:///foo/bar")! + FileURL(string: "file:///foo/bar"), + FileURL(string: "file:///foo/bar") ) // Fragments are ignored. XCTAssertEqual( - FileURL(string: "file:///foo/bar")!, - FileURL(string: "file:///foo/bar#fragment")! + FileURL(string: "file:///foo/bar"), + FileURL(string: "file:///foo/bar#fragment") ) XCTAssertNotEqual( - FileURL(string: "file:///foo/bar")!, - FileURL(string: "file:///foo/baz")! + try XCTUnwrap(FileURL(string: "file:///foo/bar")), + try XCTUnwrap(FileURL(string: "file:///foo/baz")) ) XCTAssertNotEqual( - FileURL(string: "file:///foo/bar")!, - FileURL(string: "file:///foo/bar/")! + try XCTUnwrap(FileURL(string: "file:///foo/bar")), + try XCTUnwrap(FileURL(string: "file:///foo/bar/")) ) } // MARK: - URLProtocol - func testCreateFromURL() { - XCTAssertEqual(FileURL(url: URL(string: "file:///foo/bar")!)?.string, "file:///foo/bar") + func testCreateFromURL() throws { + XCTAssertEqual(try FileURL(url: XCTUnwrap(URL(string: "file:///foo/bar")))?.string, "file:///foo/bar") // Only valid for scheme `file`. - XCTAssertNil(FileURL(url: URL(string: "http://domain.com")!)) - XCTAssertNil(FileURL(url: URL(string: "opds://domain.com")!)) + XCTAssertNil(try FileURL(url: XCTUnwrap(URL(string: "http://domain.com")))) + XCTAssertNil(try FileURL(url: XCTUnwrap(URL(string: "opds://domain.com")))) } func testCreateFromString() { @@ -75,7 +75,7 @@ class FileURLTests: XCTestCase { } func testURL() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")?.url, URL(string: "file:///foo/bar")!) + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.url, URL(string: "file:///foo/bar")) } func testString() { @@ -88,8 +88,8 @@ class FileURLTests: XCTestCase { XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")?.path, "/foo/bar baz/") } - func testAppendingPath() { - var base = FileURL(string: "file:///foo/bar")! + func testAppendingPath() throws { + var base = try XCTUnwrap(FileURL(string: "file:///foo/bar")) XCTAssertEqual(base.appendingPath("", isDirectory: false).string, "file:///foo/bar") XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "file:///foo/bar/baz/quz") XCTAssertEqual(base.appendingPath("/baz/quz", isDirectory: false).string, "file:///foo/bar/baz/quz") @@ -103,42 +103,42 @@ class FileURLTests: XCTestCase { XCTAssertEqual(base.appendingPath("baz/quz/", isDirectory: false).string, "file:///foo/bar/baz/quz") // With trailing slash. - base = FileURL(string: "file:///foo/bar/")! + base = try XCTUnwrap(FileURL(string: "file:///foo/bar/")) XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "file:///foo/bar/baz/quz") } func testPathSegments() { - XCTAssertEqual(FileURL(string: "file:///foo")!.pathSegments, ["foo"]) - XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz")!.pathSegments, ["foo", "bar baz"]) - XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")!.pathSegments, ["foo", "bar baz"]) - XCTAssertEqual(FileURL(string: "file:///foo/bar?query#fragment")!.pathSegments, ["foo", "bar"]) + XCTAssertEqual(FileURL(string: "file:///foo")?.pathSegments, ["foo"]) + XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz")?.pathSegments, ["foo", "bar baz"]) + XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")?.pathSegments, ["foo", "bar baz"]) + XCTAssertEqual(FileURL(string: "file:///foo/bar?query#fragment")?.pathSegments, ["foo", "bar"]) } func testLastPathSegment() { - XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz")!.lastPathSegment, "bar baz") - XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")!.lastPathSegment, "bar baz") - XCTAssertEqual(FileURL(string: "file:///foo/bar?query#fragment")!.lastPathSegment, "bar") + XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz")?.lastPathSegment, "bar baz") + XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")?.lastPathSegment, "bar baz") + XCTAssertEqual(FileURL(string: "file:///foo/bar?query#fragment")?.lastPathSegment, "bar") } func testRemovingLastPathSegment() { - XCTAssertEqual(FileURL(string: "file:///")!.removingLastPathSegment().string, "file:///") - XCTAssertEqual(FileURL(string: "file:///foo")!.removingLastPathSegment().string, "file:///") - XCTAssertEqual(FileURL(string: "file:///foo/bar")!.removingLastPathSegment().string, "file:///foo/") + XCTAssertEqual(FileURL(string: "file:///")?.removingLastPathSegment().string, "file:///") + XCTAssertEqual(FileURL(string: "file:///foo")?.removingLastPathSegment().string, "file:///") + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingLastPathSegment().string, "file:///foo/") } func testPathExtension() { - XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")!.pathExtension, "txt") - XCTAssertNil(FileURL(string: "file:///foo/bar")!.pathExtension) - XCTAssertNil(FileURL(string: "file:///foo/bar/")!.pathExtension) - XCTAssertNil(FileURL(string: "file:///foo/.hidden")!.pathExtension) + XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")?.pathExtension, "txt") + XCTAssertNil(FileURL(string: "file:///foo/bar")?.pathExtension) + XCTAssertNil(FileURL(string: "file:///foo/bar/")?.pathExtension) + XCTAssertNil(FileURL(string: "file:///foo/.hidden")?.pathExtension) } func testReplacingPathExtension() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")!.replacingPathExtension("xml").string, "file:///foo/bar.xml") - XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")!.replacingPathExtension("xml").string, "file:///foo/bar.xml") - XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")!.replacingPathExtension(nil).string, "file:///foo/bar") - XCTAssertEqual(FileURL(string: "file:///foo/bar/")!.replacingPathExtension("xml").string, "file:///foo/bar/") - XCTAssertEqual(FileURL(string: "file:///foo/bar/")!.replacingPathExtension(nil).string, "file:///foo/bar/") + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.replacingPathExtension("xml").string, "file:///foo/bar.xml") + XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")?.replacingPathExtension("xml").string, "file:///foo/bar.xml") + XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")?.replacingPathExtension(nil).string, "file:///foo/bar") + XCTAssertEqual(FileURL(string: "file:///foo/bar/")?.replacingPathExtension("xml").string, "file:///foo/bar/") + XCTAssertEqual(FileURL(string: "file:///foo/bar/")?.replacingPathExtension(nil).string, "file:///foo/bar/") } func testQuery() { @@ -148,8 +148,8 @@ class FileURLTests: XCTestCase { } func testRemovingQuery() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingQuery(), FileURL(string: "file:///foo/bar")!) - XCTAssertEqual(FileURL(string: "file:///foo/bar?param=quz%20baz")?.removingQuery(), FileURL(string: "file:///foo/bar")!) + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingQuery(), FileURL(string: "file:///foo/bar")) + XCTAssertEqual(FileURL(string: "file:///foo/bar?param=quz%20baz")?.removingQuery(), FileURL(string: "file:///foo/bar")) } func testFragment() { @@ -159,81 +159,81 @@ class FileURLTests: XCTestCase { } func testRemovingFragment() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingFragment(), FileURL(string: "file:///foo/bar")!) - XCTAssertEqual(FileURL(string: "file:///foo/bar#quz%20baz")?.removingFragment(), FileURL(string: "file:///foo/bar")!) + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingFragment(), FileURL(string: "file:///foo/bar")) + XCTAssertEqual(FileURL(string: "file:///foo/bar#quz%20baz")?.removingFragment(), FileURL(string: "file:///foo/bar")) } // MARK: - AbsoluteURL func testScheme() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")!.scheme, .file) - XCTAssertEqual(FileURL(string: "FILE:///foo/bar")!.scheme, .file) + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.scheme, .file) + XCTAssertEqual(FileURL(string: "FILE:///foo/bar")?.scheme, .file) } func testHost() { - XCTAssertNil(FileURL(string: "file:///foo/bar")!.host) + XCTAssertNil(FileURL(string: "file:///foo/bar")?.host) } func testOrigin() { // Always null for a file URL. - XCTAssertNil(FileURL(string: "file:///foo/bar")!.origin) + XCTAssertNil(FileURL(string: "file:///foo/bar")?.origin) } - func testResolveAbsoluteURL() { - let base = FileURL(string: "file:///foo/bar")! - XCTAssertEqual(base.resolve(FileURL(string: "file:///foo")!)!.string, "file:///foo") - XCTAssertEqual(base.resolve(HTTPURL(string: "http://domain.com")!)!.string, "http://domain.com") - XCTAssertEqual(base.resolve(UnknownAbsoluteURL(string: "opds://other")!)!.string, "opds://other") + func testResolveAbsoluteURL() throws { + let base = try XCTUnwrap(FileURL(string: "file:///foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(FileURL(string: "file:///foo")))?.string, "file:///foo") + XCTAssertEqual(try base.resolve(XCTUnwrap(HTTPURL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try base.resolve(XCTUnwrap(UnknownAbsoluteURL(string: "opds://other")))?.string, "opds://other") } - func testResolveRelativeURL() { - var base = FileURL(string: "file:///foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, FileURL(string: "file:///foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, FileURL(string: "file:///quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, FileURL(string: "file:///quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, FileURL(string: "file:///foo/bar#fragment")!) + func testResolveRelativeURL() throws { + var base = try XCTUnwrap(FileURL(string: "file:///foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), FileURL(string: "file:///foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), FileURL(string: "file:///quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), FileURL(string: "file:///quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), FileURL(string: "file:///foo/bar#fragment")) // With trailing slash - base = FileURL(string: "file:///foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, FileURL(string: "file:///foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, FileURL(string: "file:///foo/quz/baz")!) + base = try XCTUnwrap(FileURL(string: "file:///foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), FileURL(string: "file:///foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), FileURL(string: "file:///foo/quz/baz")) } - func testRelativize() { - var base = FileURL(string: "file:///foo")! + func testRelativize() throws { + var base = try XCTUnwrap(FileURL(string: "file:///foo")) - XCTAssertNil(base.relativize(AnyURL(string: "file:///foo")!)) - XCTAssertEqual(base.relativize(AnyURL(string: "file:///foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertNil(base.relativize(AnyURL(string: "file:///quz/baz")!)) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "file:///foo")))) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "file:///foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "file:///quz/baz")))) // With trailing slash - base = FileURL(string: "file:///foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "file:///foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) + base = try XCTUnwrap(FileURL(string: "file:///foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "file:///foo/quz/baz"))), RelativeURL(string: "quz/baz")) } - func testRelativizeRelativeURL() { - let base = FileURL(string: "file:///foo")! - XCTAssertNil(base.relativize(RelativeURL(string: "foo/bar")!)) + func testRelativizeRelativeURL() throws { + let base = try XCTUnwrap(FileURL(string: "file:///foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(RelativeURL(string: "foo/bar")))) } - func testRelativizeAbsoluteURLWithDifferentScheme() { - let base = FileURL(string: "file:///foo")! - XCTAssertNil(base.relativize(HTTPURL(string: "https://host/foo/bar")!)) - XCTAssertNil(base.relativize(UnknownAbsoluteURL(string: "opds://host/foo/bar")!)) + func testRelativizeAbsoluteURLWithDifferentScheme() throws { + let base = try XCTUnwrap(FileURL(string: "file:///foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(HTTPURL(string: "https://host/foo/bar")))) + XCTAssertNil(try base.relativize(XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")))) } - func testIsRelative() { + func testIsRelative() throws { // Always relative if same scheme. - let url = FileURL(string: "file:///foo/bar")! - XCTAssertTrue(url.isRelative(to: FileURL(string: "file:///foo")!)) - XCTAssertTrue(url.isRelative(to: FileURL(string: "file:///foo/bar")!)) - XCTAssertTrue(url.isRelative(to: FileURL(string: "file:///foo/bar/baz")!)) - XCTAssertTrue(url.isRelative(to: FileURL(string: "file:///bar")!)) + let url = try XCTUnwrap(FileURL(string: "file:///foo/bar")) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(FileURL(string: "file:///foo")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(FileURL(string: "file:///foo/bar")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(FileURL(string: "file:///foo/bar/baz")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(FileURL(string: "file:///bar")))) // Different scheme - XCTAssertFalse(url.isRelative(to: UnknownAbsoluteURL(string: "other://host/foo")!)) - XCTAssertFalse(url.isRelative(to: HTTPURL(string: "http://foo")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "other://host/foo")))) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://foo")))) // Relative path - XCTAssertFalse(url.isRelative(to: RelativeURL(path: "foo/bar")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(RelativeURL(path: "foo/bar")))) } } diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift index 097476491f..a615972b3e 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift @@ -9,26 +9,26 @@ import Foundation import XCTest class HTTPURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - HTTPURL(string: "http://domain.com")!, - HTTPURL(string: "http://domain.com")! + HTTPURL(string: "http://domain.com"), + HTTPURL(string: "http://domain.com") ) XCTAssertNotEqual( - HTTPURL(string: "http://domain.com")!, - HTTPURL(string: "http://domain.com#fragment")! + try XCTUnwrap(HTTPURL(string: "http://domain.com")), + try XCTUnwrap(HTTPURL(string: "http://domain.com#fragment")) ) } // MARK: - URLProtocol - func testCreateFromURL() { - XCTAssertEqual(HTTPURL(url: URL(string: "http://domain.com")!)?.string, "http://domain.com") - XCTAssertEqual(HTTPURL(url: URL(string: "https://domain.com")!)?.string, "https://domain.com") + func testCreateFromURL() throws { + XCTAssertEqual(try HTTPURL(url: XCTUnwrap(URL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try HTTPURL(url: XCTUnwrap(URL(string: "https://domain.com")))?.string, "https://domain.com") // Only valid for schemes `http` or `https`. - XCTAssertNil(HTTPURL(url: URL(string: "file://domain.com")!)) - XCTAssertNil(HTTPURL(url: URL(string: "opds://domain.com")!)) + XCTAssertNil(try HTTPURL(url: XCTUnwrap(URL(string: "file://domain.com")))) + XCTAssertNil(try HTTPURL(url: XCTUnwrap(URL(string: "opds://domain.com")))) } func testCreateFromString() { @@ -44,7 +44,7 @@ class HTTPURLTests: XCTestCase { } func testURL() { - XCTAssertEqual(HTTPURL(string: "http://foo/bar?query#fragment")?.url, URL(string: "http://foo/bar?query#fragment")!) + XCTAssertEqual(HTTPURL(string: "http://foo/bar?query#fragment")?.url, URL(string: "http://foo/bar?query#fragment")) } func testString() { @@ -60,8 +60,8 @@ class HTTPURLTests: XCTestCase { XCTAssertEqual(HTTPURL(string: "http://host?query")?.path, "") } - func testAppendingPath() { - var base = HTTPURL(string: "http://foo/bar")! + func testAppendingPath() throws { + var base = try XCTUnwrap(HTTPURL(string: "http://foo/bar")) XCTAssertEqual(base.appendingPath("", isDirectory: false).string, "http://foo/bar") XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "http://foo/bar/baz/quz") XCTAssertEqual(base.appendingPath("/baz/quz", isDirectory: false).string, "http://foo/bar/baz/quz") @@ -75,7 +75,7 @@ class HTTPURLTests: XCTestCase { XCTAssertEqual(base.appendingPath("baz/quz/", isDirectory: false).string, "http://foo/bar/baz/quz") // With trailing slash. - base = HTTPURL(string: "http://foo/bar/")! + base = try XCTUnwrap(HTTPURL(string: "http://foo/bar/")) XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "http://foo/bar/baz/quz") } @@ -98,10 +98,10 @@ class HTTPURLTests: XCTestCase { } func testRemovingLastPathSegment() { - XCTAssertEqual(HTTPURL(string: "http://")!.removingLastPathSegment().string, "http://") - XCTAssertEqual(HTTPURL(string: "http://foo")!.removingLastPathSegment().string, "http://foo") - XCTAssertEqual(HTTPURL(string: "http://foo/bar")!.removingLastPathSegment().string, "http://foo/") - XCTAssertEqual(HTTPURL(string: "http://foo/bar/baz")!.removingLastPathSegment().string, "http://foo/bar/") + XCTAssertEqual(HTTPURL(string: "http://")?.removingLastPathSegment().string, "http://") + XCTAssertEqual(HTTPURL(string: "http://foo")?.removingLastPathSegment().string, "http://foo") + XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingLastPathSegment().string, "http://foo/") + XCTAssertEqual(HTTPURL(string: "http://foo/bar/baz")?.removingLastPathSegment().string, "http://foo/bar/") } func testPathExtension() { @@ -112,12 +112,12 @@ class HTTPURLTests: XCTestCase { } func testReplacingPathExtension() { - XCTAssertEqual(HTTPURL(string: "http://foo/bar")!.replacingPathExtension("xml").string, "http://foo/bar.xml") - XCTAssertEqual(HTTPURL(string: "http://foo/bar.txt")!.replacingPathExtension("xml").string, "http://foo/bar.xml") - XCTAssertEqual(HTTPURL(string: "http://foo/bar.txt")!.replacingPathExtension(nil).string, "http://foo/bar") - XCTAssertEqual(HTTPURL(string: "http://foo/bar/")!.replacingPathExtension("xml").string, "http://foo/bar/") - XCTAssertEqual(HTTPURL(string: "http://foo/bar/")!.replacingPathExtension(nil).string, "http://foo/bar/") - XCTAssertEqual(HTTPURL(string: "http://foo")!.replacingPathExtension("xml").string, "http://foo") + XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.replacingPathExtension("xml").string, "http://foo/bar.xml") + XCTAssertEqual(HTTPURL(string: "http://foo/bar.txt")?.replacingPathExtension("xml").string, "http://foo/bar.xml") + XCTAssertEqual(HTTPURL(string: "http://foo/bar.txt")?.replacingPathExtension(nil).string, "http://foo/bar") + XCTAssertEqual(HTTPURL(string: "http://foo/bar/")?.replacingPathExtension("xml").string, "http://foo/bar/") + XCTAssertEqual(HTTPURL(string: "http://foo/bar/")?.replacingPathExtension(nil).string, "http://foo/bar/") + XCTAssertEqual(HTTPURL(string: "http://foo")?.replacingPathExtension("xml").string, "http://foo") } func testQuery() { @@ -129,8 +129,8 @@ class HTTPURLTests: XCTestCase { } func testRemovingQuery() { - XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingQuery(), HTTPURL(string: "http://foo/bar")!) - XCTAssertEqual(HTTPURL(string: "http://foo/bar?param=quz%20baz")?.removingQuery(), HTTPURL(string: "http://foo/bar")!) + XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingQuery(), HTTPURL(string: "http://foo/bar")) + XCTAssertEqual(HTTPURL(string: "http://foo/bar?param=quz%20baz")?.removingQuery(), HTTPURL(string: "http://foo/bar")) } func testFragment() { @@ -139,8 +139,8 @@ class HTTPURLTests: XCTestCase { } func testRemovingFragment() { - XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingFragment(), HTTPURL(string: "http://foo/bar")!) - XCTAssertEqual(HTTPURL(string: "http://foo/bar#quz%20baz")?.removingFragment(), HTTPURL(string: "http://foo/bar")!) + XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingFragment(), HTTPURL(string: "http://foo/bar")) + XCTAssertEqual(HTTPURL(string: "http://foo/bar#quz%20baz")?.removingFragment(), HTTPURL(string: "http://foo/bar")) } // MARK: - AbsoluteURL @@ -152,76 +152,76 @@ class HTTPURLTests: XCTestCase { } func testHost() { - XCTAssertNil(HTTPURL(string: "http://")!.host) - XCTAssertNil(HTTPURL(string: "http:///")!.host) - XCTAssertEqual(HTTPURL(string: "http://domain")!.host, "domain") - XCTAssertEqual(HTTPURL(string: "http://domain/path")!.host, "domain") + XCTAssertNil(HTTPURL(string: "http://")?.host) + XCTAssertNil(HTTPURL(string: "http:///")?.host) + XCTAssertEqual(HTTPURL(string: "http://domain")?.host, "domain") + XCTAssertEqual(HTTPURL(string: "http://domain/path")?.host, "domain") } func testOrigin() { - XCTAssertEqual(HTTPURL(string: "HTTP://foo/bar")!.origin, "http://foo") - XCTAssertEqual(HTTPURL(string: "https://foo:443/bar")!.origin, "https://foo:443") + XCTAssertEqual(HTTPURL(string: "HTTP://foo/bar")?.origin, "http://foo") + XCTAssertEqual(HTTPURL(string: "https://foo:443/bar")?.origin, "https://foo:443") } - func testResolveAbsoluteURL() { - let base = HTTPURL(string: "http://host/foo/bar")! - XCTAssertEqual(base.resolve(HTTPURL(string: "http://domain.com")!)!.string, "http://domain.com") - XCTAssertEqual(base.resolve(UnknownAbsoluteURL(string: "opds://other")!)!.string, "opds://other") - XCTAssertEqual(base.resolve(FileURL(string: "file:///foo")!)!.string, "file:///foo") + func testResolveAbsoluteURL() throws { + let base = try XCTUnwrap(HTTPURL(string: "http://host/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(HTTPURL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try base.resolve(XCTUnwrap(UnknownAbsoluteURL(string: "opds://other")))?.string, "opds://other") + XCTAssertEqual(try base.resolve(XCTUnwrap(FileURL(string: "file:///foo")))?.string, "file:///foo") } - func testResolveRelativeURL() { - var base = HTTPURL(string: "http://host/foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, HTTPURL(string: "http://host/foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, HTTPURL(string: "http://host/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, HTTPURL(string: "http://host/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, HTTPURL(string: "http://host/foo/bar#fragment")!) + func testResolveRelativeURL() throws { + var base = try XCTUnwrap(HTTPURL(string: "http://host/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), HTTPURL(string: "http://host/foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), HTTPURL(string: "http://host/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), HTTPURL(string: "http://host/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), HTTPURL(string: "http://host/foo/bar#fragment")) // With trailing slash - base = HTTPURL(string: "http://host/foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, HTTPURL(string: "http://host/foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, HTTPURL(string: "http://host/foo/quz/baz")!) + base = try XCTUnwrap(HTTPURL(string: "http://host/foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), HTTPURL(string: "http://host/foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), HTTPURL(string: "http://host/foo/quz/baz")) } - func testRelativize() { - var base = HTTPURL(string: "http://host/foo")! + func testRelativize() throws { + var base = try XCTUnwrap(HTTPURL(string: "http://host/foo")) - XCTAssertNil(base.relativize(AnyURL(string: "http://host/foo")!)) - XCTAssertEqual(base.relativize(AnyURL(string: "http://host/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.relativize(AnyURL(string: "http://host/foo#fragment")!)!, RelativeURL(string: "#fragment")!) - XCTAssertNil(base.relativize(AnyURL(string: "http://host/quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "http://host//foo/bar")!)) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/foo")))) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/foo#fragment"))), RelativeURL(string: "#fragment")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://host//foo/bar")))) // With trailing slash - base = HTTPURL(string: "http://host/foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "http://host/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) + base = try XCTUnwrap(HTTPURL(string: "http://host/foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/foo/quz/baz"))), RelativeURL(string: "quz/baz")) } - func testRelativizeRelativeURL() { - let base = HTTPURL(string: "http://host/foo")! - XCTAssertNil(base.relativize(RelativeURL(string: "host/foo/bar")!)) + func testRelativizeRelativeURL() throws { + let base = try XCTUnwrap(HTTPURL(string: "http://host/foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(RelativeURL(string: "host/foo/bar")))) } - func testRelativizeAbsoluteURLWithDifferentScheme() { - let base = HTTPURL(string: "http://host/foo")! - XCTAssertNil(base.relativize(HTTPURL(string: "https://host/foo/bar")!)) - XCTAssertNil(base.relativize(FileURL(string: "file://host/foo/bar")!)) + func testRelativizeAbsoluteURLWithDifferentScheme() throws { + let base = try XCTUnwrap(HTTPURL(string: "http://host/foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(HTTPURL(string: "https://host/foo/bar")))) + XCTAssertNil(try base.relativize(XCTUnwrap(FileURL(string: "file://host/foo/bar")))) } - func testIsRelative() { + func testIsRelative() throws { // Only relative with the same origin. - let url = HTTPURL(string: "http://host/foo/bar")! - XCTAssertTrue(url.isRelative(to: HTTPURL(string: "http://host/foo")!)) - XCTAssertTrue(url.isRelative(to: HTTPURL(string: "http://host/foo/bar")!)) - XCTAssertTrue(url.isRelative(to: HTTPURL(string: "http://host/foo/bar/baz")!)) - XCTAssertTrue(url.isRelative(to: HTTPURL(string: "http://host/bar")!)) + let url = try XCTUnwrap(HTTPURL(string: "http://host/foo/bar")) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://host/foo")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://host/foo/bar")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://host/foo/bar/baz")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://host/bar")))) // Different scheme - XCTAssertFalse(url.isRelative(to: UnknownAbsoluteURL(string: "other://host/foo")!)) - XCTAssertFalse(url.isRelative(to: HTTPURL(string: "https://host/foo")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "other://host/foo")))) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "https://host/foo")))) // Different host - XCTAssertFalse(url.isRelative(to: HTTPURL(string: "http://foo")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://foo")))) // Relative path - XCTAssertFalse(url.isRelative(to: RelativeURL(path: "foo/bar")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(RelativeURL(path: "foo/bar")))) } } diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift index 13ebfa33cf..9c440f979a 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift @@ -9,21 +9,21 @@ import Foundation import XCTest class UnknownAbsoluteURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - UnknownAbsoluteURL(string: "opds://domain.com")!, - UnknownAbsoluteURL(string: "opds://domain.com")! + UnknownAbsoluteURL(string: "opds://domain.com"), + UnknownAbsoluteURL(string: "opds://domain.com") ) XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://domain.com")!, - UnknownAbsoluteURL(string: "opds://domain.com#fragment")! + try XCTUnwrap(UnknownAbsoluteURL(string: "opds://domain.com")), + try XCTUnwrap(UnknownAbsoluteURL(string: "opds://domain.com#fragment")) ) } // MARK: - URLProtocol - func testCreateFromURL() { - XCTAssertEqual(UnknownAbsoluteURL(url: URL(string: "opds://callback")!)?.string, "opds://callback") + func testCreateFromURL() throws { + XCTAssertEqual(try UnknownAbsoluteURL(url: XCTUnwrap(URL(string: "opds://callback")))?.string, "opds://callback") } func testCreateFromString() { @@ -36,7 +36,7 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testURL() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar?query#fragment")?.url, URL(string: "opds://foo/bar?query#fragment")!) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar?query#fragment")?.url, URL(string: "opds://foo/bar?query#fragment")) } func testString() { @@ -52,8 +52,8 @@ class UnknownAbsoluteURLTests: XCTestCase { XCTAssertEqual(UnknownAbsoluteURL(string: "opds://host?query")?.path, "") } - func testAppendingPath() { - var base = UnknownAbsoluteURL(string: "opds://foo/bar")! + func testAppendingPath() throws { + var base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://foo/bar")) XCTAssertEqual(base.appendingPath("", isDirectory: false).string, "opds://foo/bar") XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "opds://foo/bar/baz/quz") XCTAssertEqual(base.appendingPath("/baz/quz", isDirectory: false).string, "opds://foo/bar/baz/quz") @@ -67,7 +67,7 @@ class UnknownAbsoluteURLTests: XCTestCase { XCTAssertEqual(base.appendingPath("baz/quz/", isDirectory: false).string, "opds://foo/bar/baz/quz") // With trailing slash. - base = UnknownAbsoluteURL(string: "opds://foo/bar/")! + base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://foo/bar/")) XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "opds://foo/bar/baz/quz") } @@ -90,10 +90,10 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testRemovingLastPathSegment() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://")!.removingLastPathSegment().string, "opds://") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo")!.removingLastPathSegment().string, "opds://foo") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")!.removingLastPathSegment().string, "opds://foo/") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/baz")!.removingLastPathSegment().string, "opds://foo/bar/") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://")?.removingLastPathSegment().string, "opds://") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo")?.removingLastPathSegment().string, "opds://foo") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingLastPathSegment().string, "opds://foo/") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/baz")?.removingLastPathSegment().string, "opds://foo/bar/") } func testPathExtension() { @@ -104,12 +104,12 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testReplacingPathExtension() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")!.replacingPathExtension("xml").string, "opds://foo/bar.xml") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar.txt")!.replacingPathExtension("xml").string, "opds://foo/bar.xml") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar.txt")!.replacingPathExtension(nil).string, "opds://foo/bar") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/")!.replacingPathExtension("xml").string, "opds://foo/bar/") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/")!.replacingPathExtension(nil).string, "opds://foo/bar/") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo")!.replacingPathExtension("xml").string, "opds://foo") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.replacingPathExtension("xml").string, "opds://foo/bar.xml") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar.txt")?.replacingPathExtension("xml").string, "opds://foo/bar.xml") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar.txt")?.replacingPathExtension(nil).string, "opds://foo/bar") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/")?.replacingPathExtension("xml").string, "opds://foo/bar/") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/")?.replacingPathExtension(nil).string, "opds://foo/bar/") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo")?.replacingPathExtension("xml").string, "opds://foo") } func testQuery() { @@ -121,8 +121,8 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testRemovingQuery() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingQuery(), UnknownAbsoluteURL(string: "opds://foo/bar")!) - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar?param=quz%20baz")?.removingQuery(), UnknownAbsoluteURL(string: "opds://foo/bar")!) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingQuery(), UnknownAbsoluteURL(string: "opds://foo/bar")) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar?param=quz%20baz")?.removingQuery(), UnknownAbsoluteURL(string: "opds://foo/bar")) } func testFragment() { @@ -131,8 +131,8 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testRemovingFragment() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingFragment(), UnknownAbsoluteURL(string: "opds://foo/bar")!) - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar#quz%20baz")?.removingFragment(), UnknownAbsoluteURL(string: "opds://foo/bar")!) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingFragment(), UnknownAbsoluteURL(string: "opds://foo/bar")) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar#quz%20baz")?.removingFragment(), UnknownAbsoluteURL(string: "opds://foo/bar")) } // MARK: - AbsoluteURL @@ -143,74 +143,74 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testHost() { - XCTAssertNil(UnknownAbsoluteURL(string: "opds://")!.host) - XCTAssertNil(UnknownAbsoluteURL(string: "opds:///")!.host) - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://domain")!.host, "domain") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://domain/path")!.host, "domain") + XCTAssertNil(UnknownAbsoluteURL(string: "opds://")?.host) + XCTAssertNil(UnknownAbsoluteURL(string: "opds:///")?.host) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://domain")?.host, "domain") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://domain/path")?.host, "domain") } func testOrigin() { - XCTAssertNil(UnknownAbsoluteURL(string: "opds://foo/bar")!.origin) + XCTAssertNil(UnknownAbsoluteURL(string: "opds://foo/bar")?.origin) } - func testResolveAbsoluteURL() { - let base = UnknownAbsoluteURL(string: "opds://host/foo/bar")! - XCTAssertEqual(base.resolve(UnknownAbsoluteURL(string: "opds://other")!)!.string, "opds://other") - XCTAssertEqual(base.resolve(HTTPURL(string: "http://domain.com")!)!.string, "http://domain.com") - XCTAssertEqual(base.resolve(FileURL(string: "file:///foo")!)!.string, "file:///foo") + func testResolveAbsoluteURL() throws { + let base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(UnknownAbsoluteURL(string: "opds://other")))?.string, "opds://other") + XCTAssertEqual(try base.resolve(XCTUnwrap(HTTPURL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try base.resolve(XCTUnwrap(FileURL(string: "file:///foo")))?.string, "file:///foo") } - func testResolveRelativeURL() { - var base = UnknownAbsoluteURL(string: "opds://host/foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, UnknownAbsoluteURL(string: "opds://host/foo/bar#fragment")!) + func testResolveRelativeURL() throws { + var base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), UnknownAbsoluteURL(string: "opds://host/foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), UnknownAbsoluteURL(string: "opds://host/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), UnknownAbsoluteURL(string: "opds://host/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), UnknownAbsoluteURL(string: "opds://host/foo/bar#fragment")) // With trailing slash - base = UnknownAbsoluteURL(string: "opds://host/foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/foo/quz/baz")!) + base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), UnknownAbsoluteURL(string: "opds://host/foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), UnknownAbsoluteURL(string: "opds://host/foo/quz/baz")) } - func testRelativize() { - var base = UnknownAbsoluteURL(string: "opds://host/foo")! + func testRelativize() throws { + var base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo")) - XCTAssertNil(base.relativize(AnyURL(string: "opds://host/foo")!)) - XCTAssertEqual(base.relativize(AnyURL(string: "opds://host/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.relativize(AnyURL(string: "opds://host/foo#fragment")!)!, RelativeURL(string: "#fragment")!) - XCTAssertNil(base.relativize(AnyURL(string: "opds://host/quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "opds://host//foo/bar")!)) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/foo")))) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/foo#fragment"))), RelativeURL(string: "#fragment")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host//foo/bar")))) // With trailing slash - base = UnknownAbsoluteURL(string: "opds://host/foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "opds://host/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) + base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/foo/quz/baz"))), RelativeURL(string: "quz/baz")) } - func testRelativizeRelativeURL() { - let base = UnknownAbsoluteURL(string: "opds://host/foo")! - XCTAssertNil(base.relativize(RelativeURL(string: "host/foo/bar")!)) + func testRelativizeRelativeURL() throws { + let base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(RelativeURL(string: "host/foo/bar")))) } - func testRelativizeAbsoluteURLWithDifferentScheme() { - let base = UnknownAbsoluteURL(string: "opds://host/foo")! - XCTAssertNil(base.relativize(HTTPURL(string: "http://host/foo/bar")!)) - XCTAssertNil(base.relativize(FileURL(string: "file://host/foo/bar")!)) + func testRelativizeAbsoluteURLWithDifferentScheme() throws { + let base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(HTTPURL(string: "http://host/foo/bar")))) + XCTAssertNil(try base.relativize(XCTUnwrap(FileURL(string: "file://host/foo/bar")))) } - func testIsRelative() { + func testIsRelative() throws { // Always relative if same scheme. - let url = UnknownAbsoluteURL(string: "opds://host/foo/bar")! - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://host/foo")!)) - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://host/foo/bar")!)) - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://host/foo/bar/baz")!)) - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://host/bar")!)) - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://other-host")!)) + let url = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar/baz")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/bar")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://other-host")))) // Different scheme - XCTAssertFalse(url.isRelative(to: UnknownAbsoluteURL(string: "other://host/foo")!)) - XCTAssertFalse(url.isRelative(to: HTTPURL(string: "http://foo")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "other://host/foo")))) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://foo")))) // Relative path - XCTAssertFalse(url.isRelative(to: RelativeURL(path: "foo/bar")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(RelativeURL(path: "foo/bar")))) } } diff --git a/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift b/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift index 9dc6084580..1a7b58ab3d 100644 --- a/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift @@ -9,23 +9,23 @@ import Foundation import XCTest class AnyURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - AnyURL(string: "opds://domain.com")!, - AnyURL(string: "opds://domain.com")! + AnyURL(string: "opds://domain.com"), + AnyURL(string: "opds://domain.com") ) XCTAssertNotEqual( - AnyURL(string: "opds://domain.com")!, - AnyURL(string: "https://domain.com")! + try XCTUnwrap(AnyURL(string: "opds://domain.com")), + try XCTUnwrap(AnyURL(string: "https://domain.com")) ) XCTAssertEqual( - AnyURL(string: "dir/file")!, - AnyURL(string: "dir/file")! + AnyURL(string: "dir/file"), + AnyURL(string: "dir/file") ) XCTAssertNotEqual( - AnyURL(string: "dir/file")!, - AnyURL(string: "dir/file#fragment")! + try XCTUnwrap(AnyURL(string: "dir/file")), + try XCTUnwrap(AnyURL(string: "dir/file#fragment")) ) } @@ -35,163 +35,163 @@ class AnyURLTests: XCTestCase { XCTAssertNil(AnyURL(string: "invalid character")) } - func testCreateFromRelativePath() { - XCTAssertEqual(AnyURL(string: "/foo/bar"), .relative(RelativeURL(string: "/foo/bar")!)) - XCTAssertEqual(AnyURL(string: "foo/bar"), .relative(RelativeURL(string: "foo/bar")!)) - XCTAssertEqual(AnyURL(string: "../bar"), .relative(RelativeURL(string: "../bar")!)) + func testCreateFromRelativePath() throws { + XCTAssertEqual(AnyURL(string: "/foo/bar"), try .relative(XCTUnwrap(RelativeURL(string: "/foo/bar")))) + XCTAssertEqual(AnyURL(string: "foo/bar"), try .relative(XCTUnwrap(RelativeURL(string: "foo/bar")))) + XCTAssertEqual(AnyURL(string: "../bar"), try .relative(XCTUnwrap(RelativeURL(string: "../bar")))) } - func testCreateFromAbsoluteURLs() { - XCTAssertEqual(AnyURL(string: "file:///foo/bar"), .absolute(FileURL(string: "file:///foo/bar")!)) - XCTAssertEqual(AnyURL(string: "http://host/foo/bar"), .absolute(HTTPURL(string: "http://host/foo/bar")!)) - XCTAssertEqual(AnyURL(string: "opds://host/foo/bar"), .absolute(UnknownAbsoluteURL(string: "opds://host/foo/bar")!)) + func testCreateFromAbsoluteURLs() throws { + XCTAssertEqual(AnyURL(string: "file:///foo/bar"), try .absolute(XCTUnwrap(FileURL(string: "file:///foo/bar")))) + XCTAssertEqual(AnyURL(string: "http://host/foo/bar"), try .absolute(XCTUnwrap(HTTPURL(string: "http://host/foo/bar")))) + XCTAssertEqual(AnyURL(string: "opds://host/foo/bar"), try .absolute(XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")))) } - func testCreateFromLegacyHREF() { - XCTAssertEqual(AnyURL(legacyHREF: "dir/chapter.xhtml"), .relative(RelativeURL(string: "dir/chapter.xhtml")!)) + func testCreateFromLegacyHREF() throws { + XCTAssertEqual(AnyURL(legacyHREF: "dir/chapter.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "dir/chapter.xhtml")))) // Starting slash is removed. - XCTAssertEqual(AnyURL(legacyHREF: "/dir/chapter.xhtml"), .relative(RelativeURL(string: "dir/chapter.xhtml")!)) + XCTAssertEqual(AnyURL(legacyHREF: "/dir/chapter.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "dir/chapter.xhtml")))) // Special characters are percent-encoded. - XCTAssertEqual(AnyURL(legacyHREF: "/dir/per%cent.xhtml"), .relative(RelativeURL(string: "dir/per%25cent.xhtml")!)) - XCTAssertEqual(AnyURL(legacyHREF: "/barré.xhtml"), .relative(RelativeURL(string: "barr%C3%A9.xhtml")!)) - XCTAssertEqual(AnyURL(legacyHREF: "/spa ce.xhtml"), .relative(RelativeURL(string: "spa%20ce.xhtml")!)) + XCTAssertEqual(AnyURL(legacyHREF: "/dir/per%cent.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "dir/per%25cent.xhtml")))) + XCTAssertEqual(AnyURL(legacyHREF: "/barré.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "barr%C3%A9.xhtml")))) + XCTAssertEqual(AnyURL(legacyHREF: "/spa ce.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "spa%20ce.xhtml")))) // We assume that a relative path is percent-decoded. - XCTAssertEqual(AnyURL(legacyHREF: "/spa%20ce.xhtml"), .relative(RelativeURL(string: "spa%2520ce.xhtml")!)) + XCTAssertEqual(AnyURL(legacyHREF: "/spa%20ce.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "spa%2520ce.xhtml")))) // Some special characters are authorized in a path. - XCTAssertEqual(AnyURL(legacyHREF: "/$&+,/=@"), .relative(RelativeURL(string: "$&+,/=@")!)) + XCTAssertEqual(AnyURL(legacyHREF: "/$&+,/=@"), try .relative(XCTUnwrap(RelativeURL(string: "$&+,/=@")))) // Valid absolute URL are left untouched. XCTAssertEqual( AnyURL(legacyHREF: "http://domain.com/a%20book?page=3"), - .absolute(HTTPURL(string: "http://domain.com/a%20book?page=3")!) + try .absolute(XCTUnwrap(HTTPURL(string: "http://domain.com/a%20book?page=3"))) ) } - func testResolveHTTPURL() { - var base = AnyURL(string: "http://example.com/foo/bar")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz/baz")!)!.string, "http://example.com/foo/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "../quz/baz")!)!.string, "http://example.com/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "/quz/baz")!)!.string, "http://example.com/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "#fragment")!)!.string, "http://example.com/foo/bar#fragment") - XCTAssertEqual(base.resolve(AnyURL(string: "file:///foo/bar")!)!.string, "file:///foo/bar") + func testResolveHTTPURL() throws { + var base = try XCTUnwrap(AnyURL(string: "http://example.com/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz/baz")))?.string, "http://example.com/foo/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "../quz/baz")))?.string, "http://example.com/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "/quz/baz")))?.string, "http://example.com/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "#fragment")))?.string, "http://example.com/foo/bar#fragment") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "file:///foo/bar")))?.string, "file:///foo/bar") // With trailing slash - base = AnyURL(string: "http://example.com/foo/bar/")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz/baz")!)!.string, "http://example.com/foo/bar/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "../quz/baz")!)!.string, "http://example.com/foo/quz/baz") + base = try XCTUnwrap(AnyURL(string: "http://example.com/foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz/baz")))?.string, "http://example.com/foo/bar/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "../quz/baz")))?.string, "http://example.com/foo/quz/baz") } - func testResolveFileURL() { - var base = AnyURL(string: "file:///root/foo/bar")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz")!)!.string, "file:///root/foo/quz") - XCTAssertEqual(base.resolve(AnyURL(string: "quz/baz")!)!.string, "file:///root/foo/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "../quz")!)!.string, "file:///root/quz") + func testResolveFileURL() throws { + var base = try XCTUnwrap(AnyURL(string: "file:///root/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz")))?.string, "file:///root/foo/quz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz/baz")))?.string, "file:///root/foo/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "../quz")))?.string, "file:///root/quz") // With trailing slash - base = AnyURL(string: "file:///root/foo/bar/")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz/baz")!)!.string, "file:///root/foo/bar/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "../quz")!)!.string, "file:///root/foo/quz") + base = try XCTUnwrap(AnyURL(string: "file:///root/foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz/baz")))?.string, "file:///root/foo/bar/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "../quz")))?.string, "file:///root/foo/quz") } - func testResolveTwoRelativeURLs() { - var base = RelativeURL(string: "foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, RelativeURL(string: "/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, RelativeURL(string: "foo/bar#fragment")!) + func testResolveTwoRelativeURLs() throws { + var base = try XCTUnwrap(RelativeURL(string: "foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), RelativeURL(string: "/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), RelativeURL(string: "foo/bar#fragment")) // With trailing slash - base = RelativeURL(string: "foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, RelativeURL(string: "foo/quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), RelativeURL(string: "foo/quz/baz")) // With starting slash - base = RelativeURL(string: "/foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "/foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, RelativeURL(string: "/quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "/foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), RelativeURL(string: "/quz/baz")) } - func testRelativizeHTTPURL() { - var base = AnyURL(string: "http://example.com/foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "http://example.com/foo/quz/baz")!)!.string, "quz/baz") - XCTAssertEqual(base.relativize(AnyURL(string: "http://example.com/foo#fragment")!)!.string, "#fragment") + func testRelativizeHTTPURL() throws { + var base = try XCTUnwrap(AnyURL(string: "http://example.com/foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo/quz/baz")))?.string, "quz/baz") + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo#fragment")))?.string, "#fragment") // With trailing slash - base = AnyURL(string: "http://example.com/foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "http://example.com/foo/quz/baz")!)!.string, "quz/baz") + base = try XCTUnwrap(AnyURL(string: "http://example.com/foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo/quz/baz")))?.string, "quz/baz") } - func testRelativizeFileURL() { - var base = AnyURL(string: "file:///root/foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "file:///root/foo/quz/baz")!)!.string, "quz/baz") - XCTAssertNil(base.relativize(AnyURL(string: "http://example.com/foo/bar")!)) + func testRelativizeFileURL() throws { + var base = try XCTUnwrap(AnyURL(string: "file:///root/foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "file:///root/foo/quz/baz")))?.string, "quz/baz") + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo/bar")))) // With trailing slash - base = AnyURL(string: "file:///root/foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "file:///root/foo/quz/baz")!)!.string, "quz/baz") + base = try XCTUnwrap(AnyURL(string: "file:///root/foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "file:///root/foo/quz/baz")))?.string, "quz/baz") } - func testRelativizeTwoRelativeURLs() { - var base = AnyURL(string: "foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "foo/quz/baz")!)!.string, "quz/baz") - XCTAssertEqual(base.relativize(AnyURL(string: "foo#fragment")!)!.string, "#fragment") - XCTAssertNil(base.relativize(AnyURL(string: "quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "/quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "http://example.com/foo/bar")!)) + func testRelativizeTwoRelativeURLs() throws { + var base = try XCTUnwrap(AnyURL(string: "foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz/baz")))?.string, "quz/baz") + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo#fragment")))?.string, "#fragment") + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "/quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo/bar")))) // With trailing slash - base = AnyURL(string: "foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "foo/quz/baz")!)!.string, "quz/baz") + base = try XCTUnwrap(AnyURL(string: "foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz/baz")))?.string, "quz/baz") // With starting slash - base = AnyURL(string: "/foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "/foo/quz/baz")!)!.string, "quz/baz") - XCTAssertNil(base.relativize(AnyURL(string: "/quz/baz")!)) + base = try XCTUnwrap(AnyURL(string: "/foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "/foo/quz/baz")))?.string, "quz/baz") + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "/quz/baz")))) } func testNormalized() { // Scheme is lower case. XCTAssertEqual( - AnyURL(string: "HTTP://example.com")!.normalized.string, + AnyURL(string: "HTTP://example.com")?.normalized.string, "http://example.com" ) // Path is percent-decoded. XCTAssertEqual( - AnyURL(string: "HTTP://example.com/c%27est%20valide")!.normalized.string, + AnyURL(string: "HTTP://example.com/c%27est%20valide")?.normalized.string, "http://example.com/c'est%20valide" ) XCTAssertEqual( - AnyURL(string: "c%27est%20valide")!.normalized.string, + AnyURL(string: "c%27est%20valide")?.normalized.string, "c'est%20valide" ) // Relative paths are resolved. XCTAssertEqual( - AnyURL(string: "http://example.com/foo/./bar/../baz")!.normalized.string, + AnyURL(string: "http://example.com/foo/./bar/../baz")?.normalized.string, "http://example.com/foo/baz" ) XCTAssertEqual( - AnyURL(string: "foo/./bar/../baz")!.normalized.string, + AnyURL(string: "foo/./bar/../baz")?.normalized.string, "foo/baz" ) XCTAssertEqual( - AnyURL(string: "foo/./bar/../../../baz")!.normalized.string, + AnyURL(string: "foo/./bar/../../../baz")?.normalized.string, "../baz" ) // Trailing slash is kept. XCTAssertEqual( - AnyURL(string: "http://example.com/foo/")!.normalized.string, + AnyURL(string: "http://example.com/foo/")?.normalized.string, "http://example.com/foo/" ) XCTAssertEqual( - AnyURL(string: "foo/")!.normalized.string, + AnyURL(string: "foo/")?.normalized.string, "foo/" ) // The other components are left as-is. XCTAssertEqual( - AnyURL(string: "http://user:password@example.com:443/foo?b=b&a=a#fragment")!.normalized.string, + AnyURL(string: "http://user:password@example.com:443/foo?b=b&a=a#fragment")?.normalized.string, "http://user:password@example.com:443/foo?b=b&a=a#fragment" ) } diff --git a/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift b/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift index 1390cd8d92..f6a982ff03 100644 --- a/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift @@ -4,34 +4,32 @@ // available in the top-level LICENSE file of the project. // -import Foundation - import Foundation @testable import ReadiumShared import XCTest class RelativeURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - RelativeURL(string: "dir/file")!, - RelativeURL(string: "dir/file")! + RelativeURL(string: "dir/file"), + RelativeURL(string: "dir/file") ) XCTAssertNotEqual( - RelativeURL(string: "dir/file/")!, - RelativeURL(string: "dir/file")! + try XCTUnwrap(RelativeURL(string: "dir/file/")), + try XCTUnwrap(RelativeURL(string: "dir/file")) ) XCTAssertNotEqual( - RelativeURL(string: "dir")!, - RelativeURL(string: "dir/file")! + try XCTUnwrap(RelativeURL(string: "dir")), + try XCTUnwrap(RelativeURL(string: "dir/file")) ) } // MARK: - URLProtocol - func testCreateFromURL() { - XCTAssertNil(RelativeURL(url: URL(string: "https://domain.com")!)) + func testCreateFromURL() throws { + XCTAssertNil(try RelativeURL(url: XCTUnwrap(URL(string: "https://domain.com")))) XCTAssertNil(RelativeURL(url: URL(fileURLWithPath: "/dir/file"))) - XCTAssertEqual(RelativeURL(url: URL(string: "/dir/file")!)?.string, "/dir/file") + XCTAssertEqual(try RelativeURL(url: XCTUnwrap(URL(string: "/dir/file")))?.string, "/dir/file") } func testCreateFromPath() { @@ -70,7 +68,7 @@ class RelativeURLTests: XCTestCase { } func testURL() { - XCTAssertEqual(RelativeURL(string: "foo/bar?query#fragment")?.url, URL(string: "foo/bar?query#fragment")!) + XCTAssertEqual(RelativeURL(string: "foo/bar?query#fragment")?.url, URL(string: "foo/bar?query#fragment")) } func testString() { @@ -87,8 +85,8 @@ class RelativeURLTests: XCTestCase { XCTAssertEqual(RelativeURL(string: "?query")?.path, "") } - func testAppendingPath() { - var base = RelativeURL(string: "foo/bar")! + func testAppendingPath() throws { + var base = try XCTUnwrap(RelativeURL(string: "foo/bar")) XCTAssertEqual(base.appendingPath("", isDirectory: false).string, "foo/bar") XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "foo/bar/baz/quz") XCTAssertEqual(base.appendingPath("/baz/quz", isDirectory: false).string, "foo/bar/baz/quz") @@ -102,7 +100,7 @@ class RelativeURLTests: XCTestCase { XCTAssertEqual(base.appendingPath("baz/quz/", isDirectory: false).string, "foo/bar/baz/quz") // With trailing slash. - base = RelativeURL(string: "foo/bar/")! + base = try XCTUnwrap(RelativeURL(string: "foo/bar/")) XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "foo/bar/baz/quz") } @@ -126,12 +124,12 @@ class RelativeURLTests: XCTestCase { } func testRemovingLastPathSegment() { - XCTAssertEqual(RelativeURL(string: "foo")!.removingLastPathSegment().string, "./") - XCTAssertEqual(RelativeURL(string: "foo/bar")!.removingLastPathSegment().string, "foo/") - XCTAssertEqual(RelativeURL(string: "foo/bar/")!.removingLastPathSegment().string, "foo/") - XCTAssertEqual(RelativeURL(string: "/foo")!.removingLastPathSegment().string, "/") - XCTAssertEqual(RelativeURL(string: "/foo/bar")!.removingLastPathSegment().string, "/foo/") - XCTAssertEqual(RelativeURL(string: "/foo/bar/")!.removingLastPathSegment().string, "/foo/") + XCTAssertEqual(RelativeURL(string: "foo")?.removingLastPathSegment().string, "./") + XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingLastPathSegment().string, "foo/") + XCTAssertEqual(RelativeURL(string: "foo/bar/")?.removingLastPathSegment().string, "foo/") + XCTAssertEqual(RelativeURL(string: "/foo")?.removingLastPathSegment().string, "/") + XCTAssertEqual(RelativeURL(string: "/foo/bar")?.removingLastPathSegment().string, "/foo/") + XCTAssertEqual(RelativeURL(string: "/foo/bar/")?.removingLastPathSegment().string, "/foo/") } func testPathExtension() { @@ -142,11 +140,11 @@ class RelativeURLTests: XCTestCase { } func testReplacingPathExtension() { - XCTAssertEqual(RelativeURL(string: "/foo/bar")!.replacingPathExtension("xml").string, "/foo/bar.xml") - XCTAssertEqual(RelativeURL(string: "/foo/bar.txt")!.replacingPathExtension("xml").string, "/foo/bar.xml") - XCTAssertEqual(RelativeURL(string: "/foo/bar.txt")!.replacingPathExtension(nil).string, "/foo/bar") - XCTAssertEqual(RelativeURL(string: "/foo/bar/")!.replacingPathExtension("xml").string, "/foo/bar/") - XCTAssertEqual(RelativeURL(string: "/foo/bar/")!.replacingPathExtension(nil).string, "/foo/bar/") + XCTAssertEqual(RelativeURL(string: "/foo/bar")?.replacingPathExtension("xml").string, "/foo/bar.xml") + XCTAssertEqual(RelativeURL(string: "/foo/bar.txt")?.replacingPathExtension("xml").string, "/foo/bar.xml") + XCTAssertEqual(RelativeURL(string: "/foo/bar.txt")?.replacingPathExtension(nil).string, "/foo/bar") + XCTAssertEqual(RelativeURL(string: "/foo/bar/")?.replacingPathExtension("xml").string, "/foo/bar/") + XCTAssertEqual(RelativeURL(string: "/foo/bar/")?.replacingPathExtension(nil).string, "/foo/bar/") } func testQuery() { @@ -158,8 +156,8 @@ class RelativeURLTests: XCTestCase { } func testRemovingQuery() { - XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingQuery(), RelativeURL(string: "foo/bar")!) - XCTAssertEqual(RelativeURL(string: "foo/bar?param=quz%20baz")?.removingQuery(), RelativeURL(string: "foo/bar")!) + XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingQuery(), RelativeURL(string: "foo/bar")) + XCTAssertEqual(RelativeURL(string: "foo/bar?param=quz%20baz")?.removingQuery(), RelativeURL(string: "foo/bar")) } func testFragment() { @@ -168,59 +166,59 @@ class RelativeURLTests: XCTestCase { } func testRemovingFragment() { - XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingFragment(), RelativeURL(string: "foo/bar")!) - XCTAssertEqual(RelativeURL(string: "foo/bar#quz%20baz")?.removingFragment(), RelativeURL(string: "foo/bar")!) + XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingFragment(), RelativeURL(string: "foo/bar")) + XCTAssertEqual(RelativeURL(string: "foo/bar#quz%20baz")?.removingFragment(), RelativeURL(string: "foo/bar")) } // MARK: - RelativeURL - func testResolveURLConvertible() { - let base = RelativeURL(string: "foo/bar")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz")!)?.string, "foo/quz") - XCTAssertEqual(base.resolve(HTTPURL(string: "http://domain.com")!)!.string, "http://domain.com") - XCTAssertEqual(base.resolve(FileURL(string: "file:///foo")!)!.string, "file:///foo") + func testResolveURLConvertible() throws { + let base = try XCTUnwrap(RelativeURL(string: "foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz")))?.string, "foo/quz") + XCTAssertEqual(try base.resolve(XCTUnwrap(HTTPURL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try base.resolve(XCTUnwrap(FileURL(string: "file:///foo")))?.string, "file:///foo") } - func testResolveRelativeURL() { - var base = RelativeURL(string: "foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, RelativeURL(string: "/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, RelativeURL(string: "foo/bar#fragment")!) + func testResolveRelativeURL() throws { + var base = try XCTUnwrap(RelativeURL(string: "foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), RelativeURL(string: "/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), RelativeURL(string: "foo/bar#fragment")) // With trailing slash - base = RelativeURL(string: "foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, RelativeURL(string: "foo/quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), RelativeURL(string: "foo/quz/baz")) // With starting slash - base = RelativeURL(string: "/foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "/foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, RelativeURL(string: "/quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "/foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), RelativeURL(string: "/quz/baz")) } - func testRelativize() { - var base = RelativeURL(string: "foo")! + func testRelativize() throws { + var base = try XCTUnwrap(RelativeURL(string: "foo")) - XCTAssertEqual(base.relativize(AnyURL(string: "foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.relativize(AnyURL(string: "foo#fragment")!)!, RelativeURL(string: "#fragment")!) - XCTAssertNil(base.relativize(AnyURL(string: "quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "/foo/bar")!)) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo#fragment"))), RelativeURL(string: "#fragment")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "/foo/bar")))) // With trailing slash - base = RelativeURL(string: "foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz/baz"))), RelativeURL(string: "quz/baz")) // With starting slash - base = RelativeURL(string: "/foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertNil(base.relativize(AnyURL(string: "foo/quz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "/quz/baz")!)) + base = try XCTUnwrap(RelativeURL(string: "/foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "/foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "/quz/baz")))) } - func testRelativizeAbsoluteURL() { - let base = RelativeURL(string: "foo")! - XCTAssertNil(base.relativize(HTTPURL(string: "http://example.com/foo/bar")!)) - XCTAssertNil(base.relativize(FileURL(string: "file:///foo")!)) + func testRelativizeAbsoluteURL() throws { + let base = try XCTUnwrap(RelativeURL(string: "foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(HTTPURL(string: "http://example.com/foo/bar")))) + XCTAssertNil(try base.relativize(XCTUnwrap(FileURL(string: "file:///foo")))) } } diff --git a/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift b/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift index 2fa0ff10c9..51701c35f1 100644 --- a/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift +++ b/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift @@ -10,13 +10,13 @@ import XCTest class URLQueryTests: XCTestCase { func testParseEmptyQuery() throws { - let query = URLQuery(url: URL(string: "foo")!) + let query = try URLQuery(url: XCTUnwrap(URL(string: "foo"))) XCTAssertNil(query) } func testGetFirstQueryParameterNamedX() throws { - let query = try XCTUnwrap(URLQuery( - url: URL(string: "foo?query=param&fruit=banana&query=other&empty")! + let query = try XCTUnwrap(try URLQuery( + url: XCTUnwrap(URL(string: "foo?query=param&fruit=banana&query=other&empty")) )) XCTAssertEqual(query.first(named: "query"), "param") @@ -26,8 +26,8 @@ class URLQueryTests: XCTestCase { } func testGetAllQueryParametersNamedX() throws { - let query = try XCTUnwrap(URLQuery( - url: URL(string: "foo?query=param&fruit=banana&query=other&empty")! + let query = try XCTUnwrap(try URLQuery( + url: XCTUnwrap(URL(string: "foo?query=param&fruit=banana&query=other&empty")) )) XCTAssertEqual(query.all(named: "query"), ["param", "other"]) @@ -37,8 +37,8 @@ class URLQueryTests: XCTestCase { } func testQueryParameterArePercentDecoded() throws { - let query = try XCTUnwrap(URLQuery( - url: URL(string: "foo?query=hello%20world")! + let query = try XCTUnwrap(try URLQuery( + url: XCTUnwrap(URL(string: "foo?query=hello%20world")) )) XCTAssertEqual(query.first(named: "query"), "hello world") } diff --git a/Tests/SharedTests/Toolkit/XML/XMLTests.swift b/Tests/SharedTests/Toolkit/XML/XMLTests.swift index 6931dd2c43..5a1653d6a5 100644 --- a/Tests/SharedTests/Toolkit/XML/XMLTests.swift +++ b/Tests/SharedTests/Toolkit/XML/XMLTests.swift @@ -96,12 +96,35 @@ class FuziTests: XCTestCase { try FuziXMLDocument(string: xml, namespaces: namespaces) } - func testParseInvalidXML() { tester.testParseValidXML() } - func testParseValidXML() { tester.testParseValidXML() } - func testParseHTML5() { tester.testParseHTML5() } - func testDocumentElement() throws { try tester.testDocumentElement() } - func testFirstElement() throws { try tester.testFirstElement() } - func testAllElements() throws { try tester.testAllElements() } - func testLocalName() throws { try tester.testLocalName() } - func testAttribute() throws { try tester.testAttribute() } + func testParseInvalidXML() { + tester.testParseInvalidXML() + } + + func testParseValidXML() { + tester.testParseValidXML() + } + + func testParseHTML5() { + tester.testParseHTML5() + } + + func testDocumentElement() throws { + try tester.testDocumentElement() + } + + func testFirstElement() throws { + try tester.testFirstElement() + } + + func testAllElements() throws { + try tester.testAllElements() + } + + func testLocalName() throws { + try tester.testLocalName() + } + + func testAttribute() throws { + try tester.testAttribute() + } } diff --git a/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift b/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift index 236c9df436..34b26ca6c4 100644 --- a/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift +++ b/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift @@ -34,7 +34,7 @@ class MinizipContainerTests: XCTestCase { func testGetNonExistingEntry() async throws { let container = try await container(for: "test.zip") - XCTAssertNil(container[AnyURL(path: "unknown")!]) + XCTAssertNil(try container[XCTUnwrap(AnyURL(path: "unknown"))]) } func testEntries() async throws { @@ -42,14 +42,14 @@ class MinizipContainerTests: XCTestCase { XCTAssertEqual( container.entries, - Set([ - AnyURL(path: ".hidden")!, - AnyURL(path: "A folder/Sub.folder%/file.txt")!, - AnyURL(path: "A folder/wasteland-cover.jpg")!, - AnyURL(path: "root.txt")!, - AnyURL(path: "uncompressed.jpg")!, - AnyURL(path: "uncompressed.txt")!, - AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!, + try Set([ + XCTUnwrap(AnyURL(path: ".hidden")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt")), + XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg")), + XCTUnwrap(AnyURL(path: "root.txt")), + XCTUnwrap(AnyURL(path: "uncompressed.jpg")), + XCTUnwrap(AnyURL(path: "uncompressed.txt")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")), ]) ) } @@ -68,16 +68,16 @@ class MinizipContainerTests: XCTestCase { func testReadCompressedEntry() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) let data = try await entry.read().get() - let string = String(data: data, encoding: .utf8)! + let string = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertEqual(string.count, 29609) XCTAssertTrue(string.hasPrefix("I'm inside\nthe ZIP.")) } func testReadUncompressedEntry() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read().get() XCTAssertNotNil(data) XCTAssertEqual( @@ -89,7 +89,7 @@ class MinizipContainerTests: XCTestCase { func testReadUncompressedRange() async throws { // FIXME: It looks like unzseek64 starts from the beginning of the file header, instead of the content. Reading a first byte solves this but then Minizip crashes randomly... Note that this only fails in the test case. I didn't see actual issues in LCPDF or videos embedded in EPUBs. let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -99,7 +99,7 @@ class MinizipContainerTests: XCTestCase { func testReadCompressedRange() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -110,7 +110,7 @@ class MinizipContainerTests: XCTestCase { func testRandomCompressedRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/wasteland-cover.jpg")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg"))]) let length: UInt64 = 103_477 let lower = UInt64.random(in: 0 ..< length - 100) let upper = UInt64.random(in: lower ..< length) @@ -122,7 +122,7 @@ class MinizipContainerTests: XCTestCase { func testRandomStoredRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "uncompressed.jpg")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "uncompressed.jpg"))]) let length: UInt64 = 279_551 let lower = UInt64.random(in: 0 ..< length - 100) let upper = UInt64.random(in: lower ..< length) diff --git a/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift b/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift index 319a9c1b07..bebf759ee0 100644 --- a/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift +++ b/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift @@ -34,7 +34,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testGetNonExistingEntry() async throws { let container = try await container(for: "test.zip") - XCTAssertNil(container[AnyURL(path: "unknown")!]) + XCTAssertNil(try container[XCTUnwrap(AnyURL(path: "unknown"))]) } func testEntries() async throws { @@ -42,14 +42,14 @@ class ZIPFoundationContainerTests: XCTestCase { XCTAssertEqual( container.entries, - Set([ - AnyURL(path: ".hidden")!, - AnyURL(path: "A folder/Sub.folder%/file.txt")!, - AnyURL(path: "A folder/wasteland-cover.jpg")!, - AnyURL(path: "root.txt")!, - AnyURL(path: "uncompressed.jpg")!, - AnyURL(path: "uncompressed.txt")!, - AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!, + try Set([ + XCTUnwrap(AnyURL(path: ".hidden")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt")), + XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg")), + XCTUnwrap(AnyURL(path: "root.txt")), + XCTUnwrap(AnyURL(path: "uncompressed.jpg")), + XCTUnwrap(AnyURL(path: "uncompressed.txt")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")), ]) ) } @@ -68,16 +68,16 @@ class ZIPFoundationContainerTests: XCTestCase { func testReadCompressedEntry() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) let data = try await entry.read().get() - let string = String(data: data, encoding: .utf8)! + let string = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertEqual(string.count, 29609) XCTAssertTrue(string.hasPrefix("I'm inside\nthe ZIP.")) } func testReadUncompressedEntry() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read().get() XCTAssertNotNil(data) XCTAssertEqual( @@ -88,7 +88,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testReadUncompressedRange() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -98,7 +98,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testReadCompressedRange() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -109,7 +109,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testRandomCompressedRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/wasteland-cover.jpg")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg"))]) let length: UInt64 = 103_477 let lower = UInt64.random(in: 0 ..< length - 100) let upper = UInt64.random(in: lower ..< length) @@ -121,7 +121,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testRandomStoredRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "uncompressed.jpg")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "uncompressed.jpg"))]) let length: UInt64 = 279_551 let lower = UInt64.random(in: 0 ..< length - 100) let upper = UInt64.random(in: lower ..< length) diff --git a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift index c5bd99899d..aba1465b6a 100644 --- a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift +++ b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift @@ -69,16 +69,16 @@ class AudioLocatorServiceTests: XCTestCase { ) } - func testLocateLocatorUsingTotalProgressionKeepsTitleAndText() async { + func testLocateLocatorUsingTotalProgressionKeepsTitleAndText() async throws { let service = makeService(readingOrder: [ Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) - let result = await service.locate( + let result = try await service.locate( Locator( href: "wrong", - mediaType: MediaType("text/plain")!, + mediaType: XCTUnwrap(MediaType("text/plain")), title: "Title", locations: .init( fragments: ["ignored"], diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift index bc4e377adf..c91f95849a 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift @@ -11,18 +11,18 @@ import XCTest class EPUBEncryptionParserTests: XCTestCase { let fixtures = Fixtures(path: "Encryption") - func testParseLCPEncryption() { + func testParseLCPEncryption() throws { let sut = parseEncryptions("encryption-lcp") - XCTAssertEqual(sut, [ - RelativeURL(path: "chapter01.xhtml")!: Encryption( + XCTAssertEqual(sut, try [ + XCTUnwrap(RelativeURL(path: "chapter01.xhtml")): Encryption( algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc", compression: "deflate", originalLength: 13291, profile: nil, scheme: "http://readium.org/2014/01/lcp" ), - RelativeURL(path: "dir/chapter02.xhtml")!: Encryption( + XCTUnwrap(RelativeURL(path: "dir/chapter02.xhtml")): Encryption( algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc", compression: "none", originalLength: 12914, @@ -32,18 +32,18 @@ class EPUBEncryptionParserTests: XCTestCase { ]) } - func testParseEncryptionWithNamespaces() { + func testParseEncryptionWithNamespaces() throws { let sut = parseEncryptions("encryption-lcp-namespaces") - XCTAssertEqual(sut, [ - RelativeURL(path: "chapter01.xhtml")!: Encryption( + XCTAssertEqual(sut, try [ + XCTUnwrap(RelativeURL(path: "chapter01.xhtml")): Encryption( algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc", compression: "deflate", originalLength: 13291, profile: nil, scheme: "http://readium.org/2014/01/lcp" ), - RelativeURL(path: "dir/chapter02.xhtml")!: Encryption( + XCTUnwrap(RelativeURL(path: "dir/chapter02.xhtml")): Encryption( algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc", compression: "none", originalLength: 12914, diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift index fa0bfcea2e..bbea011fdc 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift @@ -21,7 +21,7 @@ class EPUBManifestParserTests: XCTestCase { XCTAssertEqual( manifest, - Manifest( + try Manifest( metadata: Metadata( identifier: "urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41", conformsTo: [.epub], @@ -74,7 +74,7 @@ class EPUBManifestParserTests: XCTestCase { link(href: "EPUB/chapter02.xhtml", mediaType: .xhtml), ], resources: [ - link(href: "EPUB/fonts/MinionPro.otf", mediaType: MediaType("application/vnd.ms-opentype")!), + link(href: "EPUB/fonts/MinionPro.otf", mediaType: XCTUnwrap(MediaType("application/vnd.ms-opentype"))), link(href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), link(href: "EPUB/style.css", mediaType: .css), link(href: "EPUB/images/alice01a.gif", mediaType: .gif, rels: [.cover]), diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift index 25877ed2e3..ded61322bd 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift @@ -321,9 +321,9 @@ class EPUBMetadataParserTests: XCTestCase { let sut = try parseMetadata("tdm-epub2") XCTAssertEqual( sut.tdm, - TDM( + try TDM( reservation: .all, - policy: HTTPURL(string: "https://provider.com/policies/policy.json")! + policy: XCTUnwrap(HTTPURL(string: "https://provider.com/policies/policy.json")) ) ) } @@ -332,9 +332,9 @@ class EPUBMetadataParserTests: XCTestCase { let sut = try parseMetadata("tdm-epub3") XCTAssertEqual( sut.tdm, - TDM( + try TDM( reservation: .all, - policy: HTTPURL(string: "https://provider.com/policies/policy.json")! + policy: XCTUnwrap(HTTPURL(string: "https://provider.com/policies/policy.json")) ) ) } diff --git a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift index 9a9d07f4db..1ecda1e787 100644 --- a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift @@ -49,8 +49,8 @@ class OPFParserTests: XCTestCase { link(href: "titlepage.xhtml", mediaType: .xhtml), link(href: "EPUB/chapter01.xhtml", mediaType: .xhtml), ]) - XCTAssertEqual(sut.resources, [ - link(href: "EPUB/fonts/MinionPro.otf", mediaType: MediaType("application/vnd.ms-opentype")!), + 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), diff --git a/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift b/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift index f04a0c6d62..7d9a7c61c3 100644 --- a/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift @@ -29,7 +29,7 @@ class EPUBDeobfuscatorTests: XCTestCase { XCTAssertEqual(result, .success(font)) } - // Fix for https://github.com/readium/r2-streamer-swift/issues/208 + /// Fix for https://github.com/readium/r2-streamer-swift/issues/208 func testEmptyPublicationID() async throws { let file = fixtures.data(at: "nav.xhtml") diff --git a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift index fa8b66facd..e8deab160e 100644 --- a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift @@ -357,7 +357,9 @@ private class MockContainer: Container { let sourceURL: AbsoluteURL? = nil - var entries: Set { Set(readingOrder.map { $0.1.url() }) } + var entries: Set { + Set(readingOrder.map { $0.1.url() }) + } subscript(url: any URLConvertible) -> (any Resource)? { guard let (length, _, archiveProperties) = readingOrder.first(where: { _, link, _ in link.url().isEquivalentTo(url) }) else { diff --git a/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift b/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift index c740337099..a30421149f 100644 --- a/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift @@ -11,7 +11,7 @@ import XCTest class ComicInfoParserTests: XCTestCase { // MARK: - Basic Parsing - func testParseMinimalComicInfo() { + func testParseMinimalComicInfo() throws { let xml = """ @@ -19,13 +19,13 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertNotNil(result) XCTAssertEqual(result?.title, "Test Issue") } - func testParseCompleteComicInfo() { + func testParseCompleteComicInfo() throws { let xml = """ @@ -52,7 +52,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertNotNil(result) XCTAssertEqual(result?.title, "The Beginning") @@ -77,15 +77,15 @@ class ComicInfoParserTests: XCTestCase { XCTAssertEqual(result?.gtin, "978-1234567890") } - func testParseReturnsNilForInvalidXML() { + func testParseReturnsNilForInvalidXML() throws { let xml = "not valid xml" - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertNil(result) } - func testParseReturnsNilForWrongRootElement() { + func testParseReturnsNilForWrongRootElement() throws { let xml = """ @@ -93,14 +93,14 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertNil(result) } // MARK: - Other Metadata - func testOtherMetadataCollectsUnknownTags() { + func testOtherMetadataCollectsUnknownTags() throws { let xml = """ @@ -112,7 +112,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertEqual(result?.otherMetadata["Volume"], "2") XCTAssertEqual(result?.otherMetadata["Characters"], "Batman, Robin") @@ -122,7 +122,7 @@ class ComicInfoParserTests: XCTestCase { // MARK: - Cover Page Detection - func testFirstPageWithTypeFrontCover() { + func testFirstPageWithTypeFrontCover() throws { let xml = """ @@ -135,12 +135,12 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertEqual(result?.firstPageWithType(.frontCover)?.image, 1) } - func testFirstPageWithTypeReturnsNilWhenNoCover() { + func testFirstPageWithTypeReturnsNilWhenNoCover() throws { let xml = """ @@ -152,12 +152,12 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertNil(result?.firstPageWithType(.frontCover)) } - func testFirstPageWithTypeReturnsNilWhenNoPagesElement() { + func testFirstPageWithTypeReturnsNilWhenNoPagesElement() throws { let xml = """ @@ -165,7 +165,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertNil(result?.firstPageWithType(.frontCover)) } @@ -196,7 +196,7 @@ class ComicInfoParserTests: XCTestCase { // MARK: - PageInfo Parsing - func testPageInfoParsesAllAttributes() { + func testPageInfoParsesAllAttributes() throws { let xml = """ @@ -206,7 +206,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertEqual(result?.pages.count, 1) let page = result?.pages.first @@ -220,7 +220,7 @@ class ComicInfoParserTests: XCTestCase { XCTAssertEqual(page?.imageHeight, 1200) } - func testPageInfoRequiresImageAttribute() { + func testPageInfoRequiresImageAttribute() throws { let xml = """ @@ -231,14 +231,14 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) // Only the page with Image attribute should be parsed XCTAssertEqual(result?.pages.count, 1) XCTAssertEqual(result?.pages.first?.image, 1) } - func testPageInfoWithMinimalAttributes() { + func testPageInfoWithMinimalAttributes() throws { let xml = """ @@ -248,7 +248,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertEqual(result?.pages.count, 1) let page = result?.pages.first @@ -262,7 +262,7 @@ class ComicInfoParserTests: XCTestCase { XCTAssertNil(page?.imageHeight) } - func testPageInfoDoublePageBooleanParsing() { + func testPageInfoDoublePageBooleanParsing() throws { let xml = """ @@ -276,7 +276,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertEqual(result?.pages.count, 5) XCTAssertEqual(result?.pages[0].doublePage, true) @@ -288,7 +288,7 @@ class ComicInfoParserTests: XCTestCase { // MARK: - Story Start Detection - func testFirstPageWithTypeStory() { + func testFirstPageWithTypeStory() throws { let xml = """ @@ -301,13 +301,13 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertEqual(result?.firstPageWithType(.frontCover)?.image, 0) XCTAssertEqual(result?.firstPageWithType(.story)?.image, 2) } - func testFirstPageWithTypeStoryReturnsNilWhenNoStoryPages() { + func testFirstPageWithTypeStoryReturnsNilWhenNoStoryPages() throws { let xml = """ @@ -318,13 +318,13 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertEqual(result?.firstPageWithType(.frontCover)?.image, 0) XCTAssertNil(result?.firstPageWithType(.story)) } - func testFirstPageWithTypeStoryReturnsNilWhenNoPagesElement() { + func testFirstPageWithTypeStoryReturnsNilWhenNoPagesElement() throws { let xml = """ @@ -332,14 +332,14 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) XCTAssertNil(result?.firstPageWithType(.story)) } // MARK: - Metadata Conversion - func testToMetadataWithSeriesAndNumber() { + func testToMetadataWithSeriesAndNumber() throws { let xml = """ @@ -349,7 +349,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.title, "Issue 5") @@ -358,7 +358,7 @@ class ComicInfoParserTests: XCTestCase { XCTAssertEqual(metadata?.belongsToSeries.first?.position, 5.0) } - func testToMetadataWithAlternateSeries() { + func testToMetadataWithAlternateSeries() throws { let xml = """ @@ -370,7 +370,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.belongsToSeries.count, 2) @@ -380,7 +380,7 @@ class ComicInfoParserTests: XCTestCase { XCTAssertEqual(metadata?.belongsToSeries[1].position, 3.0) } - func testToMetadataWithFractionalNumber() { + func testToMetadataWithFractionalNumber() throws { let xml = """ @@ -390,13 +390,13 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.belongsToSeries.first?.position, 5.5) } - func testToMetadataWithNonNumericNumber() { + func testToMetadataWithNonNumericNumber() throws { let xml = """ @@ -406,14 +406,14 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() // Non-numeric number should result in nil position XCTAssertNil(metadata?.belongsToSeries.first?.position) } - func testToMetadataMangaYesAndRightToLeftSetsRTL() { + func testToMetadataMangaYesAndRightToLeftSetsRTL() throws { let xml = """ @@ -422,13 +422,13 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.readingProgression, .rtl) } - func testToMetadataMangaYesDoesNotSetRTL() { + func testToMetadataMangaYesDoesNotSetRTL() throws { let xml = """ @@ -437,13 +437,13 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.readingProgression, .auto) } - func testToMetadataMangaNoSetsAuto() { + func testToMetadataMangaNoSetsAuto() throws { let xml = """ @@ -452,13 +452,13 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.readingProgression, .auto) } - func testToMetadataMangaCaseInsensitiveParsing() { + func testToMetadataMangaCaseInsensitiveParsing() throws { let xml = """ @@ -467,13 +467,13 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.readingProgression, .rtl) } - func testToMetadataContributors() { + func testToMetadataContributors() throws { let xml = """ @@ -484,7 +484,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.authors.count, 2) @@ -496,7 +496,7 @@ class ComicInfoParserTests: XCTestCase { XCTAssertEqual(metadata?.contributors.first?.roles, ["cov"]) } - func testToMetadataSubjects() { + func testToMetadataSubjects() throws { let xml = """ @@ -505,14 +505,14 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.subjects.count, 3) XCTAssertEqual(metadata?.subjects.map(\.name), ["Superhero", "Action", "Adventure"]) } - func testToMetadataPublishedDate() { + func testToMetadataPublishedDate() throws { let xml = """ @@ -523,18 +523,18 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() let calendar = Calendar(identifier: .gregorian) - let components = calendar.dateComponents([.year, .month, .day], from: metadata!.published!) + let components = try calendar.dateComponents([.year, .month, .day], from: XCTUnwrap(metadata?.published)) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 6) XCTAssertEqual(components.day, 15) } - func testToMetadataPublishedDateYearOnly() { + func testToMetadataPublishedDateYearOnly() throws { let xml = """ @@ -543,18 +543,18 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() let calendar = Calendar(identifier: .gregorian) - let components = calendar.dateComponents([.year, .month, .day], from: metadata!.published!) + let components = try calendar.dateComponents([.year, .month, .day], from: XCTUnwrap(metadata?.published)) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 1) // Default to January XCTAssertEqual(components.day, 1) // Default to 1st } - func testToMetadataOtherMetadata() { + func testToMetadataOtherMetadata() throws { let xml = """ @@ -565,7 +565,7 @@ class ComicInfoParserTests: XCTestCase { """ - let result = ComicInfoParser.parse(data: xml.data(using: .utf8)!, warnings: nil) + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) let metadata = result?.toMetadata() XCTAssertEqual(metadata?.otherMetadata["https://anansi-project.github.io/docs/comicinfo/documentation#volume"] as? String, "2") diff --git a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index 6e26add569..cac26af9b2 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -92,9 +92,9 @@ class ImageParserTests: XCTestCase { let publication = try await parser.parse(asset: cbzAsset, warnings: nil).get().build() let result = try await publication.positions().get() - XCTAssertEqual(result, [ + XCTAssertEqual(result, try [ Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/a-fc.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/a-fc.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 0, @@ -102,7 +102,7 @@ class ImageParserTests: XCTestCase { ) ), Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-002.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-002.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 1 / 5.0, @@ -110,7 +110,7 @@ class ImageParserTests: XCTestCase { ) ), Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-003.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-003.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 2 / 5.0, @@ -118,7 +118,7 @@ class ImageParserTests: XCTestCase { ) ), Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-153.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-153.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 3 / 5.0, @@ -126,7 +126,7 @@ class ImageParserTests: XCTestCase { ) ), Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/z-bc.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/z-bc.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 4 / 5.0, From 5208b528993daa5123857e7078a3e3af8c781ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 17 Feb 2026 16:45:16 +0100 Subject: [PATCH 38/55] Remove the HTTP server from the EPUB navigator (#723) --- CHANGELOG.md | 7 + Sources/Internal/Extensions/Range.swift | 40 ++ .../EPUB/CSS/HTMLFontFamilyDeclaration.swift | 16 +- Sources/Navigator/EPUB/CSS/ReadiumCSS.swift | 2 +- .../Navigator/EPUB/EPUBFixedSpreadView.swift | 2 +- .../EPUB/EPUBNavigatorViewController.swift | 28 +- .../EPUB/EPUBNavigatorViewModel.swift | 133 +++---- Sources/Navigator/EPUB/EPUBSpread.swift | 12 +- Sources/Navigator/EPUB/EPUBSpreadView.swift | 17 +- Sources/Navigator/EPUB/WebViewServer.swift | 356 ++++++++++++++++++ Sources/Navigator/Toolkit/WebView.swift | 19 +- Sources/Shared/Publication/Link.swift | 4 +- .../Data/Resource/BufferingResource.swift | 97 +++-- .../Data/Resource/TransformingResource.swift | 6 +- .../URL/Absolute URL/AbsoluteURL.swift | 2 +- Support/Carthage/.xcodegen | 1 + .../Readium.xcodeproj/project.pbxproj | 4 + TestApp/Sources/App/AppModule.swift | 2 +- TestApp/Sources/Reader/EPUB/EPUBModule.swift | 3 +- .../Reader/EPUB/EPUBViewController.swift | 6 +- .../InternalTests/Extensions/RangeTests.swift | 125 ++++++ .../NavigatorTestHost/AccessibilityID.swift | 2 + .../UITests/NavigatorTestHost/Container.swift | 3 +- .../NavigatorTestHost/ReaderView.swift | 28 ++ .../NavigationStressTests.swift | 33 ++ docs/Guides/Getting Started.md | 2 +- docs/Guides/Navigator/EPUB Fonts.md | 3 +- docs/Guides/Navigator/Navigator.md | 12 +- docs/Migration Guide.md | 17 + 29 files changed, 788 insertions(+), 194 deletions(-) create mode 100644 Sources/Navigator/EPUB/WebViewServer.swift create mode 100644 Tests/InternalTests/Extensions/RangeTests.swift create mode 100644 Tests/NavigatorTests/UITests/NavigatorUITests/NavigationStressTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b00a5d9a9..8480b8ede4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ All notable changes to this project will be documented in this file. Take a look * Persist across app reinstalls. * Optionally synchronized across devices via iCloud Keychain. +### Changed + +#### Navigator + +* The EPUB navigator no longer requires an HTTP server. Publication resources are now served directly to the web views using a custom URL scheme handler. + * The `httpServer` parameter of `EPUBNavigatorViewController` is deprecated and ignored. + ### Deprecated #### Navigator diff --git a/Sources/Internal/Extensions/Range.swift b/Sources/Internal/Extensions/Range.swift index 7ffc891d1b..546ce91bc6 100644 --- a/Sources/Internal/Extensions/Range.swift +++ b/Sources/Internal/Extensions/Range.swift @@ -10,4 +10,44 @@ public extension Range where Bound == UInt64 { func clampedToInt() -> Range { clamped(to: 0 ..< UInt64(Int.max)) } + + /// Parses an HTTP `Range` header value (RFC 7233) into a byte range. + /// + /// Supports: + /// - `bytes=0-1023` → `0..<1024` + /// - `bytes=1024-` → `1024.. 0 else { return nil } + let start = totalLength > suffix ? totalLength - suffix : 0 + self = start ..< totalLength + return + } + + let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, let start = UInt64(parts[0]) else { return nil } + + if parts[1].isEmpty { + // Open-ended range: bytes=N- + guard start < totalLength else { return nil } + self = start ..< totalLength + return + } + + // Closed range: bytes=N-M + guard let end = UInt64(parts[1]), end >= start else { return nil } + let clampedEnd = Swift.min(end + 1, totalLength) + guard start < clampedEnd else { return nil } + self = start ..< clampedEnd + } } diff --git a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift index 493183e025..85a3b94507 100644 --- a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift +++ b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift @@ -19,16 +19,16 @@ public protocol HTMLFontFamilyDeclaration { /// Injects this font family declaration in the given `html` document. /// - /// Use `servingFile` to convert a file URL into an http one to make a local - /// file available to the web views. - func inject(in html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String + /// Use `servingFile` to convert a file URL into a URL accessible from the + /// web views. + func inject(in html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String } /// A type-erasing `HTMLFontFamilyDeclaration` object public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration { private let _fontFamily: () -> FontFamily private let _alternates: () -> [FontFamily] - private let _inject: (String, (FileURL) throws -> HTTPURL) throws -> String + private let _inject: (String, (FileURL) throws -> any AbsoluteURL) throws -> String public var fontFamily: FontFamily { _fontFamily() @@ -44,7 +44,7 @@ public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration { _inject = { try declaration.inject(in: $0, servingFile: $1) } } - public func inject(in html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String { + public func inject(in html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String { try _inject(html, servingFile) } } @@ -70,7 +70,7 @@ public struct CSSFontFamilyDeclaration: HTMLFontFamilyDeclaration { self.fontFaces = fontFaces } - public func inject(in html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String { + public func inject(in html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String { var injections = try fontFaces.flatMap { try $0.injections(for: html, servingFile: servingFile) } @@ -122,7 +122,7 @@ public struct CSSFontFace { return copy } - func injections(for html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> [HTMLInjection] { + func injections(for html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> [HTMLInjection] { try sources .filter(\.preload) .map { source in @@ -131,7 +131,7 @@ public struct CSSFontFace { } } - func css(for fontFamily: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String { + func css(for fontFamily: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String { let urls = try sources.map { try servingFile($0.file) } var descriptors: [String: String] = [ "font-family": "\"\(fontFamily)\"", diff --git a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift index 44ea767458..e70a8bc1c7 100644 --- a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift +++ b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift @@ -15,7 +15,7 @@ struct ReadiumCSS { var userProperties: CSSUserProperties = .init() /// Base URL of the Readium CSS assets. - var baseURL: HTTPURL + var baseURL: any AbsoluteURL var fontFamilyDeclarations: [AnyHTMLFontFamilyDeclaration] = [] } diff --git a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift index d88a938a4c..f9f92e5b62 100644 --- a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift @@ -54,7 +54,7 @@ final class EPUBFixedSpreadView: EPUBSpreadView { { wrapperPage = wrapperPage.replacingOccurrences( of: "{{ASSETS_URL}}", - with: viewModel.assetsURL.string + with: viewModel.assetsBaseURL.string ) // The publication's base URL is used to make sure we can access the resources through the iframe with JavaScript. diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 1b9b636dd6..77ccf8cd4d 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -43,6 +43,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, /// Failed to serve the publication or assets with the provided HTTP /// server. + @available(*, deprecated, message: "The HTTP server is no longer needed for the EPUB navigator.") case serverFailure(Error) } @@ -287,14 +288,11 @@ open class EPUBNavigatorViewController: InputObservableViewController, /// - readingOrder: Custom order of resources to display. Used for example /// to display a non-linear resource on its own. /// - config: Additional navigator configuration. - /// - httpServer: HTTP server used to serve the publication resources to - /// the web views. public convenience init( publication: Publication, initialLocation: Locator?, readingOrder: [Link]? = nil, - config: Configuration = .init(), - httpServer: HTTPServer + config: Configuration = .init() ) throws { precondition(readingOrder.map { !$0.isEmpty } ?? true) @@ -302,11 +300,10 @@ open class EPUBNavigatorViewController: InputObservableViewController, throw EPUBError.publicationRestricted } - let viewModel = try EPUBNavigatorViewModel( + let viewModel = EPUBNavigatorViewModel( publication: publication, readingOrder: readingOrder ?? publication.readingOrder, - config: config, - httpServer: httpServer + config: config ) self.init( @@ -323,6 +320,23 @@ open class EPUBNavigatorViewController: InputObservableViewController, ) } + /// Creates a new instance of `EPUBNavigatorViewController`. + @available(*, deprecated, message: "The HTTP server is no longer needed for the EPUB navigator.") + public convenience init( + publication: Publication, + initialLocation: Locator?, + readingOrder: [Link]? = nil, + config: Configuration = .init(), + httpServer: HTTPServer + ) throws { + try self.init( + publication: publication, + initialLocation: initialLocation, + readingOrder: readingOrder, + config: config + ) + } + private init( viewModel: EPUBNavigatorViewModel, initialLocation: Locator?, diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 423ae327ac..10a5c1cc76 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -20,71 +20,55 @@ enum EPUBScriptScope { case resource(href: AnyURL) } -final class EPUBNavigatorViewModel: Loggable { - enum Error: Swift.Error { - case noHTTPServer - } - +@MainActor final class EPUBNavigatorViewModel: Loggable { let publication: Publication let config: EPUBNavigatorViewController.Configuration let editingActions: EditingActionsController - private let httpServer: HTTPServer? - private let publicationEndpoint: HTTPServerEndpoint? - private(set) var publicationBaseURL: HTTPURL! - let assetsURL: HTTPURL - weak var delegate: EPUBNavigatorViewModelDelegate? - /// Local file URL associated to the HTTP URL used to serve the file on the - /// `httpServer`. This is used to serve custom font files, for example. - @Atomic private var servedFiles: [FileURL: HTTPURL] = [:] + /// The base URL for the publication resources. + private(set) var publicationBaseURL: AbsoluteURL! + + /// The base URL for Readium assets (CSS, scripts, etc.) and fonts. + let assetsBaseURL: any AbsoluteURL + + /// The server used to serve publication resources and static assets to + /// the web view. + let server: WebViewServer + + weak var delegate: EPUBNavigatorViewModelDelegate? let readingOrder: ReadingOrder convenience init( publication: Publication, readingOrder: ReadingOrder, - config: EPUBNavigatorViewController.Configuration, - httpServer: HTTPServer - ) throws { - let uuidEndpoint: HTTPServerEndpoint = UUID().uuidString - let publicationEndpoint: HTTPServerEndpoint? - if publication.baseURL != nil { - publicationEndpoint = nil - } else { - publicationEndpoint = uuidEndpoint - } + config: EPUBNavigatorViewController.Configuration + ) { + let assetsDirectory = Bundle.module.resourceURL!.fileURL! + .appendingPath("Assets/Static", isDirectory: true) + + let server = WebViewServer(scheme: "readium") - try self.init( + // Serve static assets directory. + let assetsBaseURL = server.serve(directory: assetsDirectory, at: "assets") + + self.init( publication: publication, readingOrder: readingOrder, config: config, - httpServer: httpServer, - publicationEndpoint: publicationEndpoint, - assetsURL: httpServer.serve( - at: "readium", - contentsOf: Bundle.module.resourceURL!.fileURL! - .appendingPath("Assets/Static", isDirectory: true) - ) + server: server, + assetsBaseURL: assetsBaseURL ) if let url = publication.baseURL { + // The publication already has an HTTP base URL (e.g. served + // remotely). Use it directly; the server only needs to serve + // assets. publicationBaseURL = url } else { - publicationBaseURL = try httpServer.serve( - at: uuidEndpoint, // serving the chapters endpoint - publication: publication, - onFailure: { [weak self] request, error in - guard let self = self, let href = request.href else { - return - } - self.delegate?.epubNavigatorViewModel(self, didFailToLoadResourceAt: href, withError: error) - } - ) - } - - if let endpoint = publicationEndpoint { - try httpServer.transformResources(at: endpoint) { [weak self] href, resource in - self?.injectReadiumCSS(in: resource, at: href) ?? resource + // Serve publication resources. + publicationBaseURL = server.serve(at: UUID().uuidString) { [weak self] in + self?.serve(href: $0) } } } @@ -93,9 +77,8 @@ final class EPUBNavigatorViewModel: Loggable { publication: Publication, readingOrder: ReadingOrder, config: EPUBNavigatorViewController.Configuration, - httpServer: HTTPServer?, - publicationEndpoint: HTTPServerEndpoint?, - assetsURL: HTTPURL + server: WebViewServer, + assetsBaseURL: any AbsoluteURL ) { var config = config @@ -132,9 +115,8 @@ final class EPUBNavigatorViewModel: Loggable { actions: config.editingActions, publication: publication ) - self.httpServer = httpServer - self.publicationEndpoint = publicationEndpoint - self.assetsURL = assetsURL + self.server = server + self.assetsBaseURL = assetsBaseURL preferences = config.preferences settings = EPUBSettings(publication: publication, config: config) @@ -142,7 +124,7 @@ final class EPUBNavigatorViewModel: Loggable { css = ReadiumCSS( layout: CSSLayout(), rsProperties: config.readiumCSSRSProperties, - baseURL: assetsURL.appendingPath("readium-css", isDirectory: true), + baseURL: assetsBaseURL.appendingPath("readium-css", isDirectory: true), fontFamilyDeclarations: config.fontFamilyDeclarations ) @@ -158,30 +140,12 @@ final class EPUBNavigatorViewModel: Loggable { deinit { NotificationCenter.default.removeObserver(self) - - if let endpoint = publicationEndpoint { - try? httpServer?.remove(at: endpoint) - } } func url(to link: Link) -> AnyURL { link.url(relativeTo: publicationBaseURL) } - private func serveFile(at file: FileURL, baseEndpoint: HTTPServerEndpoint) throws -> HTTPURL { - if let url = servedFiles[file] { - return url - } - - guard let httpServer = httpServer else { - throw Error.noHTTPServer - } - let endpoint = baseEndpoint.addingSuffix("/") + file.lastPathSegment - let url = try httpServer.serve(at: endpoint, contentsOf: file) - $servedFiles.write { $0[file] = url } - return url - } - private var needsInvalidatePagination = false private func setNeedsInvalidatePagination() { guard !needsInvalidatePagination else { @@ -194,6 +158,15 @@ final class EPUBNavigatorViewModel: Loggable { } } + // MARK: - Web View Server + + private func serve(href: RelativeURL) -> Resource? { + guard let resource = publication.get(href) else { + return nil + } + return injectReadiumCSS(in: resource, at: href) + } + // MARK: - User preferences /// Currently applied settings. @@ -303,10 +276,7 @@ final class EPUBNavigatorViewModel: Loggable { // MARK: - Readium CSS private var css: ReadiumCSS - - private func serveFont(at file: FileURL) throws -> HTTPURL { - try serveFile(at: file, baseEndpoint: "custom-fonts/\(UUID().uuidString)") - } + private var servedFonts: [FileURL: AbsoluteURL] = [:] func injectReadiumCSS(in resource: Resource, at href: HREF) -> Resource { guard @@ -325,7 +295,18 @@ final class EPUBNavigatorViewModel: Loggable { do { var content = try css.inject(in: content) for ff in config.fontFamilyDeclarations { - content = try ff.inject(in: content, servingFile: serveFont) + content = try ff.inject( + in: content, + servingFile: { [server] file in + if let url = self.servedFonts[file] { + return url + } + let name = file.lastPathSegment ?? UUID().uuidString + let url = server.serve(file: file, at: "assets/fonts/\(name)") + self.servedFonts[file] = url + return url + } + ) } return content } catch { diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 0c2988b277..284f3b6154 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -23,7 +23,7 @@ protocol EPUBSpreadProtocol { /// - 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]] + func json(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> [[String: Any]] } /// Represents a spread of EPUB resources displayed in the viewport. A spread @@ -71,11 +71,11 @@ enum EPUBSpread: EPUBSpreadProtocol { spread.positionCount(in: readingOrder, positionsByReadingOrder: positionsByReadingOrder) } - func json(forBaseURL baseURL: HTTPURL, readingProgression: ReadingProgression) -> [[String: Any]] { + func json(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> [[String: Any]] { spread.json(forBaseURL: baseURL, readingProgression: readingProgression) } - func jsonString(forBaseURL baseURL: HTTPURL, readingProgression: ReadingProgression) -> String { + func jsonString(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> String { serializeJSONString(json(forBaseURL: baseURL, readingProgression: readingProgression)) ?? "[]" } @@ -207,7 +207,7 @@ struct EPUBSpreadResource { let link: Link /// Returns a JSON representation of the resource for the spread scripts. - func json(forBaseURL baseURL: HTTPURL, page: Properties.Page) -> [String: Any] { + func json(forBaseURL baseURL: AbsoluteURL, page: Properties.Page) -> [String: Any] { [ "index": index, "link": link.json, @@ -230,7 +230,7 @@ struct EPUBSingleSpread: EPUBSpreadProtocol, Loggable { positionsByReadingOrder.getOrNil(resource.index)?.count ?? 0 } - func json(forBaseURL baseURL: HTTPURL, readingProgression: ReadingProgression) -> [[String: Any]] { + func json(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> [[String: Any]] { [ resource.json( forBaseURL: baseURL, @@ -293,7 +293,7 @@ struct EPUBDoubleSpread: EPUBSpreadProtocol, Loggable { return firstPositions + secondPositions } - func json(forBaseURL baseURL: HTTPURL, readingProgression: ReadingProgression) -> [[String: Any]] { + func json(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> [[String: Any]] { [ left(for: readingProgression).json(forBaseURL: baseURL, page: .left), right(for: readingProgression).json(forBaseURL: baseURL, page: .right), diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 1c6ae6091e..a038eb5d46 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -71,7 +71,18 @@ class EPUBSpreadView: UIView, Loggable, PageView { self.viewModel = viewModel self.spread = spread self.animatedLoad = animatedLoad - webView = WebView(editingActions: viewModel.editingActions) + + let config = WKWebViewConfiguration() + config.setURLSchemeHandler(viewModel.server, forURLScheme: viewModel.server.scheme) + config.mediaTypesRequiringUserActionForPlayback = .all + + // Disable the Apple Intelligence Writing tools in the web views. + // See https://github.com/readium/swift-toolkit/issues/509#issuecomment-2577780749 + if #available(iOS 18.0, *) { + config.writingToolsBehavior = .none + } + + webView = WebView(editingActions: viewModel.editingActions, configuration: config) super.init(frame: .zero) @@ -532,12 +543,12 @@ extension EPUBSpreadView: WKNavigationDelegate { var policy: WKNavigationActionPolicy = .allow if navigationAction.navigationType == .linkActivated { - if let url = navigationAction.request.url?.httpURL { + if let url = navigationAction.request.url { // Check if url is internal or external if let relativeURL = viewModel.publicationBaseURL.relativize(url) { delegate?.spreadView(self, didTapOnInternalLink: relativeURL.string, clickEvent: lastClick) } else { - delegate?.spreadView(self, didTapOnExternalURL: url.url) + delegate?.spreadView(self, didTapOnExternalURL: url) } policy = .cancel diff --git a/Sources/Navigator/EPUB/WebViewServer.swift b/Sources/Navigator/EPUB/WebViewServer.swift new file mode 100644 index 0000000000..c17f5950f9 --- /dev/null +++ b/Sources/Navigator/EPUB/WebViewServer.swift @@ -0,0 +1,356 @@ +// +// 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 ReadiumInternal +import ReadiumShared +import WebKit + +/// A generic `WKURLSchemeHandler` that serves files, directories, and +/// arbitrary resources at named routes using a custom URL scheme (e.g. +/// `readium://`). +@MainActor final class WebViewServer: NSObject, WKURLSchemeHandler, Loggable { + /// The custom scheme used to serve the content. + let scheme: String + + private let formatSniffer = DefaultFormatSniffer() + + init(scheme: String) { + self.scheme = scheme + super.init() + } + + // MARK: - Route registration + + private enum RouteHandler { + case file(FileURL) + case directory(FileURL) + case resources(@MainActor (RelativeURL) -> Resource?) + } + + /// Registered routes, sorted by reverse alphabetical order to ensure + /// longest-prefix matching of routes sharing a common prefix. + private var routes: [(path: String, baseURL: AbsoluteURL, handler: RouteHandler)] = [] + + /// Serves a single local file at the given route. + /// + /// - Returns: The absolute URL (e.g. `readium://assets/fonts/abc/Font.otf`) + /// to the served file. + @discardableResult + func serve(file: FileURL, at route: String) -> AbsoluteURL { + let route = normalizedRoute(route) + let baseURL = AnyURL(string: "\(scheme)://\(route)")!.absoluteURL! + insertRoute((path: route, baseURL: baseURL, handler: .file(file))) + return baseURL + } + + /// Serves a local directory at the given route. + /// + /// All files under the directory are accessible. + /// + /// - Returns: The absolute base URL (e.g. `readium://assets/`) to the + /// served directory. + @discardableResult + func serve(directory: FileURL, at route: String) -> AbsoluteURL { + let route = normalizedRoute(route, isDirectory: true) + let baseURL = AnyURL(string: "\(scheme)://\(route)")!.absoluteURL! + insertRoute((path: route, baseURL: baseURL, handler: .directory(directory))) + return baseURL + } + + /// Serves resources at the given route using a handler callback. + /// + /// The handler receives a relative URL and returns a `Resource`, or + /// `nil` for 404. Returned resources are automatically wrapped in a + /// `BufferingResource` cache. + /// + /// Returns the base URL (e.g. `readium://{uuid}/`). + @discardableResult + func serve(at route: String, handler: @escaping @MainActor (RelativeURL) -> Resource?) -> AbsoluteURL { + let route = normalizedRoute(route, isDirectory: true) + let baseURL = AnyURL(string: "\(scheme)://\(route)")!.absoluteURL! + insertRoute((path: route, baseURL: baseURL, handler: .resources(handler))) + return baseURL + } + + /// Removes the handler at the given route. + func remove(at route: String) { + let route = normalizedRoute(route) + routes.removeAll { $0.path.hasPrefix(route) } + } + + private func normalizedRoute(_ route: String, isDirectory: Bool = false) -> String { + var r = route.removingPrefix("/") + if isDirectory { + r = r.addingSuffix("/") + } + return r + } + + private func insertRoute(_ entry: (path: String, baseURL: AbsoluteURL, handler: RouteHandler)) { + // Remove any existing route with the same path. + routes.removeAll { $0.path == entry.path } + routes.append(entry) + // Reverse alphabetical order ensures longest-prefix matching: + // routes sharing a common prefix are grouped with longer ones first. + routes.sort { $0.path > $1.path } + } + + // MARK: - Active tasks & caching + + /// Tracks active tasks for cancellation support. + private var activeTasks: [ObjectIdentifier: Task] = [:] + + /// Bounded cache of buffered resources keyed by publication-relative URL. + /// + /// Reusing the same ``Resource`` across requests lets compressed ZIP + /// resources benefit from forward-seek optimization instead of + /// decompressing from offset 0 on every request. + /// + /// Oldest entries are evicted when the cache exceeds its capacity. + private var resourceCache = BoundedResourceCache() + + // MARK: - WKURLSchemeHandler + + func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { + let taskID = ObjectIdentifier(urlSchemeTask) + activeTasks[taskID] = Task { + await serve(urlSchemeTask) + _ = activeTasks.removeValue(forKey: taskID) + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { + let taskID = ObjectIdentifier(urlSchemeTask) + activeTasks.removeValue(forKey: taskID)?.cancel() + } + + // MARK: - Serving + + private func serve(_ urlSchemeTask: WKURLSchemeTask) async { + guard let requestURL = urlSchemeTask.request.url else { + await fail(urlSchemeTask, with: URLError(.badURL)) + return + } + + // Find the matching route (longest prefix wins). + for route in routes { + switch route.handler { + case let .file(file): + guard route.baseURL.isEquivalentTo(requestURL) else { + continue + } + await serveFile(urlSchemeTask, at: file, requestURL: requestURL) + return + + case let .directory(directory): + guard + let relativeURL = route.baseURL.relativize(requestURL), + let file = directory.resolve(relativeURL)?.fileURL, + directory.isParent(of: file) + else { + continue + } + await serveFile(urlSchemeTask, at: file, requestURL: requestURL) + return + + case let .resources(handler): + guard let relativeURL = route.baseURL.relativize(requestURL) else { + continue + } + await serveResource( + urlSchemeTask, + relativeURL: relativeURL, + handler: handler, + requestURL: requestURL + ) + return + } + } + + await fail(urlSchemeTask, with: URLError(.fileDoesNotExist)) + } + + /// Serves a resource from a handler callback, with caching. + private func serveResource( + _ urlSchemeTask: WKURLSchemeTask, + relativeURL: RelativeURL, + handler: @MainActor (RelativeURL) -> Resource?, + requestURL: URL + ) async { + // Reuse a cached buffered resource to benefit from forward-seek + // optimization and read-ahead buffering, or create and cache a new + // one. + let resource: Resource + if let cached = resourceCache[relativeURL] { + resource = cached + } else { + guard var res = handler(relativeURL) else { + await fail(urlSchemeTask, with: URLError(.fileDoesNotExist)) + return + } + res = res.buffered(size: 256 * 1024) + resourceCache.set(relativeURL, res) + resource = res + } + + let mediaType = await resource.properties().getOrNil()?.mediaType ?? mediaTypeFromURL(relativeURL) + + await serveResource( + resource, + with: urlSchemeTask, + mediaType: mediaType, + requestURL: requestURL + ) + } + + /// Reads a local file and sends it as a response. + private func serveFile( + _ urlSchemeTask: WKURLSchemeTask, + at file: FileURL, + requestURL: URL + ) async { + await serveResource( + FileResource(file: file), + with: urlSchemeTask, + mediaType: mediaTypeFromURL(file), + requestURL: requestURL + ) + } + + private func serveResource( + _ resource: Resource, + with urlSchemeTask: WKURLSchemeTask, + mediaType: MediaType?, + requestURL: URL + ) async { + // Try to serve a byte range if the client requested one and the + // resource length is known. + if + let totalLength = await (try? resource.estimatedLength().get()).flatMap({ $0 }), + let range = urlSchemeTask.request.byteRange(in: totalLength) + { + let result = await resource.read(range: range) + switch result { + case let .success(data): + await respond(urlSchemeTask, with: data, range: range, totalLength: totalLength, mediaType: mediaType, url: requestURL) + case let .failure(error): + log(.error, "Failed to read resource \(requestURL.path) range \(range): \(error)") + await fail(urlSchemeTask, with: URLError(.resourceUnavailable)) + } + return + } + + // Full read fallback. + let result = await resource.read() + switch result { + case let .success(data): + await respond(urlSchemeTask, with: data, range: nil, totalLength: UInt64(data.count), mediaType: mediaType, url: requestURL) + case let .failure(error): + log(.error, "Failed to read resource \(requestURL.path): \(error)") + await fail(urlSchemeTask, with: URLError(.resourceUnavailable)) + } + } + + private func mediaTypeFromURL(_ url: URLConvertible) -> MediaType? { + guard let ext = url.anyURL.pathExtension else { + return nil + } + return formatSniffer.sniffHints(FormatHints(fileExtension: ext))?.mediaType + } + + // MARK: - Response helpers + + /// Sends data as a response, optionally as a 206 Partial Content when a + /// byte range was requested. + /// + /// - Parameters: + /// - range: The byte range being served, or `nil` for a full 200 + /// response. + /// - totalLength: The total size of the resource (used in + /// `Content-Range`). + private func respond( + _ urlSchemeTask: WKURLSchemeTask, + with data: Data, + range: Range?, + totalLength: UInt64, + mediaType: MediaType?, + url: URL + ) async { + var headers: [String: String] = [ + "Content-Length": "\(data.count)", + "Accept-Ranges": "bytes", + ] + + if let mediaType { + headers["Content-Type"] = mediaType.string + } + + let statusCode: Int + if let range = range { + statusCode = 206 + headers["Content-Range"] = "bytes \(range.lowerBound)-\(range.upperBound - 1)/\(totalLength)" + } else { + statusCode = 200 + } + + guard let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headers + ) else { + await fail(urlSchemeTask, with: URLError(.unknown)) + return + } + + // Guard against task cancellation to avoid calling WKURLSchemeTask + // methods after WebKit has stopped the task. + guard !Task.isCancelled else { return } + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + private func fail(_ urlSchemeTask: WKURLSchemeTask, with error: Error) async { + guard !Task.isCancelled else { return } + urlSchemeTask.didFailWithError(error) + } +} + +private extension URLRequest { + /// Parses an HTTP `Range` header value (RFC 7233) into a byte range. + func byteRange(in totalLength: UInt64) -> Range? { + Range(httpRange: value(forHTTPHeaderField: "Range") ?? "", in: totalLength) + } +} + +/// A simple bounded FIFO cache for ``Resource`` instances. +/// +/// Evicts the oldest entries when the number of cached resources exceeds +/// ``capacity``, preventing unbounded memory growth as the user navigates +/// through chapters. +private struct BoundedResourceCache { + private let capacity = 8 + private var entries: [RelativeURL: Resource] = [:] + private var order: [RelativeURL] = [] + + subscript(key: RelativeURL) -> Resource? { + entries[key] + } + + mutating func set(_ key: RelativeURL, _ value: Resource) { + if entries[key] == nil { + order.append(key) + } + entries[key] = value + + while order.count > capacity { + let evicted = order.removeFirst() + entries.removeValue(forKey: evicted) + } + } +} diff --git a/Sources/Navigator/Toolkit/WebView.swift b/Sources/Navigator/Toolkit/WebView.swift index ce6d97c5ab..fe561b87e6 100644 --- a/Sources/Navigator/Toolkit/WebView.swift +++ b/Sources/Navigator/Toolkit/WebView.swift @@ -12,21 +12,14 @@ import WebKit final class WebView: WKWebView { private let editingActions: EditingActionsController - init(editingActions: EditingActionsController) { - self.editingActions = editingActions - - let config = WKWebViewConfiguration() - config.mediaTypesRequiringUserActionForPlayback = .all + convenience init(editingActions: EditingActionsController) { + self.init(editingActions: editingActions, configuration: WKWebViewConfiguration()) + } - // Disable the Apple Intelligence Writing tools in the web views. - // See https://github.com/readium/swift-toolkit/issues/509#issuecomment-2577780749 - #if compiler(>=6.0) - if #available(iOS 18.0, *) { - config.writingToolsBehavior = .none - } - #endif + init(editingActions: EditingActionsController, configuration: WKWebViewConfiguration) { + self.editingActions = editingActions - super.init(frame: .zero, configuration: config) + super.init(frame: .zero, configuration: configuration) #if DEBUG && swift(>=5.8) if #available(macOS 13.3, iOS 16.4, *) { diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index 94384437b8..5197fc977f 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -172,8 +172,8 @@ public struct Link: JSONEquatable, Hashable, Sendable { /// /// If the HREF is a template, the `parameters` are used to expand it /// according to RFC 6570. - public func url( - relativeTo baseURL: T?, + public func url( + relativeTo baseURL: URLConvertible?, parameters: [String: LosslessStringConvertible] = [:] ) -> AnyURL { let url = url(parameters: parameters) diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index 9db0cd274a..f35e459903 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -27,7 +27,7 @@ public actor BufferingResource: Resource, Loggable { private var buffer: Buffer /// - Parameter bufferSize: Size of the buffer chunks to read. - public init(resource: Resource, bufferSize: Int = 8192) { + public init(resource: Resource, bufferSize: Int = 256 * 1024) { precondition(bufferSize > 0) self.resource = resource buffer = Buffer(maxSize: bufferSize) @@ -49,83 +49,70 @@ public actor BufferingResource: Resource, Loggable { private var cachedLength: ReadResult? public func estimatedLength() async -> ReadResult { - if cachedLength == nil { - cachedLength = await resource.estimatedLength() + if let cachedLength { + return cachedLength } - return cachedLength! + let result = await resource.estimatedLength() + if case .success = result { + cachedLength = result + } + return result } public func stream( range: Range?, consume: @escaping (Data) -> Void ) async -> ReadResult { - guard - // Reading the whole resource bypasses buffering to keep things simple. - var requestedRange = range, - let optionalLength = await estimatedLength().getOrNil(), - let length = optionalLength - else { + // Reading the whole resource bypasses buffering to keep things simple. + guard let requestedRange = range, !requestedRange.isEmpty else { return await resource.stream(range: range, consume: consume) } - requestedRange = requestedRange.clamped(to: 0 ..< length) - guard !requestedRange.isEmpty else { - consume(Data()) - return .success(()) - } + // Serve from the buffer if the request is fully covered. if let data = buffer.get(range: requestedRange) { - log(.trace, "Used buffer for \(requestedRange) (\(requestedRange.count) bytes)") consume(data) return .success(()) } - // Calculate the adjusted range to cover at least buffer.maxSize bytes. - // Adjust the start if near the end of the resource. - var adjustedStart = requestedRange.lowerBound - var adjustedEnd = requestedRange.upperBound - let missingBytesToMatchBufferSize = buffer.maxSize - requestedRange.count - if missingBytesToMatchBufferSize > 0 { - adjustedEnd = min(adjustedEnd + UInt64(missingBytesToMatchBufferSize), length) - } - if adjustedEnd - adjustedStart < buffer.maxSize { - adjustedStart = UInt64(max(0, Int(adjustedEnd) - buffer.maxSize)) - } - let adjustedRange = adjustedStart ..< adjustedEnd - log(.trace, "Requested \(requestedRange) (\(requestedRange.count) bytes), adjusted to \(adjustedRange) (\(adjustedRange.count) bytes) of resource with length \(length)") - - var data = Data() + // Read ahead from the request start to fill the buffer. + let readAheadEnd = requestedRange.lowerBound + UInt64(buffer.maxSize) + let readRange = requestedRange.lowerBound ..< max(requestedRange.upperBound, readAheadEnd) - // Range that will need to be read from the original resource. - var readRange = adjustedRange + // Range that will actually need to be read from the original resource, + // after reusing any overlap with the current buffer. + var fetchRange = readRange + var prefixData = Data() // Checks if the beginning of the range to read is already buffered. // This is an optimization particularly useful with LCP, where we need // to go backward for every read to get the previous block of data. if - readRange.lowerBound < buffer.range.upperBound, - readRange.upperBound > buffer.range.upperBound, - let dataPrefix = buffer.get(range: readRange.lowerBound ..< buffer.range.upperBound) + fetchRange.lowerBound < buffer.range.upperBound, + fetchRange.upperBound > buffer.range.upperBound, + let dataPrefix = buffer.get(range: fetchRange.lowerBound ..< buffer.range.upperBound) { - log(.trace, "Found \(dataPrefix.count) bytes to reuse at the end of the buffer") - data.append(dataPrefix) - readRange = buffer.range.upperBound ..< readRange.upperBound + prefixData = Data(dataPrefix) + fetchRange = buffer.range.upperBound ..< fetchRange.upperBound } - log(.trace, "Will read \(readRange) (\(readRange.count) bytes)") + // Read from the original resource using stream to avoid materializing + // more than needed. + var data = prefixData + let result = await resource.stream(range: fetchRange) { chunk in + data.append(chunk) + } - // Fallback on reading the requested range from the original resource. - return await resource.read(range: readRange) - .flatMap { readData in - data.append(readData) - buffer.set(data, at: adjustedRange.lowerBound) + guard case .success = result else { + return result + } - guard let data = data[requestedRange, offsetBy: adjustedRange.lowerBound] else { - return .failure(.decoding("Cannot extract the requested range from the read range")) - } + buffer.set(data, at: readRange.lowerBound) - consume(data) - return .success(()) - } + let end = min(Int(requestedRange.count), data.count) + if end > 0 { + consume(data[0 ..< end]) + } + return .success(()) } private struct Buffer { @@ -164,7 +151,13 @@ public actor BufferingResource: Resource, Loggable { public extension Resource { /// Wraps this resource in a `BufferingResource` to improve reading /// performances. - func buffered(size: Int = 8192) -> BufferingResource { + func buffered() -> BufferingResource { + BufferingResource(resource: self) + } + + /// Wraps this resource in a `BufferingResource` to improve reading + /// performances. + func buffered(size: Int) -> BufferingResource { BufferingResource(resource: self, bufferSize: size) } diff --git a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift index 505c7ebb06..71e9b9a4b5 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift @@ -70,11 +70,11 @@ public extension Resource { TransformingResource(self, transform: { await $0.asyncMap(transform) }) } - func mapAsString(encoding: String.Encoding = .utf8, transform: @escaping (String) -> String) -> Resource { + func mapAsString(encoding: String.Encoding = .utf8, transform: @escaping (String) async -> String) -> Resource { TransformingResource(self) { - $0.map { data in + await $0.asyncMap { data in let string = String(data: data, encoding: encoding) ?? "" - return transform(string).data(using: .utf8) ?? Data() + return await transform(string).data(using: .utf8) ?? Data() } } } diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift index 7b93f7bbbb..363e166ce8 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift @@ -57,7 +57,7 @@ public extension AbsoluteURL { /// self: http://example.com/foo /// other: http://example.com/foo/bar/baz /// returns bar/baz - func relativize(_ other: T) -> RelativeURL? { + func relativize(_ other: URLConvertible) -> RelativeURL? { guard let absoluteURL = other.anyURL.absoluteURL, scheme == absoluteURL.scheme, diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 648e6982ff..e452de3f5a 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -525,6 +525,7 @@ ../../Sources/Navigator/EPUB/Scripts/src/vendor/hypothesis/anchoring/xpath.js ../../Sources/Navigator/EPUB/Scripts/src/vendor/hypothesis/README.md ../../Sources/Navigator/EPUB/Scripts/webpack.config.js +../../Sources/Navigator/EPUB/WebViewServer.swift ../../Sources/Navigator/Input ../../Sources/Navigator/Input/CompositeInputObserver.swift ../../Sources/Navigator/Input/InputObservable.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 07a334bf74..0b7785aa7d 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -282,6 +282,7 @@ B0B2C38D1A7B36E73C3E3779 /* DifferenceKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F95F3F20D758BE0E7005EA3 /* DifferenceKit.xcframework */; }; B0F62AC136EF3587E147468E /* AssetRetriever.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C054DDC6D1BCF4A420C980C /* AssetRetriever.swift */; }; B1008DFBDE3E33CA552E0E26 /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8FCFFDA8AA421435CB40DE /* Optional.swift */; }; + B22857E75D32AF3810D4E074 /* WebViewServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B673858EDF2BBCB316C28CBE /* WebViewServer.swift */; }; B23C740199DCBD23BDF0670F /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2085E9C042F54271D5B9555 /* Container.swift */; }; B49522888052E9F41D0DD013 /* ZIPFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AA8BD22E2F5495286C0A1C /* ZIPFormatSniffer.swift */; }; B4D55F234AD2CB5728184346 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F669F31B0B6EC690C48916EC /* Bundle.swift */; }; @@ -762,6 +763,7 @@ B53B841C2F5A59BA3B161258 /* Resource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = ""; }; B5CE464C519852D38F873ADB /* PotentialRights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotentialRights.swift; sourceTree = ""; }; B65A22BA2FF8230955BC7C06 /* LCPKeychainLicenseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPKeychainLicenseRepository.swift; sourceTree = ""; }; + B673858EDF2BBCB316C28CBE /* WebViewServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewServer.swift; sourceTree = ""; }; B7457AD096857CA307F6ED6A /* InputObservable+Legacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InputObservable+Legacy.swift"; sourceTree = ""; }; B7C9D54352714641A87F64A0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; BAA7CEF568A02BA2CB4AAD7F /* OPDSFormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSFormatSniffer.swift; sourceTree = ""; }; @@ -1086,6 +1088,7 @@ 98D8CC7BC117BBFB206D01CC /* EPUBSpread.swift */, E233289C75C9F73E6E28DDB4 /* EPUBSpreadView.swift */, 8AB3B86AB42261727B2811CF /* HTMLDecorationTemplate.swift */, + B673858EDF2BBCB316C28CBE /* WebViewServer.swift */, 01819314A9C8B44F7EF6EC7D /* CSS */, AC7E4A4E70D2E94C04BB1366 /* Preferences */, ); @@ -2483,6 +2486,7 @@ B96E8865DCA4A0CEFDA24DDF /* VisualNavigator.swift in Sources */, 6F042D80A0E07C285E006678 /* WKWebView.swift in Sources */, 7305815B0C701A4E9BA2DF7C /* WebView.swift in Sources */, + B22857E75D32AF3810D4E074 /* WebViewServer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index 5e70e5ed4b..18ae743ec8 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -57,7 +57,7 @@ final class AppModule { opds = OPDSModule(delegate: self) // Set Readium 2's logging minimum level. - ReadiumEnableLog(withMinimumSeverityLevel: .info) + ReadiumEnableLog(withMinimumSeverityLevel: .debug) } private(set) lazy var aboutViewController: UIViewController = { diff --git a/TestApp/Sources/Reader/EPUB/EPUBModule.swift b/TestApp/Sources/Reader/EPUB/EPUBModule.swift index e0748192d4..9fe245c2d8 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBModule.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBModule.swift @@ -39,8 +39,7 @@ final class EPUBModule: ReaderFormatModule { bookmarks: bookmarks, highlights: highlights, initialPreferences: preferencesStore.preferences(for: bookId), - preferencesStore: preferencesStore, - httpServer: readium.httpServer + preferencesStore: preferencesStore ) epubViewController.moduleDelegate = delegate return epubViewController diff --git a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift index 1e90d2b4c0..de9476e57b 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift @@ -27,8 +27,7 @@ class EPUBViewController: VisualReaderViewController, - httpServer: HTTPServer + preferencesStore: AnyUserPreferencesStore ) throws { // Create default templates, but make highlights opaque with experimental positioning. var templates = HTMLDecorationTemplate.defaultTemplates(alpha: 1.0, experimentalPositioning: true) @@ -62,8 +61,7 @@ class EPUBViewController: VisualReaderViewController [!NOTE] -> The HTTP server is used to serve the publication resources to the Navigator. You may use your own implementation, or the recommended `GCDHTTPServer` which is part of the `ReadiumAdapterGCDWebServer` package. - ### Audio Navigator The `AudioNavigator` is chromeless and does not provide any user interface, allowing you to create your own custom UI. @@ -133,8 +128,7 @@ let lastReadLocation = Locator(jsonString: dabase.lastReadLocation()) let navigator = try EPUBNavigatorViewController( publication: publication, - initialLocation: lastReadLocation, - httpServer: GCDHTTPServer.shared + initialLocation: lastReadLocation ) ``` diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index 70bfa6b8f6..e31a7ab8c7 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -4,6 +4,23 @@ All migration steps necessary in reading apps to upgrade to major versions of th ## Unreleased +### Removing the HTTP Server from the EPUB Navigator + +The EPUB navigator no longer requires an HTTP server. Publication resources are now served directly to the web views using a custom URL scheme handler. + +Remove the `httpServer` parameter when creating an `EPUBNavigatorViewController`: + +```diff + let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, +- httpServer: GCDHTTPServer.shared + ) +``` + +> [!NOTE] +> The PDF navigator still requires an HTTP server. If you are not using the PDF navigator, you can remove the `ReadiumAdapterGCDWebServer` dependency from your project. + ### Migrating LCP Repositories from SQLite to the Keychain The `ReadiumAdapterLCPSQLite` module is now deprecated. `ReadiumLCP` provides built-in Keychain-based repositories that are more secure, persist across app reinstalls, and optionally synchronize across devices via iCloud Keychain. From a20bbaf34588c6f67ecbb02b312188a626d5ade6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 18 Feb 2026 13:23:07 +0100 Subject: [PATCH 39/55] Add Guided Navigation models (#724) --- .../GuidedNavigationDocument.swift | 51 ++ .../GuidedNavigationObject.swift | 547 ++++++++++++++++++ Sources/Shared/Publication/LinkRelation.swift | 2 + Sources/Shared/Toolkit/Format/MediaType.swift | 1 + Support/Carthage/.xcodegen | 3 + .../Readium.xcodeproj/project.pbxproj | 16 + .../GuidedNavigationDocumentTests.swift | 67 +++ .../GuidedNavigationObjectTests.swift | 243 ++++++++ 8 files changed, 930 insertions(+) create mode 100644 Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift create mode 100644 Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift create mode 100644 Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift create mode 100644 Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift diff --git a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift new file mode 100644 index 0000000000..b12538e784 --- /dev/null +++ b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift @@ -0,0 +1,51 @@ +// +// 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 ReadiumInternal + +/// Represents a Guided Navigation Document, as defined in the +/// Readium Guided Navigation specification. +/// +/// https://readium.org/guided-navigation/ +public struct GuidedNavigationDocument: Hashable, Sendable { + /// References to other resources that are related to the current Guided + /// Navigation Document. + public var links: [Link] + + /// A sequence of resources and/or media fragments into these resources, + /// meant to be presented sequentially to the user. + public var guided: [GuidedNavigationObject] + + public init( + links: [Link] = [], + guided: [GuidedNavigationObject] + ) { + self.links = links + self.guided = guided + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + guard let json = json as? [String: Any] else { + if json == nil { + return nil + } + warnings?.log("Invalid Guided Navigation Document", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + let guided = [GuidedNavigationObject](json: json["guided"], warnings: warnings) + guard !guided.isEmpty else { + warnings?.log("Guided Navigation Document requires a non-empty guided array", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + self.init( + links: [Link](json: json["links"], warnings: warnings), + guided: guided + ) + } +} diff --git a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift new file mode 100644 index 0000000000..61f9c6c2b9 --- /dev/null +++ b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift @@ -0,0 +1,547 @@ +// +// 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 ReadiumInternal + +/// Represents a single Guided Navigation Object, as defined in the +/// Readium Guided Navigation specification. +/// +/// https://readium.org/guided-navigation/ +public struct GuidedNavigationObject: Hashable, Sendable { + public typealias ID = String + + /// Unique identifier for this object, in the scope of the containing Guided + /// Navigation Document. + public let id: ID? + + /// References to resources referenced by the current Guided Navigation + /// Object. + public let refs: Refs? + + /// Textual equivalent of the resources or fragment of the resources + /// referenced by the current Guided Navigation Object. + public let text: Text? + + /// Convey the structural semantics of a publication. + public let role: [Role] + + /// Text, audio or image description for the current Guided Navigation + /// Object. + public let description: Description? + + /// Items that are children of the containing Guided Navigation Object. + public let children: [GuidedNavigationObject] + + public init?( + id: ID? = nil, + refs: Refs? = nil, + text: Text? = nil, + role: [Role] = [], + description: Description? = nil, + children: [GuidedNavigationObject] = [] + ) { + guard refs != nil || text != nil || !children.isEmpty else { + return nil + } + self.id = id + self.refs = refs + self.text = text + self.role = role + self.description = description + self.children = children + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + guard let json = json as? [String: Any] else { + if json == nil { + return nil + } + warnings?.log("Invalid Guided Navigation Object", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + let refs = try Refs(json: json, warnings: warnings) + let text = try Text(json: json["text"], warnings: warnings) + let children = [GuidedNavigationObject](json: json["children"], warnings: warnings) + + guard refs != nil || text != nil || !children.isEmpty else { + warnings?.log("Guided Navigation Object requires at least one of audioref, imgref, textref, videoref, text, or children", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + let description = try Description(json: json["description"], warnings: warnings) + + self.init( + id: json["id"] as? String, + refs: refs, + text: text, + role: (json["role"] as? [String])?.map(Role.init) ?? [], + description: description, + children: children + ) + } + + /// Represents a collection of Guided Navigation References declared in a + /// Readium Guided Navigation Object. + public struct Refs: Hashable, Sendable { + /// References a textual resource or a fragment of it. + public let text: AnyURL? + + /// References an image or a fragment of it. + public let img: AnyURL? + + /// References an audio resource or a fragment of it. + public let audio: AnyURL? + + /// References a video clip or a fragment of it. + public let video: AnyURL? + + public init?( + text: AnyURL? = nil, + img: AnyURL? = nil, + audio: AnyURL? = nil, + video: AnyURL? = nil + ) { + guard text != nil || img != nil || audio != nil || video != nil else { + return nil + } + + self.audio = audio + self.img = img + self.text = text + self.video = video + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + guard let json = json as? [String: Any] else { + if json == nil { + return nil + } + warnings?.log("Invalid Guided Navigation Refs", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + let text = (json["textref"] as? String).flatMap(AnyURL.init(string:)) + let img = (json["imgref"] as? String).flatMap(AnyURL.init(string:)) + let audio = (json["audioref"] as? String).flatMap(AnyURL.init(string:)) + let video = (json["videoref"] as? String).flatMap(AnyURL.init(string:)) + + self.init(text: text, img: img, audio: audio, video: video) + } + } + + /// Represents the text content of a Guided Navigation Object. + /// + /// Can be either a bare string (normalized to `plain`) or an object with + /// `plain`, `ssml`, and `language` properties. + public struct Text: Hashable, Sendable { + public let plain: String? + public let ssml: String? + public let language: Language? + + public init?( + plain: String? = nil, + ssml: String? = nil, + language: Language? = nil + ) { + guard plain?.isEmpty == false || ssml?.isEmpty == false else { + return nil + } + self.plain = plain + self.ssml = ssml + self.language = language + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + if json == nil { + return nil + } + if let string = json as? String { + self.init(plain: string) + } else if let obj = json as? [String: Any] { + let plain = obj["plain"] as? String + let ssml = obj["ssml"] as? String + guard plain?.isEmpty == false || ssml?.isEmpty == false else { + warnings?.log("Guided Navigation String requires at least one of plain, or ssml", model: Self.self, source: json, severity: .moderate) + return nil + } + + self.init( + plain: plain, + ssml: ssml, + language: (obj["language"] as? String).map { Language(code: .bcp47($0)) } + ) + } else { + warnings?.log("Invalid Guided Navigation Text", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + } + } + + /// Represents the description for a Guided Navigation object. + public struct Description: Hashable, Sendable { + /// References to resources referenced by this description. + public let refs: Refs? + + /// Textual equivalent of the resources or fragment of the resources + /// referenced by this description. + public let text: Text? + + public init?( + refs: Refs? = nil, + text: Text? = nil + ) { + guard refs != nil || text != nil else { + return nil + } + self.refs = refs + self.text = text + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + guard let json = json as? [String: Any] else { + if json == nil { + return nil + } + warnings?.log("Invalid Guided Navigation Description", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + let refs = try Refs(json: json, warnings: warnings) + let text = try Text(json: json["text"], warnings: warnings) + + guard refs != nil || text != nil else { + warnings?.log("Guided Navigation Description requires at least one of audioref, imgref, textref, videoref, or text", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + self.init(refs: refs, text: text) + } + } + + /// Represents a role for a Guided Navigation Object. + /// + /// See https://readium.org/guided-navigation/roles + public struct Role: Hashable, Sendable { + public let id: String + + public init(_ id: String) { + self.id = id + } + + /// A sequential container for objects and/or child containers. + public static let sequence = Role("sequence") + + // MARK: Inherited from DPUB ARIA 1.0 + + /// A short summary of the principle ideas, concepts and conclusions of + /// the work, or of a section or excerpt within it. + public static let abstract = Role("abstract") + + /// A section or statement that acknowledges significant contributions + /// by persons, organizations, governments and other entities to the + /// realization of the work. + public static let acknowledgments = Role("acknowledgments") + + /// A closing statement from the author or a person of importance, + /// typically providing insight into how the content came to be written. + public static let afterword = Role("afterword") + + /// A section of supplemental information located after the primary + /// content that informs the content but is not central to it. + public static let appendix = Role("appendix") + + /// A link that allows the user to return to a related location in the + /// content (e.g., from a footnote to its reference or from a glossary + /// definition to where a term is used). + public static let backlink = Role("backlink") + + /// A list of external references cited in the work, which may be to + /// print or digital sources. + public static let bibliography = Role("bibliography") + + /// A reference to a bibliography entry. + public static let biblioref = Role("biblioref") + + /// A major thematic section of content in a work. + public static let chapter = Role("chapter") + + /// A short section of production notes particular to the edition + /// (e.g., describing the typeface used), often located at the end of a + /// work. + public static let colophon = Role("colophon") + + /// A concluding section or statement that summarizes the work or wraps + /// up the narrative. + public static let conclusion = Role("conclusion") + + /// An image that sets the mood or tone for the work and typically + /// includes the title and author. + public static let cover = Role("cover") + + /// An acknowledgment of the source of integrated content from + /// third-party sources, such as photos. + public static let credit = Role("credit") + + /// A collection of credits. + public static let credits = Role("credits") + + /// An inscription at the front of the work, typically addressed in + /// tribute to one or more persons close to the author. + public static let dedication = Role("dedication") + + /// A collection of notes at the end of a work or a section within it. + public static let endnotes = Role("endnotes") + + /// A quotation set at the start of the work or a section that + /// establishes the theme or sets the mood. + public static let epigraph = Role("epigraph") + + /// A concluding section of narrative that wraps up or comments on the + /// actions and events of the work, typically from a future perspective. + public static let epilogue = Role("epilogue") + + /// A set of corrections discovered after initial publication of the + /// work, sometimes referred to as corrigenda. + public static let errata = Role("errata") + + /// An illustration of the usage of a defined term or phrase. + public static let example = Role("example") + + /// Ancillary information, such as a citation or commentary, that + /// provides additional context to a referenced passage of text. + public static let footnote = Role("footnote") + + /// A preliminary section that typically introduces the scope or nature + /// of the work. + public static let foreword = Role("foreword") + + /// A brief dictionary of new, uncommon, or specialized terms used in + /// the content. + public static let glossary = Role("glossary") + + /// A reference to a glossary definition. + public static let glossref = Role("glossref") + + /// A navigational aid that provides a detailed list of links to key + /// subjects, names and other important topics covered in the work. + public static let index = Role("index") + + /// A preliminary section that typically introduces the scope or nature + /// of the work. + public static let introduction = Role("introduction") + + /// A reference to a footnote or endnote, typically appearing as a + /// superscripted number or symbol in the main body of text. + public static let noteref = Role("noteref") + + /// Notifies the user of consequences that might arise from an action + /// or event. Examples include warnings, cautions and dangers. + public static let notice = Role("notice") + + /// A separator denoting the position before which a break occurs + /// between two contiguous pages in a statically paginated version of + /// the content. + public static let pagebreak = Role("pagebreak") + + /// A navigational aid that provides a list of links to the pagebreaks + /// in the content. + public static let pagelist = Role("pagelist") + + /// A major structural division in a work that contains a set of + /// related sections dealing with a particular subject, narrative arc or + /// similar encapsulated theme. + public static let part = Role("part") + + /// An introductory section that precedes the work, typically written by + /// the author of the work. + public static let preface = Role("preface") + + /// An introductory section that sets the background to a work, + /// typically part of the narrative. + public static let prologue = Role("prologue") + + /// A distinctively placed or highlighted quotation from the current + /// content designed to draw attention to a topic or highlight a key + /// point. + public static let pullquote = Role("pullquote") + + /// A section of content structured as a series of questions and + /// answers, such as an interview or list of frequently asked questions. + public static let qna = Role("qna") + + /// An explanatory or alternate title for the work, or a section or + /// component within it. + public static let subtitle = Role("subtitle") + + /// Helpful information that clarifies some aspect of the content or + /// assists in its comprehension. + public static let tip = Role("tip") + + /// A navigational aid that provides an ordered list of links to the + /// major sectional headings in the content. + public static let toc = Role("toc") + + // MARK: Inherited from HTML and/or ARIA + + /// A self-contained composition in a document, page, application, or + /// site, which is intended to be independently distributable or + /// reusable. + public static let article = Role("article") + + /// Secondary or supplementary content. + public static let aside = Role("aside") + + /// Embedded sound content in a document. + public static let audio = Role("audio") + + /// A section that is quoted from another source. + public static let blockquote = Role("blockquote") + + /// Represents the content of an HTML document. + public static let body = Role("body") + + /// A caption for an image or a table. + public static let caption = Role("caption") + + /// A single cell of tabular data or content. + public static let cell = Role("cell") + + /// The header cell for a column, establishing a relationship between + /// it and the other cells in the same column. + public static let columnheader = Role("columnheader") + + /// A supporting section of the document, designed to be complementary + /// to the main content at a similar level in the DOM hierarchy. + public static let complementary = Role("complementary") + + /// A definition of a term or concept. + public static let definition = Role("definition") + + /// A disclosure widget that can be expanded. + public static let details = Role("details") + + /// An illustration, diagram, photo, code listing or similar, + /// referenced from the text of a work, and typically annotated with a + /// title, caption and/or credits. + public static let figure = Role("figure") + + /// Introductory content, typically a group of introductory or + /// navigational aids. + public static let header = Role("header") + + /// A heading for a section of the page. + public static let heading1 = Role("heading1") + + /// A heading for a section of the page. + public static let heading2 = Role("heading2") + + /// A heading for a section of the page. + public static let heading3 = Role("heading3") + + /// A heading for a section of the page. + public static let heading4 = Role("heading4") + + /// A heading for a section of the page. + public static let heading5 = Role("heading5") + + /// A heading for a section of the page. + public static let heading6 = Role("heading6") + + /// An image. + public static let image = Role("image") + + /// A structure that contains an enumeration of related content items. + public static let list = Role("list") + + /// A single item in an enumeration. + public static let listItem = Role("listItem") + + /// Content that is directly related to or expands upon the central + /// topic of the document. + public static let main = Role("main") + + /// Content that represents a mathematical expression. + public static let math = Role("math") + + /// A section of a page that links to other pages or to parts within + /// the page. + public static let navigation = Role("navigation") + + /// A paragraph. + public static let paragraph = Role("paragraph") + + /// Preformatted text which is to be presented exactly as written. + public static let preformatted = Role("preformatted") + + /// An element being used only for presentation and therefore that does + /// not have any accessibility semantics. + public static let presentation = Role("presentation") + + /// Content that is relevant to a specific, author-specified purpose + /// and sufficiently important that users will likely want to be able to + /// navigate to the section easily. + public static let region = Role("region") + + /// A row of data or content in a tabular structure. + public static let row = Role("row") + + /// The header cell for a row, establishing a relationship between it + /// and the other cells in the same row. + public static let rowheader = Role("rowheader") + + /// A generic standalone section of a document, which doesn't have a + /// more specific semantic element to represent it. + public static let section = Role("section") + + /// A divider that separates and distinguishes sections of content or + /// groups of menu items. + public static let separator = Role("separator") + + /// A summary of an element contained in details. + public static let summary = Role("summary") + + /// A structure containing data or content laid out in tabular form. + public static let table = Role("table") + + /// A word or phrase with a corresponding definition. + public static let term = Role("term") + + /// Embedded videos, movies, or audio files with captions in a + /// document. + public static let video = Role("video") + + // MARK: Inherited from EPUB SSV 1.1 + + /// A collection of references to audio clips. + public static let landmarks = Role("landmarks") + + /// A listing of audio clips included in the work. + public static let loa = Role("loa") + + /// A listing of illustrations included in the work. + public static let loi = Role("loi") + + /// A listing of tables included in the work. + public static let lot = Role("lot") + + /// A listing of video clips included in the work. + public static let lov = Role("lov") + } +} + +// MARK: - Array Extension + +public extension Array where Element == GuidedNavigationObject { + init(json: Any?, warnings: WarningLogger? = nil) { + self.init() + guard let json = json as? [Any] else { + return + } + let objects = json.compactMap { try? GuidedNavigationObject(json: $0, warnings: warnings) } + append(contentsOf: objects) + } +} diff --git a/Sources/Shared/Publication/LinkRelation.swift b/Sources/Shared/Publication/LinkRelation.swift index 91b46f4309..d18aeb8a1e 100644 --- a/Sources/Shared/Publication/LinkRelation.swift +++ b/Sources/Shared/Publication/LinkRelation.swift @@ -45,6 +45,8 @@ public struct LinkRelation: Sendable { public static let cover = LinkRelation("cover") /// Links to a manifest. public static let manifest = LinkRelation("manifest") + /// Identifies a related resource. + public static let related = LinkRelation("related") /// Refers to a URI or templated URI that will perform a search. public static let search = LinkRelation("search") /// Conveys an identifier for the link's context. diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index b20c7e63e3..5baf9f4ffe 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -253,6 +253,7 @@ public struct MediaType: Hashable, Loggable, Sendable { public static let rar = MediaType("application/vnd.rar")! public static let readiumAudiobook = MediaType("application/audiobook+zip")! public static let readiumAudiobookManifest = MediaType("application/audiobook+json")! + public static let readiumGuidedNavigationDocument = MediaType("application/guided-navigation+json")! public static let readiumWebPub = MediaType("application/webpub+zip")! public static let readiumWebPubManifest = MediaType("application/webpub+json")! public static let smil = MediaType("application/smil+xml")! diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index e452de3f5a..cfe2838f65 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -637,6 +637,9 @@ ../../Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift ../../Sources/Shared/Publication/Extensions/Presentation/Presentation.swift ../../Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift +../../Sources/Shared/Publication/GuidedNavigation +../../Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift +../../Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift ../../Sources/Shared/Publication/HREFNormalizer.swift ../../Sources/Shared/Publication/Layout.swift ../../Sources/Shared/Publication/Link.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 0b7785aa7d..e48f61004f 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0025BE4D568B560277323B95 /* FileResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05502AD8940D6616D6C386F /* FileResource.swift */; }; + 007A17CE92F8F2CFEBD91534 /* GuidedNavigationDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08FC8BFB6FE5B5A18EA696DE /* GuidedNavigationDocument.swift */; }; 01AC52ADE389D14F5274CEB2 /* Minizip.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = CFFEBDFE931745C07DACD4A3 /* Minizip.xcframework */; }; 01AD628D6DE82E1C1C4C281D /* NavigationDocumentParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AD63CD2A41586290547212 /* NavigationDocumentParser.swift */; }; 01E785BEA7F30AD1C8A5F3DE /* SearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B5B029CA09EE1F86A19612A /* SearchService.swift */; }; @@ -247,6 +248,7 @@ 999EF656A5CDAF3BA30C26EF /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD03AFC9C69E785886FB9620 /* Logger.swift */; }; 9A1877FBEAA0BFC4C74AD3BB /* Encryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87727AC33D368A88A60A12B9 /* Encryption.swift */; }; 9A463F872E1B05B64E026EBB /* LCPLicenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3230FB63D7ADDD514D74F7E6 /* LCPLicenseRepository.swift */; }; + 9A5AE6CF737280C031231519 /* GuidedNavigationObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE02C0EB77A716286A36152 /* GuidedNavigationObject.swift */; }; 9AF316DF0B1CD4452D785EBC /* KeyModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0812058DB4FBFDF0A862E57E /* KeyModifiers.swift */; }; 9B0369F8C0187528486440F4 /* CompositeInputObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8E202B8A16B960AE73CABF /* CompositeInputObserver.swift */; }; 9BC4D1F2958D2F7D7BDB88DA /* CursorList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C361F965E7A7962CA3E4C0BA /* CursorList.swift */; }; @@ -513,6 +515,7 @@ 07B5469E40752E598C070E5B /* OPDSParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSParser.swift; sourceTree = ""; }; 0812058DB4FBFDF0A862E57E /* KeyModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyModifiers.swift; sourceTree = ""; }; 0885992D0F70AD0B493985CE /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 08FC8BFB6FE5B5A18EA696DE /* GuidedNavigationDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidedNavigationDocument.swift; sourceTree = ""; }; 0918DA360AAB646144E435D5 /* TransformingContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransformingContainer.swift; sourceTree = ""; }; 093629E752DE17264B97C598 /* LCPLicense.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPLicense.swift; sourceTree = ""; }; 0977FA3A6BDEDE2F91A7C444 /* BitmapFormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitmapFormatSniffer.swift; sourceTree = ""; }; @@ -794,6 +797,7 @@ C96FD34093B3C3E83827B70C /* FileSystemError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemError.swift; sourceTree = ""; }; CAD79372361D085CA0500CF4 /* Properties+OPDS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+OPDS.swift"; sourceTree = ""; }; CBB57FCAEE605484A7290DBB /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + CBE02C0EB77A716286A36152 /* GuidedNavigationObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidedNavigationObject.swift; sourceTree = ""; }; CC925E451D875E5F74748EDC /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; CCD5904F9B9E29E2C1CA1CB5 /* LCPKeychainPassphraseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPKeychainPassphraseRepository.swift; sourceTree = ""; }; CDA8111A330AB4D7187DD743 /* LocatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorService.swift; sourceTree = ""; }; @@ -1495,6 +1499,15 @@ path = "Absolute URL"; sourceTree = ""; }; + 6558D30FDB081E512115E361 /* GuidedNavigation */ = { + isa = PBXGroup; + children = ( + 08FC8BFB6FE5B5A18EA696DE /* GuidedNavigationDocument.swift */, + CBE02C0EB77A716286A36152 /* GuidedNavigationObject.swift */, + ); + path = GuidedNavigation; + sourceTree = ""; + }; 699E0FDF48F79D5EEACE0436 /* Resources */ = { isa = PBXGroup; children = ( @@ -2063,6 +2076,7 @@ 28792F801221D49F61B92CF8 /* TDM.swift */, AD3EFEE43B6E256F6AFB1F53 /* Accessibility */, 055166DFDEE6C6A17D04D42D /* Extensions */, + 6558D30FDB081E512115E361 /* GuidedNavigation */, C1002695D860AE505D689C26 /* Media Overlays */, 75C5238287B0D2F1DF6889DB /* Protection */, 4898F65BFF048F7966C82B74 /* Services */, @@ -2698,6 +2712,8 @@ C368C73C819F65CE3409D35D /* Fuzi.swift in Sources */, 216EA1C1ABA15836D60D910C /* GeneratedCoverService.swift in Sources */, 66018235ED40B89D27EE9F33 /* Group.swift in Sources */, + 007A17CE92F8F2CFEBD91534 /* GuidedNavigationDocument.swift in Sources */, + 9A5AE6CF737280C031231519 /* GuidedNavigationObject.swift in Sources */, A8F8C4F2C0795BACE0A8C62C /* HREFNormalizer.swift in Sources */, A348284A6738CD705288CB8C /* HTMLFormatSniffer.swift in Sources */, 594CE84C2B11169AA0B86615 /* HTMLResourceContentIterator.swift in Sources */, diff --git a/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift new file mode 100644 index 0000000000..920552a075 --- /dev/null +++ b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift @@ -0,0 +1,67 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@testable import ReadiumShared +import Testing + +@Suite enum GuidedNavigationDocumentTests { + @Suite("Parsing") struct Parsing { + @Test("minimal JSON with just guided") + func minimalJSON() throws { + let sut = try GuidedNavigationDocument(json: [ + "guided": [ + ["textref": "chapter1.html"], + ], + ]) + + #expect(try sut == GuidedNavigationDocument( + guided: [ + #require(GuidedNavigationObject(refs: .init(text: AnyURL(string: "chapter1.html")))), + ] + )) + } + + @Test("full JSON with links and guided") + func fullJSON() throws { + let sut = try GuidedNavigationDocument(json: [ + "links": [ + ["href": "https://example.com/manifest.json", "type": "application/webpub+json", "rel": "self"], + ], + "guided": [ + ["textref": "chapter1.html"], + ["audioref": "track.mp3"], + ], + ]) + + #expect(sut?.guided.count == 2) + #expect(sut?.links.count == 1) + } + + @Test("missing guided throws") + func missingGuided() throws { + #expect(throws: JSONError.self) { + try GuidedNavigationDocument(json: [ + "links": [], + ]) + } + } + + @Test("empty guided array throws") + func emptyGuided() throws { + #expect(throws: JSONError.self) { + try GuidedNavigationDocument(json: [ + "guided": [], + ]) + } + } + + @Test("nil JSON returns nil") + func nilJSON() throws { + let sut = try GuidedNavigationDocument(json: nil) + #expect(sut == nil) + } + } +} diff --git a/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift new file mode 100644 index 0000000000..14eee7ece1 --- /dev/null +++ b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift @@ -0,0 +1,243 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@testable import ReadiumShared +import Testing + +@Suite enum GuidedNavigationObjectTests { + @Suite("Parsing") struct Parsing { + @Test("minimal JSON with only textref") + func minimalTextref() throws { + let sut = try GuidedNavigationObject(json: [ + "textref": "chapter1.html", + ]) + #expect(sut == GuidedNavigationObject( + refs: .init(text: AnyURL(string: "chapter1.html")) + )) + } + + @Test("full JSON with all properties") + func fullJSON() throws { + let sut = try GuidedNavigationObject(json: [ + "id": "obj1", + "audioref": "audio.mp3#t=0,20", + "imgref": "page1.jpg", + "textref": "chapter1.html", + "videoref": "video.mp4#t=10,30", + "text": ["plain": "Hello", "ssml": "Hello", "language": "en"], + "role": ["chapter", "heading2"], + "children": [ + ["textref": "child.html"], + ], + "description": ["textref": "desc.html"], + ]) + + #expect(try sut == GuidedNavigationObject( + id: "obj1", + refs: .init( + text: AnyURL(string: "chapter1.html"), + img: AnyURL(string: "page1.jpg"), + audio: AnyURL(string: "audio.mp3#t=0,20"), + video: AnyURL(string: "video.mp4#t=10,30") + ), + text: .init(plain: "Hello", ssml: "Hello", language: Language(code: .bcp47("en"))), + role: [.chapter, .heading2], + description: .init(refs: .init(text: AnyURL(string: "desc.html"))), + children: [ + #require(GuidedNavigationObject(refs: .init(text: AnyURL(string: "child.html")))), + ] + )) + } + + @Test("text as bare string normalizes to Text(plain:)") + func textBareString() throws { + let sut = try GuidedNavigationObject(json: [ + "text": "Hello world", + ]) + #expect(sut?.text == GuidedNavigationObject.Text(plain: "Hello world")) + } + + @Test("text as object with plain, ssml, and language") + func textObject() throws { + let sut = try GuidedNavigationObject(json: [ + "text": ["plain": "Hello", "ssml": "Hello", "language": "en"], + ]) + #expect(sut?.text == GuidedNavigationObject.Text( + plain: "Hello", + ssml: "Hello", + language: Language(code: .bcp47("en")) + )) + } + + @Test("requires at least one ref, text, or children") + func requiresContent() throws { + #expect(throws: JSONError.self) { + try GuidedNavigationObject(json: [ + "id": "empty", + "role": ["chapter"], + ]) + } + } + + @Test("nested children parse correctly") + func nestedChildren() throws { + let sut = try GuidedNavigationObject(json: [ + "children": [ + [ + "textref": "a.html", + "children": [ + ["textref": "b.html"], + ], + ], + ], + ]) + + #expect(sut?.children.count == 1) + #expect(sut?.children.first?.children.count == 1) + #expect(sut?.children.first?.children.first?.refs?.text == AnyURL(string: "b.html")!) + } + + @Test("recursive description parses correctly") + func recursiveDescription() throws { + let sut = try GuidedNavigationObject(json: [ + "textref": "main.html", + "description": [ + "text": "A description", + ], + ]) + + #expect(sut?.description == GuidedNavigationObject.Description( + text: .init(plain: "A description") + )) + } + + @Test("unknown roles are preserved") + func unknownRoles() throws { + let sut = try GuidedNavigationObject(json: [ + "textref": "c.html", + "role": ["chapter", "custom-role"], + ]) + #expect(sut?.role == [.chapter, GuidedNavigationObject.Role("custom-role")]) + } + + @Test("nil JSON returns nil") + func nilJSON() throws { + let sut = try GuidedNavigationObject(json: nil) + #expect(sut == nil) + } + } + + @Suite("Refs") struct RefsTests { + @Test("parses all ref types from JSON") + func parsesAllRefs() throws { + let sut = try GuidedNavigationObject.Refs(json: [ + "textref": "chapter.html", + "imgref": "page.jpg", + "audioref": "track.mp3", + "videoref": "clip.mp4", + ]) + #expect(sut == GuidedNavigationObject.Refs( + text: AnyURL(string: "chapter.html"), + img: AnyURL(string: "page.jpg"), + audio: AnyURL(string: "track.mp3"), + video: AnyURL(string: "clip.mp4") + )) + } + + @Test("returns nil when no refs present") + func nilWhenNoRefs() throws { + let sut = try GuidedNavigationObject.Refs(json: [ + "id": "test", + ]) + #expect(sut == nil) + } + + @Test("preserves URI fragments") + func preservesFragments() throws { + let sut = try GuidedNavigationObject.Refs(json: [ + "audioref": "audio.mp3#t=0,20", + ]) + #expect(sut?.audio == AnyURL(string: "audio.mp3#t=0,20")!) + } + } + + @Suite("Description") struct DescriptionTests { + @Test("parses description with text") + func withText() throws { + let sut = try GuidedNavigationObject.Description(json: [ + "text": "A description", + ]) + #expect(sut == GuidedNavigationObject.Description( + text: .init(plain: "A description") + )) + } + + @Test("parses description with refs") + func withRefs() throws { + let sut = try GuidedNavigationObject.Description(json: [ + "imgref": "desc.jpg", + ]) + #expect(sut == GuidedNavigationObject.Description( + refs: .init(img: AnyURL(string: "desc.jpg")) + )) + } + + @Test("throws when empty") + func throwsWhenEmpty() throws { + #expect(throws: JSONError.self) { + try GuidedNavigationObject.Description(json: [ + "id": "nothing", + ]) + } + } + } + + @Suite("Text") struct TextTests { + @Test("returns nil when both plain and ssml are nil") + func nilWhenBothNil() { + let text = GuidedNavigationObject.Text() + #expect(text == nil) + } + + @Test("returns nil when plain is empty and ssml is nil") + func nilWhenPlainEmpty() { + let text = GuidedNavigationObject.Text(plain: "") + #expect(text == nil) + } + + @Test("returns nil when ssml is empty and plain is nil") + func nilWhenSsmlEmpty() { + let text = GuidedNavigationObject.Text(ssml: "") + #expect(text == nil) + } + + @Test("returns nil when both plain and ssml are empty strings") + func nilWhenBothEmpty() { + let text = GuidedNavigationObject.Text(plain: "", ssml: "") + #expect(text == nil) + } + + @Test("succeeds when only plain is non-empty") + func succeedsWithPlainOnly() { + let text = GuidedNavigationObject.Text(plain: "Hello") + #expect(text != nil) + #expect(text?.plain == "Hello") + } + + @Test("succeeds when only ssml is non-empty") + func succeedsWithSsmlOnly() { + let text = GuidedNavigationObject.Text(ssml: "Hi") + #expect(text != nil) + #expect(text?.ssml == "Hi") + } + + @Test("JSON object with empty plain and ssml returns nil") + func jsonObjectEmptyStringsReturnsNil() throws { + let sut = try GuidedNavigationObject.Text(json: ["plain": "", "ssml": ""]) + #expect(sut == nil) + } + } +} From b97f7b1c226f06a8ef5e47bb9c4a1cca5c59d690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 18 Feb 2026 13:37:27 +0100 Subject: [PATCH 40/55] Fix decorations broken for EPUB resources with `.xml` extension (#726) --- .../EPUB/EPUBNavigatorViewModel.swift | 43 ++++++++++++++++--- .../EPUB/HTMLDecorationTemplate.swift | 10 +++++ .../Navigator/EPUB/Scripts/webpack.config.js | 1 + Sources/Navigator/EPUB/WebViewServer.swift | 36 ++++++++-------- .../Reader/EPUB/EPUBViewController.swift | 3 +- 5 files changed, 67 insertions(+), 26 deletions(-) diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 10a5c1cc76..9cd01ba8b9 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -35,6 +35,10 @@ enum EPUBScriptScope { /// the web view. let server: WebViewServer + /// Format sniffer used to infer the media type of resources served with + /// the `server`. + let formatSniffer: FormatSniffer + weak var delegate: EPUBNavigatorViewModelDelegate? let readingOrder: ReadingOrder @@ -47,7 +51,8 @@ enum EPUBScriptScope { let assetsDirectory = Bundle.module.resourceURL!.fileURL! .appendingPath("Assets/Static", isDirectory: true) - let server = WebViewServer(scheme: "readium") + let formatSniffer = DefaultFormatSniffer() + let server = WebViewServer(scheme: "readium", formatSniffer: formatSniffer) // Serve static assets directory. let assetsBaseURL = server.serve(directory: assetsDirectory, at: "assets") @@ -57,7 +62,8 @@ enum EPUBScriptScope { readingOrder: readingOrder, config: config, server: server, - assetsBaseURL: assetsBaseURL + assetsBaseURL: assetsBaseURL, + formatSniffer: formatSniffer ) if let url = publication.baseURL { @@ -68,7 +74,7 @@ enum EPUBScriptScope { } else { // Serve publication resources. publicationBaseURL = server.serve(at: UUID().uuidString) { [weak self] in - self?.serve(href: $0) + await self?.serve(href: $0) } } } @@ -78,7 +84,8 @@ enum EPUBScriptScope { readingOrder: ReadingOrder, config: EPUBNavigatorViewController.Configuration, server: WebViewServer, - assetsBaseURL: any AbsoluteURL + assetsBaseURL: any AbsoluteURL, + formatSniffer: FormatSniffer ) { var config = config @@ -117,6 +124,7 @@ enum EPUBScriptScope { ) self.server = server self.assetsBaseURL = assetsBaseURL + self.formatSniffer = formatSniffer preferences = config.preferences settings = EPUBSettings(publication: publication, config: config) @@ -160,11 +168,32 @@ enum EPUBScriptScope { // MARK: - Web View Server - private func serve(href: RelativeURL) -> Resource? { - guard let resource = publication.get(href) else { + private func serve(href: RelativeURL) async -> (Resource, MediaType)? { + guard var resource = publication.get(href) else { return nil } - return injectReadiumCSS(in: resource, at: href) + let mediaType = await resolveMediaType(for: resource, at: href) + resource = injectReadiumCSS(in: resource, at: href) + return (resource, mediaType) + } + + /// Resolves the media type to use to serve the given `resource`. + /// + /// The media type declared in the manifest takes precedence, before falling + /// back on the `Resource` properties and sniffing the `href`. + /// + /// The manifest takes precedence because a file with a `.xml` extension + /// might be declared as `application/xhtml+xml` in the OPF. + private func resolveMediaType(for resource: Resource, at href: RelativeURL) async -> MediaType { + if let mediaType = publication.linkWithHREF(href)?.mediaType { + return mediaType + } + if let mediaType = await resource.properties().getOrNil()?.mediaType { + return mediaType + } + + return href.pathExtension.flatMap { formatSniffer.sniffHints(.init(fileExtension: $0))?.mediaType } + ?? .binary } // MARK: - User preferences diff --git a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift index 86161350ae..a385dbc056 100644 --- a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift +++ b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift @@ -55,6 +55,16 @@ public struct HTMLDecorationTemplate { } /// Creates the default list of decoration styles with associated HTML templates. + /// + /// - Parameters: + /// - defaultTint: Default highlight/underline color when the decoration + /// has no tint set. + /// - lineWeight: Thickness in pixels of the underline stroke. + /// - cornerRadius: Border radius in pixels applied to each decoration box. + /// - alpha: Opacity of the highlight fill color (0–1). + /// - experimentalPositioning: When true, places decorations behind the + /// publication text using a negative z-index, preventing the highlight + /// from affecting text color. This may not work with all publications. public static func defaultTemplates( defaultTint: UIColor = .yellow, lineWeight: Int = 2, diff --git a/Sources/Navigator/EPUB/Scripts/webpack.config.js b/Sources/Navigator/EPUB/Scripts/webpack.config.js index d5072bca02..d676772c65 100644 --- a/Sources/Navigator/EPUB/Scripts/webpack.config.js +++ b/Sources/Navigator/EPUB/Scripts/webpack.config.js @@ -3,6 +3,7 @@ const path = require("path"); module.exports = { mode: "production", devtool: "source-map", + // devtool: "eval-source-map", entry: { reflowable: "./src/index-reflowable.js", fixed: "./src/index-fixed.js", diff --git a/Sources/Navigator/EPUB/WebViewServer.swift b/Sources/Navigator/EPUB/WebViewServer.swift index c17f5950f9..e85bac9337 100644 --- a/Sources/Navigator/EPUB/WebViewServer.swift +++ b/Sources/Navigator/EPUB/WebViewServer.swift @@ -16,10 +16,12 @@ import WebKit /// The custom scheme used to serve the content. let scheme: String - private let formatSniffer = DefaultFormatSniffer() + /// Format sniffer used to infer the media type of served resources. + let formatSniffer: FormatSniffer - init(scheme: String) { + init(scheme: String, formatSniffer: FormatSniffer) { self.scheme = scheme + self.formatSniffer = formatSniffer super.init() } @@ -28,7 +30,7 @@ import WebKit private enum RouteHandler { case file(FileURL) case directory(FileURL) - case resources(@MainActor (RelativeURL) -> Resource?) + case resources(@MainActor (RelativeURL) async -> (Resource, MediaType)?) } /// Registered routes, sorted by reverse alphabetical order to ensure @@ -69,7 +71,7 @@ import WebKit /// /// Returns the base URL (e.g. `readium://{uuid}/`). @discardableResult - func serve(at route: String, handler: @escaping @MainActor (RelativeURL) -> Resource?) -> AbsoluteURL { + func serve(at route: String, handler: @escaping @MainActor (RelativeURL) async -> (Resource, MediaType)?) -> AbsoluteURL { let route = normalizedRoute(route, isDirectory: true) let baseURL = AnyURL(string: "\(scheme)://\(route)")!.absoluteURL! insertRoute((path: route, baseURL: baseURL, handler: .resources(handler))) @@ -178,27 +180,27 @@ import WebKit private func serveResource( _ urlSchemeTask: WKURLSchemeTask, relativeURL: RelativeURL, - handler: @MainActor (RelativeURL) -> Resource?, + handler: @MainActor (RelativeURL) async -> (Resource, MediaType)?, requestURL: URL ) async { // Reuse a cached buffered resource to benefit from forward-seek // optimization and read-ahead buffering, or create and cache a new // one. let resource: Resource - if let cached = resourceCache[relativeURL] { - resource = cached + let mediaType: MediaType + if let (cachedResource, cachedMediaType) = resourceCache[relativeURL] { + resource = cachedResource + mediaType = cachedMediaType } else { - guard var res = handler(relativeURL) else { + guard let (newResource, newMediaType) = await handler(relativeURL) else { await fail(urlSchemeTask, with: URLError(.fileDoesNotExist)) return } - res = res.buffered(size: 256 * 1024) - resourceCache.set(relativeURL, res) - resource = res + resource = newResource.buffered(size: 256 * 1024) + mediaType = newMediaType + resourceCache.set(relativeURL, resource: resource, mediaType: mediaType) } - let mediaType = await resource.properties().getOrNil()?.mediaType ?? mediaTypeFromURL(relativeURL) - await serveResource( resource, with: urlSchemeTask, @@ -335,18 +337,18 @@ private extension URLRequest { /// through chapters. private struct BoundedResourceCache { private let capacity = 8 - private var entries: [RelativeURL: Resource] = [:] + private var entries: [RelativeURL: (Resource, MediaType)] = [:] private var order: [RelativeURL] = [] - subscript(key: RelativeURL) -> Resource? { + subscript(key: RelativeURL) -> (Resource, MediaType)? { entries[key] } - mutating func set(_ key: RelativeURL, _ value: Resource) { + mutating func set(_ key: RelativeURL, resource: Resource, mediaType: MediaType) { if entries[key] == nil { order.append(key) } - entries[key] = value + entries[key] = (resource, mediaType) while order.count > capacity { let evicted = order.removeFirst() diff --git a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift index de9476e57b..06b1efa6cc 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift @@ -29,8 +29,7 @@ class EPUBViewController: VisualReaderViewController ) throws { - // Create default templates, but make highlights opaque with experimental positioning. - var templates = HTMLDecorationTemplate.defaultTemplates(alpha: 1.0, experimentalPositioning: true) + var templates = HTMLDecorationTemplate.defaultTemplates() templates[.pageList] = .pageList let resources = FileURL(url: Bundle.main.resourceURL!)! From decacb6314743787d9c515c25630742c8fd6eb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 18 Feb 2026 16:42:45 +0100 Subject: [PATCH 41/55] Parse EPUB Media Overlays metadata into `EPUBMediaOverlay` (#727) --- .../Extensions/EPUB/EPUBMediaOverlay.swift | 39 ++++ .../Extensions/EPUB/Metadata+EPUB.swift | 16 ++ .../Media Overlays/MediaOverlayNode.swift | 65 ------ .../Media Overlays/MediaOverlays.swift | 200 ------------------ .../Parser/EPUB/EPUBManifestParser.swift | 46 ---- .../Parser/EPUB/EPUBMetadataParser.swift | 20 +- Sources/Streamer/Parser/EPUB/EPUBParser.swift | 6 - Sources/Streamer/Parser/EPUB/OPFMeta.swift | 2 +- Sources/Streamer/Parser/EPUB/OPFParser.swift | 14 +- .../Streamer/Parser/EPUB/SMILClockValue.swift | 46 ++++ Support/Carthage/.xcodegen | 6 +- .../Readium.xcodeproj/project.pbxproj | 28 ++- .../Extensions/EPUB/Metadata+EPUBTests.swift | 90 ++++++++ .../Fixtures/OPF/media-overlays.opf | 19 ++ .../Parser/EPUB/EPUBMetadataParserTests.swift | 29 +++ .../Parser/EPUB/OPFParserTests.swift | 12 +- .../Parser/EPUB/SMILClockValueTests.swift | 130 ++++++++++++ 17 files changed, 421 insertions(+), 347 deletions(-) create mode 100644 Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift create mode 100644 Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift delete mode 100644 Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift delete mode 100644 Sources/Shared/Publication/Media Overlays/MediaOverlays.swift create mode 100644 Sources/Streamer/Parser/EPUB/SMILClockValue.swift create mode 100644 Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift create mode 100644 Tests/StreamerTests/Fixtures/OPF/media-overlays.opf create mode 100644 Tests/StreamerTests/Parser/EPUB/SMILClockValueTests.swift diff --git a/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift b/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift new file mode 100644 index 0000000000..93751878a3 --- /dev/null +++ b/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift @@ -0,0 +1,39 @@ +// +// 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 ReadiumInternal + +/// EPUB Media Overlay metadata. +/// https://readium.org/webpub-manifest/profiles/epub#5-metadata +public struct EPUBMediaOverlay: Equatable, Sendable { + /// Author-defined CSS class name to apply to the currently-playing EPUB + /// Content Document element. + public var activeClass: String? + + /// Author-defined CSS class name to apply to the EPUB Content Document's + /// document element when playback is active. + public var playbackActiveClass: String? + + public init(activeClass: String? = nil, playbackActiveClass: String? = nil) { + self.activeClass = activeClass + self.playbackActiveClass = playbackActiveClass + } + + public init?(json: Any?) { + guard let json = json as? [String: Any] else { return nil } + activeClass = json["activeClass"] as? String + playbackActiveClass = json["playbackActiveClass"] as? String + guard activeClass != nil || playbackActiveClass != nil else { return nil } + } + + public var json: [String: Any] { + makeJSON([ + "activeClass": encodeIfNotNil(activeClass), + "playbackActiveClass": encodeIfNotNil(playbackActiveClass), + ]) + } +} diff --git a/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift new file mode 100644 index 0000000000..1466764af7 --- /dev/null +++ b/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift @@ -0,0 +1,16 @@ +// +// 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 + +private let mediaOverlayKey = "mediaOverlay" + +public extension Metadata { + /// Media overlay CSS class names for this publication. + var mediaOverlay: EPUBMediaOverlay? { + EPUBMediaOverlay(json: otherMetadata[mediaOverlayKey]) + } +} diff --git a/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift b/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift deleted file mode 100644 index a5ae62e004..0000000000 --- a/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift +++ /dev/null @@ -1,65 +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 Foundation - -// The publicly accessible struct. - -/// Clip is the representation of a MediaOverlay file fragment. A clip represent -/// the synchronized audio for a piece of text, it has a file where its data -/// belong, start/end times relatives to this file's data and a duration -/// calculated from the aforementioned values. -public struct Clip { - /// The relative URL. - public var relativeUrl: URL! - /// The relative fragmentId. - public var fragmentId: String? - /// Start time in seconds. - public var start: Double! - /// End time in seconds. - public var end: Double! - /// Total clip duration in seconds (end - start). - /// @available(iOS, deprecated: 9.0, message: "Don't use it when the value is negative, because some information is missing in the original SMIL file. Try to get the duration from file system or APIs in Fetcher, then minus the start value.") - public var duration: Double! - - public init() {} -} - -/// The Error enumeration of the MediaOverlayNode class. -/// -/// - audio: Couldn't generate a proper clip due to erroneous audio property. -/// - timersParsing: Couldn't generate a proper clip due to timersParsing failure. -public enum MediaOverlayNodeError: Error { - case audio - case timersParsing -} - -/// Represents a MediaOverlay XML node. -public class MediaOverlayNode { - public var text: String? - public var clip: Clip? - - public var role = [String]() - public var children = [MediaOverlayNode]() - - public init(_ text: String? = nil, clip: Clip? = nil) { - self.text = text - self.clip = clip - self.clip?.fragmentId = fragmentId() - } - - // MARK: - Internal Methods. - - /// Return the MO node's fragmentId. - /// - /// - Returns: Node's fragment id. - public func fragmentId() -> String? { - guard let text = text else { - return nil - } - return text.components(separatedBy: "#").last - } -} diff --git a/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift b/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift deleted file mode 100644 index aeda6e15c4..0000000000 --- a/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift +++ /dev/null @@ -1,200 +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 Foundation - -/// Errors related to MediaOverlays. -/// -/// - nodeNotFound: Couldn't find any node for the given `forFragmentId`. -public enum MediaOverlaysError: Error { - case nodeNotFound(forFragmentId: String?) -} - -// The functionnal wrapper around mediaOverlayNodes. - -/// The object representing the MediaOverlays for a Link. -/// Two ways of using it, using the `Clip`s or `MediaOverlayNode`s. -/// Clips or a functionnal representation of a `MediaOverlayNode` (while the -/// MediaOverlayNode is more of an XML->Object representation. -public class MediaOverlays { - public var nodes: [MediaOverlayNode]! - - public init(withNodes nodes: [MediaOverlayNode] = [MediaOverlayNode]()) { - self.nodes = nodes - } - - public func append(_ newNode: MediaOverlayNode) { - nodes.append(newNode) - } - - /// Get the audio `Clip` associated to an audio Fragment id. - /// The fragment id can be found in the HTML document in

& tags, - /// it refer to a element of one of the SMIL files, providing informations - /// about the synchronized audio. - /// This function returns the clip representing this element from SMIL. - /// - /// - Parameter id: The audio fragment id. - /// - Returns: The `Clip`, representation of the associated SMIL element. - /// - Throws: `MediaOverlayNodeError.audio`, - /// `MediaOverlayNodeError.timersParsing`. - public func clip(forFragmentId id: String) throws -> Clip? { - let clip: Clip? - - do { - let fragmentNode = try node(forFragmentId: id) - - clip = fragmentNode.clip - } - return clip - } - - /// Get the audio `Clip` for the node right after the one designated by - /// `id`. - /// The fragment id can be found in the HTML document in

& tags, - /// it refer to a element of one of the SMIL files, providing informations - /// about the synchronized audio. - /// This function returns the `Clip representing the element following this - /// element from SMIL. - /// - /// - Parameter id: The audio fragment id. - /// - Returns: The `Clip` for the node element positioned right after the - /// one designated by `id`. - /// - Throws: `MediaOverlayNodeError.audio`, - /// `MediaOverlayNodeError.timersParsing`. - public func clip(nextAfterFragmentId id: String) throws -> Clip? { - let clip: Clip? - - do { - let fragmentNextNode = try node(nextAfterFragmentId: id) - clip = fragmentNextNode.clip - } - return clip - } - - /// Return the `MediaOverlayNode` found for the given 'fragment id'. - /// - /// - Parameter forFragment: The SMIL fragment identifier. - /// - Returns: The node associated to the fragment. - public func node(forFragmentId id: String?) throws -> MediaOverlayNode { - guard let node = _findNode(forFragment: id, inNodes: nodes) else { - throw MediaOverlaysError.nodeNotFound(forFragmentId: id) - } - return node - } - - /// Return the `MediaOverlayNode` right after the node found for the given - /// 'fragment id'. - /// - /// - Parameter forFragment: The SMIL fragment identifier. - /// - Returns: The node right after the node associated to the fragment. - public func node(nextAfterFragmentId id: String?) throws -> MediaOverlayNode { - let ret = _findNextNode(forFragment: id, inNodes: nodes) - - guard let node = ret.found else { - throw MediaOverlaysError.nodeNotFound(forFragmentId: id) - } - return node - } - - // MARK: - Fileprivate Methods. - - /// [RECURISVE] - /// Find the node () corresponding to "fragment" ?? nil. - /// - /// - Parameters: - /// - fragment: The current fragment name for which we are looking the - /// associated media overlay node. - /// - nodes: The set of MediaOverlayNodes where to search. Default to - /// self children. - /// - Returns: The node we found ?? nil. - private func _findNode(forFragment fragment: String?, - inNodes nodes: [MediaOverlayNode]) -> MediaOverlayNode? - { - // For each node of the current scope.. - for node in nodes { - // If the node is a "section" ( sequence element).. - // TODO: ask if really useful? - if node.role.contains("section") { - // Try to find par nodes inside. - if let found = _findNode(forFragment: fragment, inNodes: node.children) { - return found - } - } - // If the node text refer to filename or that filename is nil, - // return node. - if fragment == nil || node.text?.contains(fragment!) ?? false { - return node - } - } - // If nothing found, return nil. - return nil - } - - /// [RECURISVE] - /// Find the node () corresponding to the next one after the given - /// "fragment" ?? nil. - /// - /// - Parameters: - /// - fragment: The fragment name corresponding to the node previous to - /// the one we want. - /// - nodes: The set of MediaOverlayNodes where to search. Default to - /// self children. - /// - Returns: The node we found ?? nil. - private func _findNextNode(forFragment fragment: String?, - inNodes nodes: [MediaOverlayNode]) -> (found: MediaOverlayNode?, prevFound: Bool) - { - var previousNodeFoundFlag = false - - // For each node of the current scope.. - for node in nodes { - guard !previousNodeFoundFlag else { - // If the node is a section, we get the first non section child. - if node.role.contains("section") { - if let validChild = getFirstNonSectionChild(of: node) { - return (validChild, false) - } else { - // Try next nodes. - continue - } - } - // Else we just return it. - return (node, false) - } - // If the node is a "section" ( sequence element).. - if node.role.contains("section") { - let ret = _findNextNode(forFragment: fragment, inNodes: node.children) - if let foundNode = ret.found { - return (foundNode, false) - } - previousNodeFoundFlag = ret.prevFound - } - // If the node text refer to filename or that filename is nil, - // return node. - if fragment == nil || node.text?.contains(fragment!) ?? false { - previousNodeFoundFlag = true - } - } - // If nothing found, return nil. - return (nil, previousNodeFoundFlag) - } - - /// Returns the closest non section children node found. - /// - /// - Parameter node: The section node - /// - Returns: The closest non section node or nil. - private func getFirstNonSectionChild(of node: MediaOverlayNode) -> MediaOverlayNode? { - for node in node.children { - if node.role.contains("section") { - if let found = getFirstNonSectionChild(of: node) { - return found - } - } else { - return node - } - } - return nil - } -} diff --git a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift index ddaca53740..19a5ac62b2 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift @@ -152,50 +152,4 @@ final class EPUBManifestParser { return collections } - - /// Parse the mediaOverlays informations contained in the ressources then - /// parse the associted SMIL files to populate the MediaOverlays objects - /// in each of the ReadingOrder's Links. - private func parseMediaOverlay(from container: Container, to publication: inout Publication) throws { - // FIXME: For now we don't fill the media-overlays anymore, since it was only half implemented and the API will change -// let mediaOverlays = publication.resources.filter(byType: .smil) -// -// guard !mediaOverlays.isEmpty else { -// log(.info, "No media-overlays found in the Publication.") -// return -// } -// for mediaOverlayLink in mediaOverlays { -// let node = MediaOverlayNode() -// -// guard let smilData = try? fetcher.data(at: mediaOverlayLink.href), -// let smilXml = try? XMLDocument(data: smilData) else -// { -// throw OPFParserError.invalidSmilResource -// } -// -// smilXml.definePrefix("smil", forNamespace: "http://www.w3.org/ns/SMIL") -// smilXml.definePrefix("epub", forNamespace: "http://www.idpf.org/2007/ops") -// guard let body = smilXml.firstChild(xpath: "./smil:body") else { -// continue -// } -// -// node.role.append("section") -// if let textRef = body.attr("textref") { // Prevent the crash on the japanese book -// node.text = HREF(textRef, relativeTo: mediaOverlayLink.href).string -// } -// // get body parameters a -// let href = mediaOverlayLink.href -// SMILParser.parseParameters(in: body, withParent: node, base: href) -// SMILParser.parseSequences(in: body, withParent: node, publicationReadingOrder: &publication.readingOrder, base: href) - // "/??/xhtml/mo-002.xhtml#mo-1" => "/??/xhtml/mo-002.xhtml" - -// guard let baseHref = node.text?.components(separatedBy: "#")[0], -// let link = publication.readingOrder.first(where: { baseHref.contains($0.href) }) else -// { -// continue -// } -// link.mediaOverlays.append(node) -// link.properties.mediaOverlay = EPUBConstant.mediaOverlayURL + link.href -// } - } } diff --git a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift index 051920701b..2c49386793 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift @@ -39,6 +39,9 @@ final class EPUBMetadataParser: Loggable { contributorsByRole[role] ?? [] } + var other = metas.otherMetadata + if let mo = mediaOverlay() { other["mediaOverlay"] = mo.json } + return Metadata( identifier: uniqueIdentifier, conformsTo: [.epub], @@ -62,11 +65,12 @@ final class EPUBMetadataParser: Loggable { layout: layout(), readingProgression: readingProgression, description: description, + duration: mediaDuration, numberOfPages: numberOfPages, belongsToCollections: belongsToCollections, belongsToSeries: belongsToSeries, tdm: tdm(), - otherMetadata: metas.otherMetadata + otherMetadata: other ) } @@ -283,6 +287,20 @@ final class EPUBMetadataParser: Loggable { .map { Accessibility.Exemption($0.content) } } + /// Publication-level SMIL duration (no `refines`). + private lazy var mediaDuration: Double? = + metas["duration", in: .media] + .first(where: { $0.refines == nil }) + .flatMap { parseSmilClockValue($0.content) } + + /// Media overlay CSS class names. + private func mediaOverlay() -> EPUBMediaOverlay? { + let active = metas["active-class", in: .media].first?.content + let playbackActive = metas["playback-active-class", in: .media].first?.content + guard active != nil || playbackActive != nil else { return nil } + return EPUBMediaOverlay(activeClass: active, playbackActiveClass: playbackActive) + } + /// https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/#sec-epub3 private func tdm() -> TDM? { guard diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index a0a97904fd..bb74ddfbfe 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -8,12 +8,6 @@ import Foundation import ReadiumFuzi import ReadiumShared -/// Epub related constants. -private enum EPUBConstant { - /// Media Overlays URL. - static let mediaOverlayURL = "media-overlay?resource=" -} - /// Errors thrown during the parsing of the EPUB /// /// - wrongMimeType: The mimetype file is missing or its content differs from diff --git a/Sources/Streamer/Parser/EPUB/OPFMeta.swift b/Sources/Streamer/Parser/EPUB/OPFMeta.swift index 1acc4a0fb5..c2c1f31aea 100644 --- a/Sources/Streamer/Parser/EPUB/OPFMeta.swift +++ b/Sources/Streamer/Parser/EPUB/OPFMeta.swift @@ -309,7 +309,7 @@ struct OPFMetaList { "language", "modified", "publisher", "subject", "title", "conformsTo", ], - .media: ["duration"], + .media: ["duration", "active-class", "playback-active-class", "narrator"], .rendition: ["layout"], .schema: [ "numberOfPages", "accessMode", "accessModeSufficient", diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 8062eb8250..09b5d31d59 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -19,13 +19,6 @@ public enum EPUBTitleType: String { case expanded } -public enum OPFParserError: Error { - /// The Epub have no title. Title is mandatory. - case missingPublicationTitle - /// Smile resource couldn't be parsed. - case invalidSmilResource -} - /// EpubParser support class, able to parse the OPF package document. /// OPF: Open Packaging Format. final class OPFParser: Loggable { @@ -206,11 +199,16 @@ final class OPFParser: Loggable { properties["encrypted"] = encryption } + let duration = metas["duration", in: .media, refining: id] + .first + .flatMap { parseSmilClockValue($0.content) } + let link = Link( href: href.string, mediaType: manifestItem.attr("media-type").flatMap { MediaType($0) }, rels: rels, - properties: Properties(properties) + properties: Properties(properties), + duration: duration ) return ManifestItem( diff --git a/Sources/Streamer/Parser/EPUB/SMILClockValue.swift b/Sources/Streamer/Parser/EPUB/SMILClockValue.swift new file mode 100644 index 0000000000..16234f8a0d --- /dev/null +++ b/Sources/Streamer/Parser/EPUB/SMILClockValue.swift @@ -0,0 +1,46 @@ +// +// 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 + +/// Parses a SMIL clock value string into seconds. +/// https://www.w3.org/TR/SMIL/smil-timing.html#Timing-ClockValueSyntax +func parseSmilClockValue(_ value: String) -> Double? { + let s = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !s.isEmpty else { return nil } + + // Timecount values: Nh, Nmin, Ns, Nms, N + let timecountPatterns: [(suffix: String, multiplier: Double)] = [ + ("h", 3600), + ("min", 60), + ("ms", 0.001), + ("s", 1), + ] + for (suffix, multiplier) in timecountPatterns { + if s.hasSuffix(suffix) { + let numStr = String(s.dropLast(suffix.count)) + if let n = Double(numStr) { + return n * multiplier + } + } + } + + // Clock values: [[hh:]mm:]ss[.fraction] + let parts = s.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + switch parts.count { + case 2: + // mm:ss[.fraction] + guard let mm = Double(parts[0]), let ss = Double(parts[1]) else { return nil } + return mm * 60 + ss + case 3: + // hh:mm:ss[.fraction] + guard let hh = Double(parts[0]), let mm = Double(parts[1]), let ss = Double(parts[2]) else { return nil } + return hh * 3600 + mm * 60 + ss + default: + // Plain number (seconds) + return Double(s) + } +} diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index cfe2838f65..a7544c5954 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -625,6 +625,8 @@ ../../Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift ../../Sources/Shared/Publication/Extensions/EPUB ../../Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift +../../Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift +../../Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift ../../Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift ../../Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift ../../Sources/Shared/Publication/Extensions/HTML @@ -648,9 +650,6 @@ ../../Sources/Shared/Publication/Locator.swift ../../Sources/Shared/Publication/Manifest.swift ../../Sources/Shared/Publication/ManifestTransformer.swift -../../Sources/Shared/Publication/Media Overlays -../../Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift -../../Sources/Shared/Publication/Media Overlays/MediaOverlays.swift ../../Sources/Shared/Publication/Metadata.swift ../../Sources/Shared/Publication/Properties.swift ../../Sources/Shared/Publication/Protection @@ -852,6 +851,7 @@ ../../Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift ../../Sources/Streamer/Parser/EPUB/Services ../../Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +../../Sources/Streamer/Parser/EPUB/SMILClockValue.swift ../../Sources/Streamer/Parser/EPUB/XMLNamespace.swift ../../Sources/Streamer/Parser/Image ../../Sources/Streamer/Parser/Image/ComicInfoParser.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index e48f61004f..360ce69229 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -104,6 +104,7 @@ 38E81FCDABFBB514685402B8 /* CoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 342D5C0FEE79A2ABEE24A43E /* CoreServices.framework */; }; 39326587EF76BFD5AD68AED2 /* ReadiumShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97BC822B36D72EF548162129 /* ReadiumShared.framework */; }; 39B1DDE3571AB3F3CC6824F4 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA827FC94F5CB3F9032028F /* JSON.swift */; }; + 3AA66AB994F11FC9470C7EDB /* EPUBMediaOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD220F39BD9BBC714F837C4 /* EPUBMediaOverlay.swift */; }; 3AD9E86BB1621CF836919E33 /* ReadiumLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38984FD65CFF1D54FF7F794F /* ReadiumLocalizedString.swift */; }; 3AF8DBD6431F7F5A156FFBCF /* EPUBManifestParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C73E1510486CBF553651D60 /* EPUBManifestParser.swift */; }; 3B138483530FDCC545A12D6F /* ZIPFoundationArchiveFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C1E0FDB5373E672D5FF80F /* ZIPFoundationArchiveFactory.swift */; }; @@ -197,7 +198,6 @@ 74E94DF537F0DE19706003DA /* NowPlayingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BCDFDD5327AB802F0F6460 /* NowPlayingInfo.swift */; }; 75E5BBD405026A68BF6741F9 /* AbsoluteURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DF154DCC73CFBDB0F919DE /* AbsoluteURL.swift */; }; 762CF84C6FB1FCCF03EA91B6 /* Loggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067E58BE65BCB4F8D1E8B911 /* Loggable.swift */; }; - 76F6EE39F504B6A80837C90D /* MediaOverlayNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DAAE19E8372F6ECF772E0A /* MediaOverlayNode.swift */; }; 77C828CCC90DF784B2D75774 /* OpdsMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DDB25FC1693613B72DFDB6E /* OpdsMetadata.swift */; }; 78C52EED635B5F8C38A02298 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B24895126F2A744A8E9E61 /* Metadata.swift */; }; 795B476F8BA9A8704E78394A /* AccessibilityMetadataDisplayGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0B58C7A924334D41A7AB1FF /* AccessibilityMetadataDisplayGuide.swift */; }; @@ -226,6 +226,7 @@ 8CD0D28056D2BBADE170ABF6 /* ContainerLicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9234A0351FDE626D8D242223 /* ContainerLicenseContainer.swift */; }; 8D6EFD7710BEB8539E4E64E6 /* DOMRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = C084C255A327387F36B97A62 /* DOMRange.swift */; }; 8E25FF2EEFA72D9B0F3025C5 /* PDFFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B72B76AB39E09E4A2E465AF /* PDFFormatSniffer.swift */; }; + 8EBE8665FD1D3BDE5FB3B8B9 /* Metadata+EPUB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 281F650E9B9CEE601D8125EE /* Metadata+EPUB.swift */; }; 8F5B0B5B83BF7F1145556FF8 /* Properties+OPDS.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD79372361D085CA0500CF4 /* Properties+OPDS.swift */; }; 9065C4C0F40B6A5601541EF7 /* Streamable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622CB8B75A568846FECA44D6 /* Streamable.swift */; }; 90CFD62B993F6759716C0AF0 /* LicensesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56286133DD0AE093F2C5E9FD /* LicensesService.swift */; }; @@ -253,7 +254,6 @@ 9B0369F8C0187528486440F4 /* CompositeInputObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8E202B8A16B960AE73CABF /* CompositeInputObserver.swift */; }; 9BC4D1F2958D2F7D7BDB88DA /* CursorList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C361F965E7A7962CA3E4C0BA /* CursorList.swift */; }; 9DB9674C11DF356966CBFA79 /* EPUBNavigatorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC9ACC1EB3903149EBF21BC0 /* EPUBNavigatorViewModel.swift */; }; - 9E064BC9E99D4F7D8AC3109B /* MediaOverlays.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F5FEE0323287B9CAA09F03 /* MediaOverlays.swift */; }; 9E6522796719FF1F16C243E7 /* MinizipContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E8D322A523DA324E3E2E59 /* MinizipContainer.swift */; }; 9E76790BAFFF08F0BFEA1BB0 /* DefaultPublicationParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5BBF2FA3188DFFCF7B88A75 /* DefaultPublicationParser.swift */; }; A036CCF310EB7408408FFF00 /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87629BF68F1EDBF06FC0AD54 /* ImageViewController.swift */; }; @@ -389,6 +389,7 @@ EFC69F5294C5B698598D0322 /* DefaultArchiveOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC150FB45A4AB33AF516AE09 /* DefaultArchiveOpener.swift */; }; F206058A5073F54E4090ECE8 /* SQLiteLCPPassphraseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22A0E76866F626D79F0A64C /* SQLiteLCPPassphraseRepository.swift */; }; F2BDD94FFFBD08526B56E337 /* URLHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D31097B3A8050A46CDAA5 /* URLHelper.swift */; }; + F5218746AB7FA152B59368FF /* SMILClockValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E59B7AEE4B4BC56FE6D93D4 /* SMILClockValue.swift */; }; F5CA172EAC7E0AFA9588C262 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93A2004A73E505BFEA8B56A /* Data.swift */; }; F5D24D8233B79EB2C55DFE80 /* EPUBReflowableSpreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C45688D0A9C1F81F463FF92 /* EPUBReflowableSpreadView.swift */; }; F5D898348F4CDC13EB904050 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F56CFDC7E9254E99561152 /* Task.swift */; }; @@ -541,6 +542,7 @@ 1C22408FE1FA81400DE8D5F7 /* OPDSPrice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSPrice.swift; sourceTree = ""; }; 1D5053C2151DDDE4E8F06513 /* LCPPassphraseAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPPassphraseAuthentication.swift; sourceTree = ""; }; 1E175BF1A1F97687B4119BB1 /* Properties+Encryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Encryption.swift"; sourceTree = ""; }; + 1E59B7AEE4B4BC56FE6D93D4 /* SMILClockValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMILClockValue.swift; sourceTree = ""; }; 1EBC685D4A0E07997088DD2D /* DataCompression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCompression.swift; sourceTree = ""; }; 1F89BC365BDD19BE84F4D3B5 /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; 1FBFAC2D57DE7EBB4E2F31BE /* ReadiumNavigatorLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumNavigatorLocalizedString.swift; sourceTree = ""; }; @@ -554,6 +556,7 @@ 251275D0DF87F85158A5FEA9 /* Assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Assets; path = ../../Sources/Navigator/EPUB/Assets; sourceTree = SOURCE_ROOT; }; 258351CE21165EDED7F87878 /* URLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocol.swift; sourceTree = ""; }; 2732AFC91AB15FA09C60207A /* Locator+Audio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+Audio.swift"; sourceTree = ""; }; + 281F650E9B9CEE601D8125EE /* Metadata+EPUB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+EPUB.swift"; sourceTree = ""; }; 2828D89EBB52CCA782ED1146 /* ReadiumFuzi.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReadiumFuzi.xcframework; path = ../../Carthage/Build/ReadiumFuzi.xcframework; sourceTree = ""; }; 28792F801221D49F61B92CF8 /* TDM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDM.swift; sourceTree = ""; }; 294E01A2E6FF25539EBC1082 /* Properties+Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Archive.swift"; sourceTree = ""; }; @@ -612,6 +615,7 @@ 48856E9AB402E2907B5230F3 /* CGRect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGRect.swift; sourceTree = ""; }; 491E1402A31F88054442D58F /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; 4944D2DB99CC59F945FDA2CA /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; + 4AD220F39BD9BBC714F837C4 /* EPUBMediaOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBMediaOverlay.swift; sourceTree = ""; }; 4BB5D42EEF0083D833E2A572 /* Publication+OPDS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publication+OPDS.swift"; sourceTree = ""; }; 4BCDF341872EEFB88B6674DE /* HTTPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPServer.swift; sourceTree = ""; }; 4BF38F71FDEC1920325B62D3 /* PublicationContentIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationContentIterator.swift; sourceTree = ""; }; @@ -838,7 +842,6 @@ E0B58C7A924334D41A7AB1FF /* AccessibilityMetadataDisplayGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityMetadataDisplayGuide.swift; sourceTree = ""; }; E0E6147EF790DE532CE1699D /* CSSProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSProperties.swift; sourceTree = ""; }; E19D31097B3A8050A46CDAA5 /* URLHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHelper.swift; sourceTree = ""; }; - E1DAAE19E8372F6ECF772E0A /* MediaOverlayNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaOverlayNode.swift; sourceTree = ""; }; E1FB533E84CE563807BDB012 /* FormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatSniffer.swift; sourceTree = ""; }; E20ED98539825B35F64D8262 /* InputObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputObservable.swift; sourceTree = ""; }; E233289C75C9F73E6E28DDB4 /* EPUBSpreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBSpreadView.swift; sourceTree = ""; }; @@ -863,7 +866,6 @@ EED4C26FFA10656866E167F4 /* ProgressionStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressionStrategy.swift; sourceTree = ""; }; EF99DAF66659A218CEC25EAE /* EPUBFixedSpreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBFixedSpreadView.swift; sourceTree = ""; }; F07214E263C6589987A561F9 /* SQLite.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SQLite.xcframework; path = ../../Carthage/Build/SQLite.xcframework; sourceTree = ""; }; - F1F5FEE0323287B9CAA09F03 /* MediaOverlays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaOverlays.swift; sourceTree = ""; }; F2E780027410F4B6CC872B3D /* OPDSAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSAvailability.swift; sourceTree = ""; }; F46CAAA92BFBFCCC24AD324A /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings; sourceTree = ""; }; F4FC8F971F00B5876803B62A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -1471,6 +1473,7 @@ 4363E8A92B1EA9AF2561DCE9 /* NCXParser.swift */, 0FC49AFB32B525AAC5BF7612 /* OPFMeta.swift */, 61575203A3BEB8E218CAFE38 /* OPFParser.swift */, + 1E59B7AEE4B4BC56FE6D93D4 /* SMILClockValue.swift */, 364F18D0F750A1D98A381654 /* XMLNamespace.swift */, 00753735EE68A5BCEF35D787 /* Extensions */, 5EC23231F487889071301718 /* Resource Transformers */, @@ -1860,15 +1863,6 @@ path = ../../Sources/Adapters/LCPSQLite; sourceTree = ""; }; - C1002695D860AE505D689C26 /* Media Overlays */ = { - isa = PBXGroup; - children = ( - E1DAAE19E8372F6ECF772E0A /* MediaOverlayNode.swift */, - F1F5FEE0323287B9CAA09F03 /* MediaOverlays.swift */, - ); - path = "Media Overlays"; - sourceTree = ""; - }; C42B511253C3D9C6DA8AA5CC /* Toolkit */ = { isa = PBXGroup; children = ( @@ -2077,7 +2071,6 @@ AD3EFEE43B6E256F6AFB1F53 /* Accessibility */, 055166DFDEE6C6A17D04D42D /* Extensions */, 6558D30FDB081E512115E361 /* GuidedNavigation */, - C1002695D860AE505D689C26 /* Media Overlays */, 75C5238287B0D2F1DF6889DB /* Protection */, 4898F65BFF048F7966C82B74 /* Services */, ); @@ -2158,6 +2151,8 @@ isa = PBXGroup; children = ( 339637CCF01E665F4CB78B01 /* EPUBLayout.swift */, + 4AD220F39BD9BBC714F837C4 /* EPUBMediaOverlay.swift */, + 281F650E9B9CEE601D8125EE /* Metadata+EPUB.swift */, 6BC71BAFF7A20D7903E6EE4D /* Properties+EPUB.swift */, 508E0CD4F9F02CC851E6D1E1 /* Publication+EPUB.swift */, ); @@ -2538,6 +2533,7 @@ 2F8F8B6A05F8E124BA9D6B22 /* PublicationOpener.swift in Sources */, 67F1C7C3D434D2AA542376E3 /* PublicationParser.swift in Sources */, C9FBD23E459FB395377E149E /* ReadiumWebPubParser.swift in Sources */, + F5218746AB7FA152B59368FF /* SMILClockValue.swift in Sources */, 4AE70F783C07D9938B40E792 /* StringExtension.swift in Sources */, DEF5B692764428697150AA8A /* XMLNamespace.swift in Sources */, ); @@ -2694,6 +2690,7 @@ 5912EC9BB073282862F325F2 /* DocumentTypes.swift in Sources */, 8BD3DB373A8785BE8E71845D /* EPUBFormatSniffer.swift in Sources */, 5DE027530786CFB542965AC6 /* EPUBLayout.swift in Sources */, + 3AA66AB994F11FC9470C7EDB /* EPUBMediaOverlay.swift in Sources */, 6263D73CD26D391A6E7D0DCA /* Either.swift in Sources */, 9A1877FBEAA0BFC4C74AD3BB /* Encryption.swift in Sources */, 188D742F80B70DE8A625AD21 /* Facet.swift in Sources */, @@ -2745,9 +2742,8 @@ 50A35FBDFC081B2EFF4C01C6 /* LoggerStub.swift in Sources */, 7E33030C45010C776A131BD5 /* Manifest.swift in Sources */, C73D876AC0852AE89D6AC3A1 /* ManifestTransformer.swift in Sources */, - 76F6EE39F504B6A80837C90D /* MediaOverlayNode.swift in Sources */, - 9E064BC9E99D4F7D8AC3109B /* MediaOverlays.swift in Sources */, A5073271D3DDAE4056629C53 /* MediaType.swift in Sources */, + 8EBE8665FD1D3BDE5FB3B8B9 /* Metadata+EPUB.swift in Sources */, 7E45E10720EA6B4F18196316 /* Metadata+Presentation.swift in Sources */, 78C52EED635B5F8C38A02298 /* Metadata.swift in Sources */, E39B7BCA5ACB6D33C47FCB38 /* MinizipArchiveOpener.swift in Sources */, diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift new file mode 100644 index 0000000000..da76e0856d --- /dev/null +++ b/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift @@ -0,0 +1,90 @@ +// +// 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 +import Testing + +@Suite enum MetadataEPUBTests { + @Suite("EPUBMediaOverlay") enum EPUBMediaOverlayTests { + @Suite("JSON parsing") struct JSONParsing { + @Test("full content") + func fullContent() { + let sut = EPUBMediaOverlay(json: [ + "activeClass": "-epub-media-overlay-active", + "playbackActiveClass": "-epub-media-overlay-playing", + ] as [String: Any]) + + #expect(sut?.activeClass == "-epub-media-overlay-active") + #expect(sut?.playbackActiveClass == "-epub-media-overlay-playing") + } + + @Test("only activeClass returns non-nil") + func onlyActiveClassReturnsNonNil() { + let sut = EPUBMediaOverlay(json: ["activeClass": "-epub-media-overlay-active"] as [String: Any]) + #expect(sut?.activeClass == "-epub-media-overlay-active") + #expect(sut?.playbackActiveClass == nil) + } + + @Test("only playbackActiveClass returns non-nil") + func onlyPlaybackActiveClassReturnsNonNil() { + let sut = EPUBMediaOverlay(json: ["playbackActiveClass": "-epub-media-overlay-playing"] as [String: Any]) + #expect(sut?.playbackActiveClass == "-epub-media-overlay-playing") + #expect(sut?.activeClass == nil) + } + + @Test("empty dictionary returns nil") + func emptyDictionaryReturnsNil() { + #expect(EPUBMediaOverlay(json: [:] as [String: Any]) == nil) + } + + @Test("nil returns nil") + func nilReturnsNil() { + #expect(EPUBMediaOverlay(json: nil) == nil) + } + + @Test("non-dictionary returns nil") + func nonDictionaryReturnsNil() { + #expect(EPUBMediaOverlay(json: "not-a-dict") == nil) + } + } + + @Suite("JSON encoding") struct JSONEncoding { + @Test("round-trip preserves all values") + func roundTrip() { + let original = EPUBMediaOverlay( + activeClass: "-epub-media-overlay-active", + playbackActiveClass: "-epub-media-overlay-playing" + ) + + #expect(EPUBMediaOverlay(json: original.json) == original) + } + + @Test("nil values are omitted from JSON") + func omitsNilValues() { + let sut = EPUBMediaOverlay(activeClass: "-epub-media-overlay-active") + #expect(sut.json["playbackActiveClass"] == nil) + } + } + } + + @Suite("Metadata.mediaOverlay accessor") struct MediaOverlayAccessorTests { + @Test("returns nil when absent") + func returnsNilWhenAbsent() { + let metadata = Metadata(title: "Test") + #expect(metadata.mediaOverlay == nil) + } + + @Test("returns value when present in otherMetadata") + func returnsValueWhenPresent() { + var metadata = Metadata(title: "Test") + metadata.otherMetadata["mediaOverlay"] = [ + "activeClass": "-epub-media-overlay-active", + ] as [String: Any] + + #expect(metadata.mediaOverlay?.activeClass == "-epub-media-overlay-active") + } + } +} diff --git a/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf b/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf new file mode 100644 index 0000000000..4a894e8f41 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf @@ -0,0 +1,19 @@ + + + + Alice's Adventures in Wonderland + 1:32:29 + 0:23:45 + 0:08:44 + -epub-media-overlay-active + -epub-media-overlay-playing + + + + + + + + + + diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift index ded61322bd..fa4c7f9c3c 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift @@ -339,6 +339,35 @@ class EPUBMetadataParserTests: XCTestCase { ) } + // MARK: - Media Overlays + + func testParseMediaOverlaysDuration() throws { + let sut = try parseMetadata("media-overlays") + // 1h 32m 29s = 3600 + 1949 = 5549 + XCTAssertEqual(sut.duration, 5549.0) + } + + func testParseMediaOverlaysActiveClass() throws { + let sut = try parseMetadata("media-overlays") + XCTAssertEqual(sut.mediaOverlay?.activeClass, "-epub-media-overlay-active") + } + + func testParseMediaOverlaysPlaybackActiveClass() throws { + let sut = try parseMetadata("media-overlays") + XCTAssertEqual(sut.mediaOverlay?.playbackActiveClass, "-epub-media-overlay-playing") + } + + func testMediaOverlayNotInRawOtherMetadata() throws { + let sut = try parseMetadata("media-overlays") + // active-class and playback-active-class should be consumed, not in otherMetadata + let mediaVocab = "http://www.idpf.org/epub/vocab/overlays/#" + XCTAssertNil(sut.otherMetadata["\(mediaVocab)active-class"]) + XCTAssertNil(sut.otherMetadata["\(mediaVocab)playback-active-class"]) + XCTAssertNil(sut.otherMetadata["\(mediaVocab)duration"]) + // The synthesized mediaOverlay key must be stored in otherMetadata + XCTAssertNotNil(sut.otherMetadata["mediaOverlay"]) + } + // MARK: - Toolkit func parseMetadata(_ name: String, displayOptions: String? = nil) throws -> Metadata { diff --git a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift index 1ecda1e787..4f72e26767 100644 --- a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift @@ -55,7 +55,7 @@ class OPFParserTests: XCTestCase { link(href: "style.css", mediaType: .css), link(href: "EPUB/chapter02.xhtml", mediaType: .xhtml), link(href: "EPUB/chapter01.smil", mediaType: .smil), - link(href: "EPUB/chapter02.smil", mediaType: .smil), + 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"), @@ -223,6 +223,16 @@ class OPFParserTests: XCTestCase { XCTAssertFalse(sut.metadata.conformsTo.contains(.divina)) } + // MARK: - Media Overlays + + func testParseMediaOverlaysDurationPerSpineItem() throws { + let sut = try parseManifest("media-overlays", at: "EPUB/content.opf").manifest + // chapter01: 0:23:45 = 23*60 + 45 = 1425 + XCTAssertEqual(sut.readingOrder[0].duration, 1425.0) + // chapter02: 0:08:44 = 8*60 + 44 = 524 + XCTAssertEqual(sut.readingOrder[1].duration, 524.0) + } + // MARK: - Helpers func parseManifest(_ name: String, at path: String = "EPUB/content.opf", displayOptions: String? = nil) throws -> (manifest: Manifest, version: String) { diff --git a/Tests/StreamerTests/Parser/EPUB/SMILClockValueTests.swift b/Tests/StreamerTests/Parser/EPUB/SMILClockValueTests.swift new file mode 100644 index 0000000000..50e7ccdbfe --- /dev/null +++ b/Tests/StreamerTests/Parser/EPUB/SMILClockValueTests.swift @@ -0,0 +1,130 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@testable import ReadiumStreamer +import Testing + +/// https://www.w3.org/TR/SMIL/smil-timing.html#Timing-ClockValueSyntax +@Suite enum SMILClockValueTests { + @Suite("full clock: hh:mm:ss[.fraction]") struct FullClock { + @Test func basic() { + #expect(parseSmilClockValue("1:32:29") == 5549.0) + } + + @Test func zero() { + #expect(parseSmilClockValue("0:00:00") == 0.0) + } + + @Test func fractionalSeconds() { + #expect(parseSmilClockValue("0:01:30.5") == 90.5) + } + + @Test func largeHours() { + #expect(parseSmilClockValue("100:00:00") == 360_000.0) + } + } + + @Suite("partial clock: mm:ss[.fraction]") struct PartialClock { + @Test func basic() { + #expect(parseSmilClockValue("23:45") == 1425.0) + } + + @Test func zero() { + #expect(parseSmilClockValue("0:00") == 0.0) + } + + @Test func singleDigitMinutes() { + #expect(parseSmilClockValue("8:44") == 524.0) + } + + @Test func fractionalSeconds() { + #expect(parseSmilClockValue("0:30.5") == 30.5) + } + } + + @Suite("timecount values") struct Timecount { + @Test func hours() { + #expect(parseSmilClockValue("2h") == 7200.0) + } + + @Test func fractionalHours() { + #expect(parseSmilClockValue("1.5h") == 5400.0) + } + + @Test func minutes() { + #expect(parseSmilClockValue("30min") == 1800.0) + } + + @Test func fractionalMinutes() { + #expect(parseSmilClockValue("0.5min") == 30.0) + } + + @Test func seconds() { + #expect(parseSmilClockValue("45s") == 45.0) + } + + @Test func fractionalSeconds() { + #expect(parseSmilClockValue("2.5s") == 2.5) + } + + @Test func milliseconds() { + #expect(parseSmilClockValue("500ms") == 0.5) + } + + @Test("milliseconds suffix takes priority over seconds suffix") + func millisecondsPriority() { + #expect(parseSmilClockValue("1000ms") == 1.0) + } + + @Test func plainNumber() { + #expect(parseSmilClockValue("120") == 120.0) + } + + @Test func plainFractionalNumber() { + #expect(parseSmilClockValue("1.5") == 1.5) + } + } + + @Suite("whitespace handling") struct Whitespace { + @Test func leadingAndTrailingSpaces() { + #expect(parseSmilClockValue(" 30s ") == 30.0) + } + + @Test func leadingAndTrailingSpacesOnClock() { + #expect(parseSmilClockValue(" 1:30 ") == 90.0) + } + } + + @Suite("invalid input returns nil") struct Invalid { + @Test func empty() { + #expect(parseSmilClockValue("") == nil) + } + + @Test func whitespaceOnly() { + #expect(parseSmilClockValue(" ") == nil) + } + + @Test func letters() { + #expect(parseSmilClockValue("abc") == nil) + } + + @Test func unknownSuffix() { + #expect(parseSmilClockValue("1m") == nil) + } + + @Test func nonNumericHours() { + #expect(parseSmilClockValue("x:00:00") == nil) + } + + @Test func nonNumericMinutes() { + #expect(parseSmilClockValue("1:xx:00") == nil) + } + + @Test func nonNumericSeconds() { + #expect(parseSmilClockValue("1:00:xx") == nil) + } + } +} From db110b271f00ad96bd2cc259eaffd71669e24d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 19 Feb 2026 15:09:56 +0100 Subject: [PATCH 42/55] Add SMIL Media Overlays parser (#730) --- .../GuidedNavigationObject.swift | 8 +- .../Parser/EPUB/EPUBMetadataParser.swift | 2 +- Sources/Streamer/Parser/EPUB/OPFParser.swift | 2 +- .../Streamer/Parser/EPUB/SMILClockValue.swift | 46 --- Sources/Streamer/Parser/EPUB/SMILParser.swift | 330 +++++++++++++++ Support/Carthage/.xcodegen | 2 +- .../Readium.xcodeproj/project.pbxproj | 8 +- .../GuidedNavigationObjectTests.swift | 4 +- .../Fixtures/SMIL/audio-clip-times.smil | 37 ++ Tests/StreamerTests/Fixtures/SMIL/basic.smil | 17 + .../Fixtures/SMIL/empty-seq.smil | 12 + Tests/StreamerTests/Fixtures/SMIL/nested.smil | 15 + .../Fixtures/SMIL/par-audio-video.smil | 13 + .../Fixtures/SMIL/par-image.smil | 12 + .../Fixtures/SMIL/par-without-text.smil | 16 + .../Fixtures/SMIL/seq-textref.smil | 13 + .../Fixtures/SMIL/seq-types.smil | 29 ++ .../Fixtures/SMIL/video-clip-times.smil | 27 ++ .../Parser/EPUB/SMILClockValueTests.swift | 130 ------ .../Parser/EPUB/SMILParserTests.swift | 388 ++++++++++++++++++ 20 files changed, 922 insertions(+), 189 deletions(-) delete mode 100644 Sources/Streamer/Parser/EPUB/SMILClockValue.swift create mode 100644 Sources/Streamer/Parser/EPUB/SMILParser.swift create mode 100644 Tests/StreamerTests/Fixtures/SMIL/audio-clip-times.smil create mode 100644 Tests/StreamerTests/Fixtures/SMIL/basic.smil create mode 100644 Tests/StreamerTests/Fixtures/SMIL/empty-seq.smil create mode 100644 Tests/StreamerTests/Fixtures/SMIL/nested.smil create mode 100644 Tests/StreamerTests/Fixtures/SMIL/par-audio-video.smil create mode 100644 Tests/StreamerTests/Fixtures/SMIL/par-image.smil create mode 100644 Tests/StreamerTests/Fixtures/SMIL/par-without-text.smil create mode 100644 Tests/StreamerTests/Fixtures/SMIL/seq-textref.smil create mode 100644 Tests/StreamerTests/Fixtures/SMIL/seq-types.smil create mode 100644 Tests/StreamerTests/Fixtures/SMIL/video-clip-times.smil delete mode 100644 Tests/StreamerTests/Parser/EPUB/SMILClockValueTests.swift create mode 100644 Tests/StreamerTests/Parser/EPUB/SMILParserTests.swift diff --git a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift index 61f9c6c2b9..a4194ab807 100644 --- a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift +++ b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift @@ -27,7 +27,7 @@ public struct GuidedNavigationObject: Hashable, Sendable { public let text: Text? /// Convey the structural semantics of a publication. - public let role: [Role] + public let roles: [Role] /// Text, audio or image description for the current Guided Navigation /// Object. @@ -40,7 +40,7 @@ public struct GuidedNavigationObject: Hashable, Sendable { id: ID? = nil, refs: Refs? = nil, text: Text? = nil, - role: [Role] = [], + roles: [Role] = [], description: Description? = nil, children: [GuidedNavigationObject] = [] ) { @@ -50,7 +50,7 @@ public struct GuidedNavigationObject: Hashable, Sendable { self.id = id self.refs = refs self.text = text - self.role = role + self.roles = roles self.description = description self.children = children } @@ -79,7 +79,7 @@ public struct GuidedNavigationObject: Hashable, Sendable { id: json["id"] as? String, refs: refs, text: text, - role: (json["role"] as? [String])?.map(Role.init) ?? [], + roles: (json["role"] as? [String])?.map(Role.init) ?? [], description: description, children: children ) diff --git a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift index 2c49386793..ef322ed3e5 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift @@ -291,7 +291,7 @@ final class EPUBMetadataParser: Loggable { private lazy var mediaDuration: Double? = metas["duration", in: .media] .first(where: { $0.refines == nil }) - .flatMap { parseSmilClockValue($0.content) } + .flatMap { SMILParser.parseClockValue($0.content) } /// Media overlay CSS class names. private func mediaOverlay() -> EPUBMediaOverlay? { diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 09b5d31d59..117b6b5997 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -201,7 +201,7 @@ final class OPFParser: Loggable { let duration = metas["duration", in: .media, refining: id] .first - .flatMap { parseSmilClockValue($0.content) } + .flatMap { SMILParser.parseClockValue($0.content) } let link = Link( href: href.string, diff --git a/Sources/Streamer/Parser/EPUB/SMILClockValue.swift b/Sources/Streamer/Parser/EPUB/SMILClockValue.swift deleted file mode 100644 index 16234f8a0d..0000000000 --- a/Sources/Streamer/Parser/EPUB/SMILClockValue.swift +++ /dev/null @@ -1,46 +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 Foundation - -/// Parses a SMIL clock value string into seconds. -/// https://www.w3.org/TR/SMIL/smil-timing.html#Timing-ClockValueSyntax -func parseSmilClockValue(_ value: String) -> Double? { - let s = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !s.isEmpty else { return nil } - - // Timecount values: Nh, Nmin, Ns, Nms, N - let timecountPatterns: [(suffix: String, multiplier: Double)] = [ - ("h", 3600), - ("min", 60), - ("ms", 0.001), - ("s", 1), - ] - for (suffix, multiplier) in timecountPatterns { - if s.hasSuffix(suffix) { - let numStr = String(s.dropLast(suffix.count)) - if let n = Double(numStr) { - return n * multiplier - } - } - } - - // Clock values: [[hh:]mm:]ss[.fraction] - let parts = s.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) - switch parts.count { - case 2: - // mm:ss[.fraction] - guard let mm = Double(parts[0]), let ss = Double(parts[1]) else { return nil } - return mm * 60 + ss - case 3: - // hh:mm:ss[.fraction] - guard let hh = Double(parts[0]), let mm = Double(parts[1]), let ss = Double(parts[2]) else { return nil } - return hh * 3600 + mm * 60 + ss - default: - // Plain number (seconds) - return Double(s) - } -} diff --git a/Sources/Streamer/Parser/EPUB/SMILParser.swift b/Sources/Streamer/Parser/EPUB/SMILParser.swift new file mode 100644 index 0000000000..73a9101b97 --- /dev/null +++ b/Sources/Streamer/Parser/EPUB/SMILParser.swift @@ -0,0 +1,330 @@ +// +// 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 ReadiumFuzi +import ReadiumShared + +/// Parses EPUB 3 SMIL Media Overlay documents and values into Readium models +/// (e.g. ``GuidedNavigationDocument``). +/// +/// https://www.w3.org/TR/epub-mediaoverlays-33/ +enum SMILParser { + /// Parses a SMIL Media Overlay document into a ``GuidedNavigationDocument``. + /// + /// - Returns: `nil` if the document is valid but contains no guided + /// content. + static func parseGuidedNavigationDocument( + smilData: Data, + at url: RelativeURL, + warnings: WarningLogger? = nil + ) throws -> GuidedNavigationDocument? { + let document = try ReadiumFuzi.XMLDocument(data: smilData) + document.defineNamespaces(.smil, .epub) + return SMILGuidedNavigationDocumentParsing( + document: document, + url: url, + warnings: warnings + ).parse() + } + + /// Parses a SMIL clock value string (e.g. `"0:01:30.5"`, `"90s"`, `"2h"`) + /// into a duration in seconds. + /// + /// https://www.w3.org/TR/SMIL/smil-timing.html#Timing-ClockValueSyntax + /// + /// - Returns: `nil` for invalid input. + static func parseClockValue(_ value: String) -> TimeInterval? { + let s = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !s.isEmpty else { return nil } + + // Timecount values: Nh, Nmin, Ns, Nms, N + let timecountPatterns: [(suffix: String, multiplier: Double)] = [ + ("h", 3600), + ("min", 60), + ("ms", 0.001), + ("s", 1), + ] + for (suffix, multiplier) in timecountPatterns { + if s.hasSuffix(suffix) { + let numStr = String(s.dropLast(suffix.count)) + if let n = Double(numStr) { + return n * multiplier + } + } + } + + // Clock values: [[hh:]mm:]ss[.fraction] + let parts = s.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + switch parts.count { + case 2: + // mm:ss[.fraction] + guard let mm = Double(parts[0]), let ss = Double(parts[1]) else { return nil } + return mm * 60 + ss + case 3: + // hh:mm:ss[.fraction] + guard let hh = Double(parts[0]), let mm = Double(parts[1]), let ss = Double(parts[2]) else { return nil } + return hh * 3600 + mm * 60 + ss + default: + // Plain number (seconds) + return Double(s) + } + } +} + +/// Parses a SMIL Media Overlay document into a ``GuidedNavigationDocument``. +/// +/// Holds the per-parse state, avoiding parameter threading through helpers. +private struct SMILGuidedNavigationDocumentParsing { + let document: ReadiumFuzi.XMLDocument + let url: RelativeURL + let warnings: WarningLogger? + + func parse() -> GuidedNavigationDocument? { + guard let body = document.firstChild(xpath: "/smil:smil/smil:body") else { + return nil + } + + let objects = parseObjects(in: body) + guard !objects.isEmpty else { + return nil + } + + return GuidedNavigationDocument(guided: objects) + } + + private func parseObjects(in element: ReadiumFuzi.XMLElement) -> [GuidedNavigationObject] { + element.xpath("smil:seq|smil:par") + .compactMap { child -> GuidedNavigationObject? in + switch child.tag?.lowercased() { + case "seq": + return parseSeq(child) + case "par": + return parsePar(child) + default: + return nil + } + } + } + + private func parseSeq(_ element: ReadiumFuzi.XMLElement) -> GuidedNavigationObject? { + let id = element.attr("id") + let epubType = element.attr("type", namespace: .epub) + + let texttrefAttr = element.attr("textref", namespace: .epub) + if texttrefAttr == nil { + warnings?.log(" is missing required epub:textref", model: GuidedNavigationObject.self, source: element, severity: .minor) + } + let textref = texttrefAttr.flatMap { resolveURL($0) } + + let children = parseObjects(in: element) + + return GuidedNavigationObject( + id: id, + refs: textref.flatMap { GuidedNavigationObject.Refs(text: $0) }, + roles: [.sequence] + roles(for: epubType), + children: children + ) + } + + private func parsePar(_ element: ReadiumFuzi.XMLElement) -> GuidedNavigationObject? { + // A par MUST have a child - skip if absent. + guard + let textElement = element.firstChild(xpath: "smil:text"), + let textURL = textElement.attr("src").flatMap({ resolveURL($0) }) + else { + warnings?.log(" has no valid element", model: GuidedNavigationObject.self, source: element, severity: .minor) + return nil + } + + let id = element.attr("id") + let epubType = element.attr("type", namespace: .epub) + + let audioRef: AnyURL? = element.firstChild(xpath: "smil:audio").flatMap(clipURL(from:)) + let videoRef: AnyURL? = element.firstChild(xpath: "smil:video").flatMap(clipURL(from:)) + + let imgRef: AnyURL? = element.firstChild(xpath: "smil:img") + .flatMap { $0.attr("src").flatMap { resolveURL($0) } } + + let refs = GuidedNavigationObject.Refs( + text: textURL, + img: imgRef, + audio: audioRef, + video: videoRef + ) + + return GuidedNavigationObject( + id: id, + refs: refs, + roles: roles(for: epubType) + ) + } + + /// Resolves a `src` attribute value relative to the SMIL document URL. + private func resolveURL(_ src: String) -> AnyURL? { + RelativeURL(epubHREF: src).flatMap { url.resolve($0) } + } + + /// Extracts the clip URL from a `` or `` element. + private func clipURL(from element: ReadiumFuzi.XMLElement) -> AnyURL? { + guard let src = element.attr("src") else { + return nil + } + return clipURL( + src: src, + clipBegin: element.attr("clipBegin"), + clipEnd: element.attr("clipEnd") + ) + } + + /// Builds a media URL with optional W3C Media Fragment times. + /// + /// Format: `media.mp4#t=begin,end` + private func clipURL(src: String, clipBegin: String?, clipEnd: String?) -> AnyURL? { + guard let base = resolveURL(src) else { + return nil + } + + let begin = clipBegin.flatMap { SMILParser.parseClockValue($0) } + let end = clipEnd.flatMap { SMILParser.parseClockValue($0) } + + guard begin != nil || end != nil else { + return base + } + + let beginStr = begin.map { formatSeconds($0) } ?? "" + let endStr = end.map { formatSeconds($0) } ?? "" + + // Append a media fragment to the URL. + guard var components = URLComponents(url: base.url, resolvingAgainstBaseURL: false) else { + return base + } + components.fragment = "t=\(beginStr),\(endStr)" + return components.url.flatMap { AnyURL(url: $0) } + } + + /// Formats a seconds value, stripping the `.0` suffix for integers. + private func formatSeconds(_ seconds: TimeInterval) -> String { + if seconds == floor(seconds) { + return String(Int(seconds)) + } + var result = String(format: "%.3f", seconds) + while result.last == "0" { + result.removeLast() + } + if result.last == "." { result.removeLast() } + return result + } + + /// Maps an `epub:type` attribute (space-separated tokens) to roles. + private func roles(for epubType: String?) -> [GuidedNavigationObject.Role] { + guard + let epubType, + !epubType.trimmingCharacters(in: .whitespaces).isEmpty + else { + return [] + } + + return epubType + .split(separator: " ") + .map { role(for: String($0)) } + } + + private func role(for token: String) -> GuidedNavigationObject.Role { + Self.epubTypeToRole[token] + // Fall back to a full EPUB type URI role. + ?? GuidedNavigationObject.Role("http://www.idpf.org/2007/ops/type#\(token)") + } + + /// Mapping from EPUB type equivalent to Guided Navigation Roles. + /// + /// See https://readium.org/guided-navigation/roles + private static let epubTypeToRole: [String: GuidedNavigationObject.Role] = [ + // HTML and/or ARIA + + "aside": .aside, + "table-cell": .cell, + "glossdef": .definition, + "figure": .figure, + "list": .list, + "list-item": .listItem, + "table-row": .row, + "table": .table, + "glossterm": .term, + + // DPUB ARIA 1.0 + + "abstract": .abstract, + "acknowledgments": .acknowledgments, + "afterword": .afterword, + "appendix": .appendix, + "backlink": .backlink, + "bibliography": .bibliography, + "biblioref": .biblioref, + "chapter": .chapter, + "colophon": .colophon, + "conclusion": .conclusion, + "cover": .cover, + "credit": .credit, + "credits": .credits, + "dedication": .dedication, + "endnotes": .endnotes, + "epigraph": .epigraph, + "epilogue": .epilogue, + "errata": .errata, + "example": .example, + "footnote": .footnote, + "glossary": .glossary, + "glossref": .glossref, + "index": .index, + "introduction": .introduction, + "noteref": .noteref, + "notice": .notice, + "pagebreak": .pagebreak, + "page-list": .pagelist, + "part": .part, + "preface": .preface, + "prologue": .prologue, + "pullquote": .pullquote, + "qna": .qna, + "subtitle": .subtitle, + "tip": .tip, + "toc": .toc, + + // EPUB 3 Structural Semantics Vocabulary 1.1 + + "landmarks": .landmarks, + "loa": .loa, + "loi": .loi, + "lot": .lot, + "lov": .lov, + ] +} + +/// Warning raised when parsing a model object from its SMIL representation +/// fails. +public struct SMILWarning: Warning { + /// Type of the model object to be parsed. + public let modelType: Any.Type + /// Details about the failure. + public let reason: String + /// String representation of the source XML element. + public let source: String? + public let severity: WarningSeverityLevel + public var tag: String { + "smil" + } + + public var message: String { + "SMIL \(modelType): \(reason)" + } +} + +private extension WarningLogger { + func log(_ reason: String, model: Any.Type, source: ReadiumFuzi.XMLElement, severity: WarningSeverityLevel = .major) { + log(SMILWarning(modelType: model, reason: reason, source: source.rawXML, severity: severity)) + } +} diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index a7544c5954..9ab4c7b815 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -851,7 +851,7 @@ ../../Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift ../../Sources/Streamer/Parser/EPUB/Services ../../Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift -../../Sources/Streamer/Parser/EPUB/SMILClockValue.swift +../../Sources/Streamer/Parser/EPUB/SMILParser.swift ../../Sources/Streamer/Parser/EPUB/XMLNamespace.swift ../../Sources/Streamer/Parser/Image ../../Sources/Streamer/Parser/Image/ComicInfoParser.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 360ce69229..f9c0bb1b14 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 18217BC157557A5DDA4BA119 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADDB8B9906FC78C038203BDD /* User.swift */; }; 1852D8C28060050B53CFABED /* XMLNamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E494545D2CCE72A3FED240 /* XMLNamespace.swift */; }; 188D742F80B70DE8A625AD21 /* Facet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387B19B66C4D91A295B5EFA6 /* Facet.swift */; }; + 19F22A0A2E7339052A888CCE /* SMILParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38A7D45005927987BFEA228 /* SMILParser.swift */; }; 1A4F41D5A7E48472DB9A181E /* ZIPFoundationArchiveOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50064FE9BBCEA4C00BA6BBEF /* ZIPFoundationArchiveOpener.swift */; }; 1AEF63A8471C7676092842D2 /* FileExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D555435E2BADB2B877FD50C7 /* FileExtension.swift */; }; 1B15166C79C7C05CE491AD2C /* CSSProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E6147EF790DE532CE1699D /* CSSProperties.swift */; }; @@ -389,7 +390,6 @@ EFC69F5294C5B698598D0322 /* DefaultArchiveOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC150FB45A4AB33AF516AE09 /* DefaultArchiveOpener.swift */; }; F206058A5073F54E4090ECE8 /* SQLiteLCPPassphraseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22A0E76866F626D79F0A64C /* SQLiteLCPPassphraseRepository.swift */; }; F2BDD94FFFBD08526B56E337 /* URLHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D31097B3A8050A46CDAA5 /* URLHelper.swift */; }; - F5218746AB7FA152B59368FF /* SMILClockValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E59B7AEE4B4BC56FE6D93D4 /* SMILClockValue.swift */; }; F5CA172EAC7E0AFA9588C262 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93A2004A73E505BFEA8B56A /* Data.swift */; }; F5D24D8233B79EB2C55DFE80 /* EPUBReflowableSpreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C45688D0A9C1F81F463FF92 /* EPUBReflowableSpreadView.swift */; }; F5D898348F4CDC13EB904050 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F56CFDC7E9254E99561152 /* Task.swift */; }; @@ -542,7 +542,6 @@ 1C22408FE1FA81400DE8D5F7 /* OPDSPrice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSPrice.swift; sourceTree = ""; }; 1D5053C2151DDDE4E8F06513 /* LCPPassphraseAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPPassphraseAuthentication.swift; sourceTree = ""; }; 1E175BF1A1F97687B4119BB1 /* Properties+Encryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Encryption.swift"; sourceTree = ""; }; - 1E59B7AEE4B4BC56FE6D93D4 /* SMILClockValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMILClockValue.swift; sourceTree = ""; }; 1EBC685D4A0E07997088DD2D /* DataCompression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCompression.swift; sourceTree = ""; }; 1F89BC365BDD19BE84F4D3B5 /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; 1FBFAC2D57DE7EBB4E2F31BE /* ReadiumNavigatorLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumNavigatorLocalizedString.swift; sourceTree = ""; }; @@ -791,6 +790,7 @@ C2085E9C042F54271D5B9555 /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; C2C93C33347DC0A41FE15AC6 /* License.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = License.swift; sourceTree = ""; }; C361F965E7A7962CA3E4C0BA /* CursorList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorList.swift; sourceTree = ""; }; + C38A7D45005927987BFEA228 /* SMILParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMILParser.swift; sourceTree = ""; }; C4BFD453E8BF6FA24F340EE0 /* HTTPContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPContainer.swift; sourceTree = ""; }; C4C94659A8749299DBE3628D /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; C51A36BFDC79EB5377D69582 /* CSSLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSLayout.swift; sourceTree = ""; }; @@ -1473,7 +1473,7 @@ 4363E8A92B1EA9AF2561DCE9 /* NCXParser.swift */, 0FC49AFB32B525AAC5BF7612 /* OPFMeta.swift */, 61575203A3BEB8E218CAFE38 /* OPFParser.swift */, - 1E59B7AEE4B4BC56FE6D93D4 /* SMILClockValue.swift */, + C38A7D45005927987BFEA228 /* SMILParser.swift */, 364F18D0F750A1D98A381654 /* XMLNamespace.swift */, 00753735EE68A5BCEF35D787 /* Extensions */, 5EC23231F487889071301718 /* Resource Transformers */, @@ -2533,7 +2533,7 @@ 2F8F8B6A05F8E124BA9D6B22 /* PublicationOpener.swift in Sources */, 67F1C7C3D434D2AA542376E3 /* PublicationParser.swift in Sources */, C9FBD23E459FB395377E149E /* ReadiumWebPubParser.swift in Sources */, - F5218746AB7FA152B59368FF /* SMILClockValue.swift in Sources */, + 19F22A0A2E7339052A888CCE /* SMILParser.swift in Sources */, 4AE70F783C07D9938B40E792 /* StringExtension.swift in Sources */, DEF5B692764428697150AA8A /* XMLNamespace.swift in Sources */, ); diff --git a/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift index 14eee7ece1..9ad3a1cfa1 100644 --- a/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift +++ b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift @@ -44,7 +44,7 @@ import Testing video: AnyURL(string: "video.mp4#t=10,30") ), text: .init(plain: "Hello", ssml: "Hello", language: Language(code: .bcp47("en"))), - role: [.chapter, .heading2], + roles: [.chapter, .heading2], description: .init(refs: .init(text: AnyURL(string: "desc.html"))), children: [ #require(GuidedNavigationObject(refs: .init(text: AnyURL(string: "child.html")))), @@ -120,7 +120,7 @@ import Testing "textref": "c.html", "role": ["chapter", "custom-role"], ]) - #expect(sut?.role == [.chapter, GuidedNavigationObject.Role("custom-role")]) + #expect(sut?.roles == [.chapter, GuidedNavigationObject.Role("custom-role")]) } @Test("nil JSON returns nil") diff --git a/Tests/StreamerTests/Fixtures/SMIL/audio-clip-times.smil b/Tests/StreamerTests/Fixtures/SMIL/audio-clip-times.smil new file mode 100644 index 0000000000..adc843a6f6 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/audio-clip-times.smil @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/basic.smil b/Tests/StreamerTests/Fixtures/SMIL/basic.smil new file mode 100644 index 0000000000..6d7132fa10 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/basic.smil @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/empty-seq.smil b/Tests/StreamerTests/Fixtures/SMIL/empty-seq.smil new file mode 100644 index 0000000000..254d4eca00 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/empty-seq.smil @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/nested.smil b/Tests/StreamerTests/Fixtures/SMIL/nested.smil new file mode 100644 index 0000000000..798a1a8e69 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/nested.smil @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/par-audio-video.smil b/Tests/StreamerTests/Fixtures/SMIL/par-audio-video.smil new file mode 100644 index 0000000000..dd92e80e00 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/par-audio-video.smil @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/par-image.smil b/Tests/StreamerTests/Fixtures/SMIL/par-image.smil new file mode 100644 index 0000000000..64fcd99cd1 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/par-image.smil @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/par-without-text.smil b/Tests/StreamerTests/Fixtures/SMIL/par-without-text.smil new file mode 100644 index 0000000000..264e845d4f --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/par-without-text.smil @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/seq-textref.smil b/Tests/StreamerTests/Fixtures/SMIL/seq-textref.smil new file mode 100644 index 0000000000..11f70ac929 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/seq-textref.smil @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/seq-types.smil b/Tests/StreamerTests/Fixtures/SMIL/seq-types.smil new file mode 100644 index 0000000000..94678cd68e --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/seq-types.smil @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/video-clip-times.smil b/Tests/StreamerTests/Fixtures/SMIL/video-clip-times.smil new file mode 100644 index 0000000000..5783c69eb7 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/video-clip-times.smil @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Parser/EPUB/SMILClockValueTests.swift b/Tests/StreamerTests/Parser/EPUB/SMILClockValueTests.swift deleted file mode 100644 index 50e7ccdbfe..0000000000 --- a/Tests/StreamerTests/Parser/EPUB/SMILClockValueTests.swift +++ /dev/null @@ -1,130 +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. -// - -@testable import ReadiumStreamer -import Testing - -/// https://www.w3.org/TR/SMIL/smil-timing.html#Timing-ClockValueSyntax -@Suite enum SMILClockValueTests { - @Suite("full clock: hh:mm:ss[.fraction]") struct FullClock { - @Test func basic() { - #expect(parseSmilClockValue("1:32:29") == 5549.0) - } - - @Test func zero() { - #expect(parseSmilClockValue("0:00:00") == 0.0) - } - - @Test func fractionalSeconds() { - #expect(parseSmilClockValue("0:01:30.5") == 90.5) - } - - @Test func largeHours() { - #expect(parseSmilClockValue("100:00:00") == 360_000.0) - } - } - - @Suite("partial clock: mm:ss[.fraction]") struct PartialClock { - @Test func basic() { - #expect(parseSmilClockValue("23:45") == 1425.0) - } - - @Test func zero() { - #expect(parseSmilClockValue("0:00") == 0.0) - } - - @Test func singleDigitMinutes() { - #expect(parseSmilClockValue("8:44") == 524.0) - } - - @Test func fractionalSeconds() { - #expect(parseSmilClockValue("0:30.5") == 30.5) - } - } - - @Suite("timecount values") struct Timecount { - @Test func hours() { - #expect(parseSmilClockValue("2h") == 7200.0) - } - - @Test func fractionalHours() { - #expect(parseSmilClockValue("1.5h") == 5400.0) - } - - @Test func minutes() { - #expect(parseSmilClockValue("30min") == 1800.0) - } - - @Test func fractionalMinutes() { - #expect(parseSmilClockValue("0.5min") == 30.0) - } - - @Test func seconds() { - #expect(parseSmilClockValue("45s") == 45.0) - } - - @Test func fractionalSeconds() { - #expect(parseSmilClockValue("2.5s") == 2.5) - } - - @Test func milliseconds() { - #expect(parseSmilClockValue("500ms") == 0.5) - } - - @Test("milliseconds suffix takes priority over seconds suffix") - func millisecondsPriority() { - #expect(parseSmilClockValue("1000ms") == 1.0) - } - - @Test func plainNumber() { - #expect(parseSmilClockValue("120") == 120.0) - } - - @Test func plainFractionalNumber() { - #expect(parseSmilClockValue("1.5") == 1.5) - } - } - - @Suite("whitespace handling") struct Whitespace { - @Test func leadingAndTrailingSpaces() { - #expect(parseSmilClockValue(" 30s ") == 30.0) - } - - @Test func leadingAndTrailingSpacesOnClock() { - #expect(parseSmilClockValue(" 1:30 ") == 90.0) - } - } - - @Suite("invalid input returns nil") struct Invalid { - @Test func empty() { - #expect(parseSmilClockValue("") == nil) - } - - @Test func whitespaceOnly() { - #expect(parseSmilClockValue(" ") == nil) - } - - @Test func letters() { - #expect(parseSmilClockValue("abc") == nil) - } - - @Test func unknownSuffix() { - #expect(parseSmilClockValue("1m") == nil) - } - - @Test func nonNumericHours() { - #expect(parseSmilClockValue("x:00:00") == nil) - } - - @Test func nonNumericMinutes() { - #expect(parseSmilClockValue("1:xx:00") == nil) - } - - @Test func nonNumericSeconds() { - #expect(parseSmilClockValue("1:00:xx") == nil) - } - } -} diff --git a/Tests/StreamerTests/Parser/EPUB/SMILParserTests.swift b/Tests/StreamerTests/Parser/EPUB/SMILParserTests.swift new file mode 100644 index 0000000000..9dfb4fcb0e --- /dev/null +++ b/Tests/StreamerTests/Parser/EPUB/SMILParserTests.swift @@ -0,0 +1,388 @@ +// +// 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 +@testable import ReadiumStreamer +import Testing + +@Suite enum SMILParserTests { + static let fixtures = Fixtures(path: "SMIL") + + /// Returns the parsed document for the given SMIL fixture filename. + /// + /// The SMIL file is assumed to be at `OEBPS/chapter01.smil` so that + /// relative HREFs like `chapter01.xhtml` resolve against `OEBPS/`. + static func parse(_ name: String) throws -> GuidedNavigationDocument? { + let data = fixtures.data(at: name) + let url = RelativeURL(string: "OEBPS/chapter01.smil")! + return try SMILParser.parseGuidedNavigationDocument(smilData: data, at: url) + } + + // MARK: - par parsing + + @Suite("par parsing") struct ParParsing { + @Test func basicParWithTextAndAudio() throws { + let doc = try SMILParserTests.parse("basic.smil") + #expect(doc != nil) + + // First top-level object is the chapter seq + let chapter = doc?.guided.first + // First child of the chapter seq is p1 + let p1 = chapter?.children.first + #expect(p1?.id == "p1") + #expect(p1?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml#id_p1")) + #expect(p1?.refs?.audio == AnyURL(string: "OEBPS/chapter01.mp3#t=0,5.123")) + #expect(p1?.roles == [.term]) + } + + @Test func parWithImage() throws { + let doc = try SMILParserTests.parse("par-image.smil") + let p1 = doc?.guided.first?.children.first + #expect(p1?.id == "p1") + #expect(p1?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml#id1")) + #expect(p1?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=0,5")) + #expect(p1?.refs?.img == AnyURL(string: "OEBPS/figure1.jpg")) + } + + @Test func audioWithBothClipTimes() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p1: clipBegin=0:00:00.000, clipEnd=0:00:05.123 + let p1 = doc?.guided.first?.children[0] + #expect(p1?.id == "p1") + #expect(p1?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=0,5.123")) + } + + @Test func audioWithOnlyClipBegin() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p2: clipBegin=0:01:30.000 + let p2 = doc?.guided.first?.children[1] + #expect(p2?.id == "p2") + #expect(p2?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=90,")) + } + + @Test func audioWithOnlyClipEnd() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p3: clipEnd=0:00:11.000 + let p3 = doc?.guided.first?.children[2] + #expect(p3?.id == "p3") + #expect(p3?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=,11")) + } + + @Test func audioWithoutClipTimes() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p4: no clip attributes → plain URL + let p4 = doc?.guided.first?.children[3] + #expect(p4?.id == "p4") + #expect(p4?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3")) + } + + @Test func audioClipEndTrailingZerosStripped() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p5: clipEnd=0:00:05.100 → "5.100" formatted, trailing zeros stripped → "5.1" + let p5 = doc?.guided.first?.children[4] + #expect(p5?.id == "p5") + #expect(p5?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=,5.1")) + } + + @Test func audioClipEndPartialTrailingZeroStripped() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p6: clipEnd=0:00:05.120 → "5.120" formatted, one trailing zero stripped → "5.12" + let p6 = doc?.guided.first?.children[5] + #expect(p6?.id == "p6") + #expect(p6?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=,5.12")) + } + + @Test func videoWithBothClipTimes() throws { + let doc = try SMILParserTests.parse("video-clip-times.smil") + // s1/p1: clipBegin=0:00:00.000, clipEnd=0:00:05.123 + let p1 = doc?.guided.first?.children[0] + #expect(p1?.id == "p1") + #expect(p1?.refs?.video == AnyURL(string: "OEBPS/video.mp4#t=0,5.123")) + } + + @Test func videoWithOnlyClipBegin() throws { + let doc = try SMILParserTests.parse("video-clip-times.smil") + // s1/p2: clipBegin=0:01:30.000 + let p2 = doc?.guided.first?.children[1] + #expect(p2?.id == "p2") + #expect(p2?.refs?.video == AnyURL(string: "OEBPS/video.mp4#t=90,")) + } + + @Test func videoWithOnlyClipEnd() throws { + let doc = try SMILParserTests.parse("video-clip-times.smil") + // s1/p3: clipEnd=0:00:11.000 + let p3 = doc?.guided.first?.children[2] + #expect(p3?.id == "p3") + #expect(p3?.refs?.video == AnyURL(string: "OEBPS/video.mp4#t=,11")) + } + + @Test func videoWithoutClipTimes() throws { + let doc = try SMILParserTests.parse("video-clip-times.smil") + // s1/p4: no clip attributes → plain URL + let p4 = doc?.guided.first?.children[3] + #expect(p4?.id == "p4") + #expect(p4?.refs?.video == AnyURL(string: "OEBPS/video.mp4")) + } + + @Test func parWithBothAudioAndVideo() throws { + let doc = try SMILParserTests.parse("par-audio-video.smil") + let p1 = doc?.guided.first?.children.first + #expect(p1?.id == "p1") + #expect(p1?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=0,5")) + #expect(p1?.refs?.video == AnyURL(string: "OEBPS/video.mp4#t=0,5")) + } + + @Test func parWithoutTextIsSkipped() throws { + let doc = try SMILParserTests.parse("par-without-text.smil") + let children = doc?.guided.first?.children + // Only the valid par survives — the no-text par is dropped. + #expect(children?.count == 1) + #expect(children?.first?.id == "p-valid") + } + } + + // MARK: - seq parsing + + @Suite("seq parsing") struct SeqParsing { + @Test func seqWithNoTypeGetsSequenceRole() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let noType = doc?.guided.first { $0.id == "s-no-type" } + #expect(noType?.roles == [.sequence]) + } + + @Test func seqWithKnownTypeGetsCorrectRole() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let chapter = doc?.guided.first { $0.id == "s-chapter" } + #expect(chapter?.roles == [.sequence, .chapter]) + } + + @Test func seqWithMultipleTypesGetsMultipleRoles() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let multi = doc?.guided.first { $0.id == "s-multi" } + #expect(multi?.roles == [.sequence, .chapter, .part]) + } + + @Test func seqWithTextref() throws { + let doc = try SMILParserTests.parse("seq-textref.smil") + // s1: textref without fragment + let s1 = doc?.guided.first { $0.id == "s1" } + #expect(s1?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml")) + // s2: textref with fragment + let s2 = doc?.guided.first { $0.id == "s2" } + #expect(s2?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml#sec1")) + } + + @Test func emptySeqIsSkipped() throws { + let doc = try SMILParserTests.parse("empty-seq.smil") + // The empty seq must be dropped; only s-valid survives. + #expect(doc?.guided.count == 1) + #expect(doc?.guided.first?.id == "s-valid") + } + + @Test func nestedSeq() throws { + let doc = try SMILParserTests.parse("nested.smil") + let s1 = doc?.guided.first + #expect(s1?.roles == [.sequence, .part]) + let s2 = s1?.children.first + #expect(s2?.roles == [.sequence, .chapter]) + let s3 = s2?.children.first + #expect(s3?.roles == [.sequence, .table]) + let p1 = s3?.children.first + #expect(p1?.refs?.text != nil) + } + + @Test func basicSeqChildrenFromBasicFixture() throws { + let doc = try SMILParserTests.parse("basic.smil") + let chapter = doc?.guided.first + #expect(chapter?.id == "s1") + #expect(chapter?.roles == [.sequence, .chapter]) + #expect(chapter?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml")) + // Two children: p1 and s2 + #expect(chapter?.children.count == 2) + let s2 = chapter?.children[1] + #expect(s2?.id == "s2") + #expect(s2?.roles == [.sequence, .table]) + #expect(s2?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml#sec1")) + } + } + + // MARK: - epub:type mapping + + @Suite("epub:type mapping") struct EpubTypeMapping { + @Test func pageListSpecialCase() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let pagelist = doc?.guided.first { $0.id == "s-pagelist" } + #expect(pagelist?.roles == [.sequence, .pagelist]) + } + + @Test func listItemSpecialCase() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let listitem = doc?.guided.first { $0.id == "s-listitem" } + #expect(listitem?.roles == [.sequence, .listItem]) + } + + @Test func unknownTypeGetsURIRole() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let unknown = doc?.guided.first { $0.id == "s-unknown" } + #expect(unknown?.roles == [.sequence, GuidedNavigationObject.Role("http://www.idpf.org/2007/ops/type#preamble")]) + } + } + + // MARK: - document-level + + @Suite("document") struct DocumentParsing { + @Test func bodyChildrenBecomeTopLevelGuided() throws { + let doc = try SMILParserTests.parse("basic.smil") + // basic.smil has one top-level seq in the body + #expect(doc?.links == []) + #expect(doc?.guided.count == 1) + } + + @Test func returnsNilForNonSMILXML() throws { + // Fuzi parses leniently, so malformed/non-SMIL content produces no + // and the parser returns nil rather than throwing. + let badData = Data("not xml at all".utf8) + let url = try #require(RelativeURL(string: "OEBPS/chapter01.smil")) + let doc = try SMILParser.parseGuidedNavigationDocument(smilData: badData, at: url) + #expect(doc == nil) + } + + @Test func returnsNilForEmptyBody() throws { + let xml = """ + + + + + """.data(using: .utf8)! + let url = try #require(RelativeURL(string: "OEBPS/chapter01.smil")) + let doc = try SMILParser.parseGuidedNavigationDocument(smilData: xml, at: url) + #expect(doc == nil) + } + } + + /// https://www.w3.org/TR/SMIL/smil-timing.html#Timing-ClockValueSyntax + @Suite("parseClockValue") struct ParseClockValue { + @Suite("full clock: hh:mm:ss[.fraction]") struct FullClock { + @Test func basic() { + #expect(SMILParser.parseClockValue("1:32:29") == 5549.0) + } + + @Test func zero() { + #expect(SMILParser.parseClockValue("0:00:00") == 0.0) + } + + @Test func fractionalSeconds() { + #expect(SMILParser.parseClockValue("0:01:30.5") == 90.5) + } + + @Test func largeHours() { + #expect(SMILParser.parseClockValue("100:00:00") == 360_000.0) + } + } + + @Suite("partial clock: mm:ss[.fraction]") struct PartialClock { + @Test func basic() { + #expect(SMILParser.parseClockValue("23:45") == 1425.0) + } + + @Test func zero() { + #expect(SMILParser.parseClockValue("0:00") == 0.0) + } + + @Test func singleDigitMinutes() { + #expect(SMILParser.parseClockValue("8:44") == 524.0) + } + + @Test func fractionalSeconds() { + #expect(SMILParser.parseClockValue("0:30.5") == 30.5) + } + } + + @Suite("timecount values") struct Timecount { + @Test func hours() { + #expect(SMILParser.parseClockValue("2h") == 7200.0) + } + + @Test func fractionalHours() { + #expect(SMILParser.parseClockValue("1.5h") == 5400.0) + } + + @Test func minutes() { + #expect(SMILParser.parseClockValue("30min") == 1800.0) + } + + @Test func fractionalMinutes() { + #expect(SMILParser.parseClockValue("0.5min") == 30.0) + } + + @Test func seconds() { + #expect(SMILParser.parseClockValue("45s") == 45.0) + } + + @Test func fractionalSeconds() { + #expect(SMILParser.parseClockValue("2.5s") == 2.5) + } + + @Test func milliseconds() { + #expect(SMILParser.parseClockValue("500ms") == 0.5) + } + + @Test("milliseconds suffix takes priority over seconds suffix") + func millisecondsPriority() { + #expect(SMILParser.parseClockValue("1000ms") == 1.0) + } + + @Test func plainNumber() { + #expect(SMILParser.parseClockValue("120") == 120.0) + } + + @Test func plainFractionalNumber() { + #expect(SMILParser.parseClockValue("1.5") == 1.5) + } + } + + @Suite("whitespace handling") struct Whitespace { + @Test func leadingAndTrailingSpaces() { + #expect(SMILParser.parseClockValue(" 30s ") == 30.0) + } + + @Test func leadingAndTrailingSpacesOnClock() { + #expect(SMILParser.parseClockValue(" 1:30 ") == 90.0) + } + } + + @Suite("invalid input returns nil") struct Invalid { + @Test func empty() { + #expect(SMILParser.parseClockValue("") == nil) + } + + @Test func whitespaceOnly() { + #expect(SMILParser.parseClockValue(" ") == nil) + } + + @Test func letters() { + #expect(SMILParser.parseClockValue("abc") == nil) + } + + @Test func unknownSuffix() { + #expect(SMILParser.parseClockValue("1m") == nil) + } + + @Test func nonNumericHours() { + #expect(SMILParser.parseClockValue("x:00:00") == nil) + } + + @Test func nonNumericMinutes() { + #expect(SMILParser.parseClockValue("1:xx:00") == nil) + } + + @Test func nonNumericSeconds() { + #expect(SMILParser.parseClockValue("1:00:xx") == nil) + } + } + } +} From 17ddf6ea87b0f73a47eb089bcde8e633de6bebc7 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Thu, 19 Feb 2026 16:46:36 +0100 Subject: [PATCH 43/55] Fix injecting Readium CSS even if publication has no `layout` property (#729) --- Sources/Navigator/EPUB/EPUBExtensions.swift | 13 +++++++++++++ .../EPUB/EPUBNavigatorViewController.swift | 6 +----- Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift | 2 +- .../EPUB/Preferences/EPUBPreferencesEditor.swift | 7 +------ Support/Carthage/.xcodegen | 1 + Support/Carthage/Readium.xcodeproj/project.pbxproj | 4 ++++ 6 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 Sources/Navigator/EPUB/EPUBExtensions.swift diff --git a/Sources/Navigator/EPUB/EPUBExtensions.swift b/Sources/Navigator/EPUB/EPUBExtensions.swift new file mode 100644 index 0000000000..f8d43769ad --- /dev/null +++ b/Sources/Navigator/EPUB/EPUBExtensions.swift @@ -0,0 +1,13 @@ +// +// 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/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 77ccf8cd4d..a130a7dfc2 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -1067,7 +1067,7 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { // the application's bars. var insets = view.window?.safeAreaInsets ?? .zero - switch publication.metadata.layout ?? .reflowable { + switch publication.metadata.epubLayout { 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 @@ -1080,10 +1080,6 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { let configInset = config.contentInset(for: view.traitCollection.verticalSizeClass) insets.top = max(insets.top, configInset.top) insets.bottom = max(insets.bottom, configInset.bottom) - - case .scrolled: - // Not supported with EPUB. - break } return insets diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 9cd01ba8b9..74b1d65049 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -311,7 +311,7 @@ enum EPUBScriptScope { guard let link = publication.linkWithHREF(href), link.mediaType?.isHTML == true, - publication.metadata.layout == .reflowable + publication.metadata.epubLayout == .reflowable else { return resource } diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift index f06856714a..986a05af65 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift @@ -21,12 +21,7 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor Date: Thu, 19 Feb 2026 17:36:23 +0100 Subject: [PATCH 44/55] Parse SMIL as alternate of reading order items (#732) --- Sources/Streamer/Parser/EPUB/OPFParser.swift | 13 +++++++++- Sources/Streamer/Parser/EPUB/SMILParser.swift | 6 ++--- Tests/StreamerTests/Fixtures/OPF/links.opf | 2 +- .../Fixtures/OPF/media-overlays.opf | 10 ++++--- .../Parser/EPUB/OPFParserTests.swift | 26 ++++++++++++++----- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 117b6b5997..0da98a2b6f 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -27,6 +27,7 @@ final class OPFParser: Loggable { let id: String let link: Link let fallbackId: String? + let mediaOverlayId: String? } /// Relative path to the OPF in the EPUB container @@ -214,7 +215,8 @@ final class OPFParser: Loggable { return ManifestItem( id: id, link: link, - fallbackId: manifestItem.attr("fallback") + fallbackId: manifestItem.attr("fallback"), + mediaOverlayId: manifestItem.attr("media-overlay") ) } @@ -261,6 +263,15 @@ final class OPFParser: Loggable { ) } + // Attach the SMIL media overlay as an alternate. + if + let mediaOverlayId = item.mediaOverlayId, + let smilIndex = items.firstIndex(where: { $0.id == mediaOverlayId && $0.link.mediaType?.matches(.smil) == true }) + { + let smilItem = items.remove(at: smilIndex) + spineLink.alternates.append(smilItem.link) + } + readingOrder.append(spineLink) } diff --git a/Sources/Streamer/Parser/EPUB/SMILParser.swift b/Sources/Streamer/Parser/EPUB/SMILParser.swift index 73a9101b97..58899bb06b 100644 --- a/Sources/Streamer/Parser/EPUB/SMILParser.swift +++ b/Sources/Streamer/Parser/EPUB/SMILParser.swift @@ -114,11 +114,11 @@ private struct SMILGuidedNavigationDocumentParsing { let id = element.attr("id") let epubType = element.attr("type", namespace: .epub) - let texttrefAttr = element.attr("textref", namespace: .epub) - if texttrefAttr == nil { + let textrefAttr = element.attr("textref", namespace: .epub) + if textrefAttr == nil { warnings?.log(" is missing required epub:textref", model: GuidedNavigationObject.self, source: element, severity: .minor) } - let textref = texttrefAttr.flatMap { resolveURL($0) } + let textref = textrefAttr.flatMap { resolveURL($0) } let children = parseObjects(in: element) diff --git a/Tests/StreamerTests/Fixtures/OPF/links.opf b/Tests/StreamerTests/Fixtures/OPF/links.opf index f8e6fd96a8..eb71e91460 100644 --- a/Tests/StreamerTests/Fixtures/OPF/links.opf +++ b/Tests/StreamerTests/Fixtures/OPF/links.opf @@ -21,7 +21,7 @@ - + diff --git a/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf b/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf index 4a894e8f41..7ccc16b506 100644 --- a/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf +++ b/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf @@ -3,14 +3,16 @@ Alice's Adventures in Wonderland 1:32:29 - 0:23:45 - 0:08:44 + 0:23:45 + 0:08:44 -epub-media-overlay-active -epub-media-overlay-playing - - + + + + diff --git a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift index 4f72e26767..a8ac0d333e 100644 --- a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift @@ -47,14 +47,19 @@ class OPFParserTests: XCTestCase { XCTAssertEqual(sut.links, []) XCTAssertEqual(sut.readingOrder, [ link(href: "titlepage.xhtml", mediaType: .xhtml), - link(href: "EPUB/chapter01.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/chapter01.smil", mediaType: .smil), 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), @@ -225,12 +230,19 @@ class OPFParserTests: XCTestCase { // MARK: - Media Overlays - func testParseMediaOverlaysDurationPerSpineItem() throws { + func testParseMediaOverlaysSmilAsAlternate() throws { let sut = try parseManifest("media-overlays", at: "EPUB/content.opf").manifest - // chapter01: 0:23:45 = 23*60 + 45 = 1425 - XCTAssertEqual(sut.readingOrder[0].duration, 1425.0) - // chapter02: 0:08:44 = 8*60 + 44 = 524 - XCTAssertEqual(sut.readingOrder[1].duration, 524.0) + + // 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) } // MARK: - Helpers From 353cd46e257a2a153b1ff161f188305ac66cfd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 20 Feb 2026 13:21:13 +0100 Subject: [PATCH 45/55] Add a Publication Service to retrieve Guided Navigation Documents (#733) --- .../GuidedNavigationDocument.swift | 15 +-- .../GuidedNavigationObject.swift | 21 +++- Sources/Shared/Publication/Link.swift | 11 ++ .../GuidedNavigationService.swift | 62 ++++++++++ .../Services/PublicationServicesBuilder.swift | 2 + Sources/Streamer/Parser/EPUB/EPUBParser.swift | 1 + .../SMIL/SMILGuidedNavigationService.swift | 90 +++++++++++++++ .../Parser/EPUB/{ => SMIL}/SMILParser.swift | 9 +- .../ReadiumGuidedNavigationService.swift | 85 ++++++++++++++ .../Parser/Readium/ReadiumWebPubParser.swift | 2 + Support/Carthage/.xcodegen | 7 +- .../Readium.xcodeproj/project.pbxproj | 36 +++++- .../GuidedNavigationDocumentTests.swift | 10 +- .../Publication/LinkArrayTests.swift | 25 ++++ .../SMILGuidedNavigationServiceTests.swift | 107 +++++++++++++++++ .../EPUB/{ => SMIL}/SMILParserTests.swift | 7 +- .../ReadiumGuidedNavigationServiceTests.swift | 108 ++++++++++++++++++ 17 files changed, 562 insertions(+), 36 deletions(-) create mode 100644 Sources/Shared/Publication/Services/GuidedNavigation/GuidedNavigationService.swift create mode 100644 Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift rename Sources/Streamer/Parser/EPUB/{ => SMIL}/SMILParser.swift (98%) create mode 100644 Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift create mode 100644 Tests/StreamerTests/Parser/EPUB/SMIL/SMILGuidedNavigationServiceTests.swift rename Tests/StreamerTests/Parser/EPUB/{ => SMIL}/SMILParserTests.swift (98%) create mode 100644 Tests/StreamerTests/Parser/Readium/ReadiumGuidedNavigationServiceTests.swift diff --git a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift index b12538e784..23e228bc66 100644 --- a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift +++ b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift @@ -12,19 +12,11 @@ import ReadiumInternal /// /// https://readium.org/guided-navigation/ public struct GuidedNavigationDocument: Hashable, Sendable { - /// References to other resources that are related to the current Guided - /// Navigation Document. - public var links: [Link] - /// A sequence of resources and/or media fragments into these resources, /// meant to be presented sequentially to the user. public var guided: [GuidedNavigationObject] - public init( - links: [Link] = [], - guided: [GuidedNavigationObject] - ) { - self.links = links + public init(guided: [GuidedNavigationObject]) { self.guided = guided } @@ -43,9 +35,6 @@ public struct GuidedNavigationDocument: Hashable, Sendable { throw JSONError.parsing(Self.self) } - self.init( - links: [Link](json: json["links"], warnings: warnings), - guided: guided - ) + self.init(guided: guided) } } diff --git a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift index a4194ab807..b0ae6cebde 100644 --- a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift +++ b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift @@ -315,10 +315,6 @@ public struct GuidedNavigationObject: Hashable, Sendable { /// provides additional context to a referenced passage of text. public static let footnote = Role("footnote") - /// A preliminary section that typically introduces the scope or nature - /// of the work. - public static let foreword = Role("foreword") - /// A brief dictionary of new, uncommon, or specialized terms used in /// the content. public static let glossary = Role("glossary") @@ -516,6 +512,14 @@ public struct GuidedNavigationObject: Hashable, Sendable { // MARK: Inherited from EPUB SSV 1.1 + /// An area in a comic panel that contains the words, spoken or thought, + /// of a character. + public static let bubble = Role("bubble") + + /// An introductory section that precedes the work, typically not + /// written by the author of the work. + public static let foreword = Role("foreword") + /// A collection of references to audio clips. public static let landmarks = Role("landmarks") @@ -530,6 +534,15 @@ public struct GuidedNavigationObject: Hashable, Sendable { /// A listing of video clips included in the work. public static let lov = Role("lov") + + /// An individual frame, or drawing. + public static let panel = Role("panel") + + /// A group of panels (e.g., a strip). + public static let panelGroup = Role("panelGroup") + + /// An area of text in a comic panel that represents a sound. + public static let sound = Role("sound") } } diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index 5197fc977f..fb29b09699 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -207,6 +207,12 @@ public struct Link: JSONEquatable, Hashable, Sendable { } } +extension Link: URLConvertible { + public var anyURL: AnyURL { + url() + } +} + public extension Array where Element == Link { /// Parses multiple JSON links into an array of Link. /// eg. let links = [Link](json: [["href", "http://link1"], ["href", "http://link2"]]) @@ -288,6 +294,11 @@ public extension Array where Element == Link { allSatisfy { $0.mediaType?.isHTML == true } } + /// Returns whether any resource in the collection matches the given media type. + func anyMatchingMediaType(_ mediaType: MediaType) -> Bool { + contains { mediaType.matches($0.mediaType) } + } + /// Returns whether all the resources in the collection are matching the given media type. func allMatchingMediaType(_ mediaType: MediaType) -> Bool { allSatisfy { mediaType.matches($0.mediaType) } diff --git a/Sources/Shared/Publication/Services/GuidedNavigation/GuidedNavigationService.swift b/Sources/Shared/Publication/Services/GuidedNavigation/GuidedNavigationService.swift new file mode 100644 index 0000000000..78bbfb13fa --- /dev/null +++ b/Sources/Shared/Publication/Services/GuidedNavigation/GuidedNavigationService.swift @@ -0,0 +1,62 @@ +// +// 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 + +public typealias GuidedNavigationServiceFactory = (PublicationServiceContext) -> GuidedNavigationService? + +/// Provides pre-authored ``GuidedNavigationDocument`` objects for individual +/// reading order resources. +public protocol GuidedNavigationService: PublicationService { + /// Whether this publication has any pre-authored guided navigation + /// documents at all. + var hasGuidedNavigation: Bool { get } + + /// Returns whether a pre-authored ``GuidedNavigationDocument`` exists for + /// the given reading order resource, without fetching or parsing it. + func hasGuidedNavigation(for href: any URLConvertible) -> Bool + + /// Returns the pre-authored ``GuidedNavigationDocument`` for the given + /// reading order resource, or `nil` if none exists for this resource. + func guidedNavigationDocument(for href: any URLConvertible) async -> ReadResult +} + +// MARK: Publication Helpers + +public extension Publication { + /// Whether this publication has any pre-authored guided navigation + /// documents. + var hasGuidedNavigation: Bool { + findService(GuidedNavigationService.self)?.hasGuidedNavigation ?? false + } + + /// Returns whether a pre-authored guided navigation document exists for + /// the given reading order resource. + func hasGuidedNavigation(for href: any URLConvertible) -> Bool { + findService(GuidedNavigationService.self)?.hasGuidedNavigation(for: href) ?? false + } + + /// Returns the pre-authored guided navigation document for the given + /// reading order resource, or `nil` if none exists. + func guidedNavigationDocument(for href: any URLConvertible) async -> ReadResult { + guard let service = findService(GuidedNavigationService.self) else { + return .success(nil) + } + return await service.guidedNavigationDocument(for: href) + } +} + +// MARK: PublicationServicesBuilder Helpers + +public extension PublicationServicesBuilder { + mutating func setGuidedNavigationServiceFactory(_ factory: GuidedNavigationServiceFactory?) { + if let factory { + set(GuidedNavigationService.self, factory) + } else { + remove(GuidedNavigationService.self) + } + } +} diff --git a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift index 7fc019103b..56027899dc 100644 --- a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift +++ b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift @@ -16,6 +16,7 @@ public struct PublicationServicesBuilder { content: ContentServiceFactory? = nil, contentProtection: ContentProtectionServiceFactory? = nil, cover: CoverServiceFactory? = ResourceCoverService.makeFactory(), + guidedNavigation: GuidedNavigationServiceFactory? = nil, locator: LocatorServiceFactory? = { DefaultLocatorService(publication: $0.publication) }, positions: PositionsServiceFactory? = nil, search: SearchServiceFactory? = nil, @@ -24,6 +25,7 @@ public struct PublicationServicesBuilder { setContentServiceFactory(content) setContentProtectionServiceFactory(contentProtection) setCoverServiceFactory(cover) + setGuidedNavigationServiceFactory(guidedNavigation) setLocatorServiceFactory(locator) setPositionsServiceFactory(positions) setSearchServiceFactory(search) diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index bb74ddfbfe..4e72ce0e1a 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -71,6 +71,7 @@ public final class EPUBParser: PublicationParser { HTMLResourceContentIterator.Factory(), ] ), + guidedNavigation: SMILGuidedNavigationService.makeFactory(), positions: EPUBPositionsService.makeFactory(reflowableStrategy: reflowablePositionsStrategy), search: StringSearchService.makeFactory() ) diff --git a/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift b/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift new file mode 100644 index 0000000000..aff8b2deba --- /dev/null +++ b/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift @@ -0,0 +1,90 @@ +// +// 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 + +/// A ``GuidedNavigationService`` for EPUB 3 publications with SMIL Media +/// Overlay documents. +/// +/// Discovers SMIL documents via `link.alternates` on reading order items, as +/// populated by ``EPUBParser``. +actor SMILGuidedNavigationService: GuidedNavigationService { + static func makeFactory() -> GuidedNavigationServiceFactory { + { context in + SMILGuidedNavigationService( + readingOrder: context.manifest.readingOrder, + container: context.container + ) + } + } + + nonisolated let readingOrder: [Link] + private let container: Container + private var gndCache: [AnyURL: GuidedNavigationDocument?] = [:] + + init(readingOrder: [Link], container: Container) { + self.readingOrder = readingOrder + self.container = container + } + + nonisolated var hasGuidedNavigation: Bool { + readingOrder.contains { + $0.alternates.anyMatchingMediaType(.smil) + } + } + + nonisolated func hasGuidedNavigation(for href: any URLConvertible) -> Bool { + readingOrder.firstWithHREF(href)? + .alternates + .anyMatchingMediaType(.smil) + ?? false + } + + func guidedNavigationDocument( + for href: any URLConvertible + ) async -> ReadResult { + guard + let link = readingOrder.firstWithHREF(href), + let smilURL = link.alternates.firstWithMediaType(.smil)?.url() + else { + return .success(nil) + } + + if let cached = gndCache[smilURL] { + return .success(cached) + } + let result = await retrieve(smilURL) + if case let .success(doc) = result { + // Use updateValue to properly store nil without removing the key. + // A nil doc is a valid cached result (SMIL exists but has no guided + // content), and the dictionary subscript setter treats `= nil` as + // key removal, which would cause repeated re-parsing. + gndCache.updateValue(doc, forKey: smilURL) + } + return result + } + + private func retrieve(_ smilURL: AnyURL) async -> ReadResult { + guard let resource = container[smilURL] else { + return .failure(.decoding("SMIL not found at \(smilURL)")) + } + + return await resource.read() + .flatMap { data in + do { + return try .success( + SMILParser.parseGuidedNavigationDocument( + smilData: data, + at: smilURL + ) + ) + } catch { + return .failure(.decoding(error)) + } + } + } +} diff --git a/Sources/Streamer/Parser/EPUB/SMILParser.swift b/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift similarity index 98% rename from Sources/Streamer/Parser/EPUB/SMILParser.swift rename to Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift index 58899bb06b..fabe4dc518 100644 --- a/Sources/Streamer/Parser/EPUB/SMILParser.swift +++ b/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift @@ -19,7 +19,7 @@ enum SMILParser { /// content. static func parseGuidedNavigationDocument( smilData: Data, - at url: RelativeURL, + at url: AnyURL, warnings: WarningLogger? = nil ) throws -> GuidedNavigationDocument? { let document = try ReadiumFuzi.XMLDocument(data: smilData) @@ -80,7 +80,7 @@ enum SMILParser { /// Holds the per-parse state, avoiding parameter threading through helpers. private struct SMILGuidedNavigationDocumentParsing { let document: ReadiumFuzi.XMLDocument - let url: RelativeURL + let url: AnyURL let warnings: WarningLogger? func parse() -> GuidedNavigationDocument? { @@ -296,11 +296,16 @@ private struct SMILGuidedNavigationDocumentParsing { // EPUB 3 Structural Semantics Vocabulary 1.1 + "balloon": .bubble, + "foreword": .foreword, "landmarks": .landmarks, "loa": .loa, "loi": .loi, "lot": .lot, "lov": .lov, + "panel": .panel, + "panel-group": .panelGroup, + "soundArea": .sound, ] } diff --git a/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift b/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift new file mode 100644 index 0000000000..5da1d4d4f1 --- /dev/null +++ b/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift @@ -0,0 +1,85 @@ +// +// 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 + +/// A ``GuidedNavigationService`` to retrieve pre-authored Readium Guided +/// Navigation Documents. +/// +/// Discovers Guided Navigation Documents via `link.alternates` on reading order +/// items. +actor ReadiumGuidedNavigationService: GuidedNavigationService { + static func makeFactory() -> GuidedNavigationServiceFactory { + { context in + ReadiumGuidedNavigationService( + manifest: context.manifest, + container: context.container + ) + } + } + + nonisolated let manifest: Manifest + private let container: Container + private var gndCache: [AnyURL: GuidedNavigationDocument?] = [:] + + init(manifest: Manifest, container: Container) { + self.manifest = manifest + self.container = container + } + + nonisolated var hasGuidedNavigation: Bool { + manifest.readingOrder.contains { + $0.alternates.anyMatchingMediaType(.readiumGuidedNavigationDocument) + } + } + + nonisolated func hasGuidedNavigation(for href: any URLConvertible) -> Bool { + manifest.readingOrder.firstWithHREF(href)? + .alternates + .anyMatchingMediaType(.readiumGuidedNavigationDocument) + ?? false + } + + func guidedNavigationDocument( + for href: any URLConvertible + ) async -> ReadResult { + guard + let readingOrderLink = manifest.readingOrder.firstWithHREF(href), + let gnURL = readingOrderLink.alternates.firstWithMediaType(.readiumGuidedNavigationDocument)?.url() + else { + return .success(nil) + } + + if let cached = gndCache[gnURL] { + return .success(cached) + } + let result = await retrieve(gnURL) + if case let .success(doc) = result { + // Use updateValue to properly store nil without removing the key. + // A nil doc is a valid cached result (document exists but has no + // guided content), and the dictionary subscript setter treats + // `= nil` as key removal, which would cause repeated re-parsing. + gndCache.updateValue(doc, forKey: gnURL) + } + return result + } + + private func retrieve(_ gnURL: AnyURL) async -> ReadResult { + guard let resource = container[gnURL] else { + return .failure(.decoding("Guided Navigation Document not found at \(gnURL)")) + } + + return await resource.readAsJSONObject() + .flatMap { json in + do { + return try .success(GuidedNavigationDocument(json: json)) + } catch { + return .failure(.decoding(error)) + } + } + } +} diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index 5e93587d84..bad35d4c06 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -136,6 +136,8 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { ] )) } + + $0.setGuidedNavigationServiceFactory(ReadiumGuidedNavigationService.makeFactory()) }) ) } diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 0d708d06c8..59511fbcf6 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -674,6 +674,8 @@ ../../Sources/Shared/Publication/Services/Cover/CoverService.swift ../../Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift ../../Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift +../../Sources/Shared/Publication/Services/GuidedNavigation +../../Sources/Shared/Publication/Services/GuidedNavigation/GuidedNavigationService.swift ../../Sources/Shared/Publication/Services/Locator ../../Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift ../../Sources/Shared/Publication/Services/Locator/LocatorService.swift @@ -852,7 +854,9 @@ ../../Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift ../../Sources/Streamer/Parser/EPUB/Services ../../Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift -../../Sources/Streamer/Parser/EPUB/SMILParser.swift +../../Sources/Streamer/Parser/EPUB/SMIL +../../Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift +../../Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift ../../Sources/Streamer/Parser/EPUB/XMLNamespace.swift ../../Sources/Streamer/Parser/Image ../../Sources/Streamer/Parser/Image/ComicInfoParser.swift @@ -865,6 +869,7 @@ ../../Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift ../../Sources/Streamer/Parser/PublicationParser.swift ../../Sources/Streamer/Parser/Readium +../../Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift ../../Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift ../../Sources/Streamer/PublicationOpener.swift ../../Sources/Streamer/Toolkit diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 961772f904..477db5d9de 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -53,7 +53,6 @@ 18217BC157557A5DDA4BA119 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADDB8B9906FC78C038203BDD /* User.swift */; }; 1852D8C28060050B53CFABED /* XMLNamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E494545D2CCE72A3FED240 /* XMLNamespace.swift */; }; 188D742F80B70DE8A625AD21 /* Facet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387B19B66C4D91A295B5EFA6 /* Facet.swift */; }; - 19F22A0A2E7339052A888CCE /* SMILParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38A7D45005927987BFEA228 /* SMILParser.swift */; }; 1A4F41D5A7E48472DB9A181E /* ZIPFoundationArchiveOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50064FE9BBCEA4C00BA6BBEF /* ZIPFoundationArchiveOpener.swift */; }; 1AEF63A8471C7676092842D2 /* FileExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D555435E2BADB2B877FD50C7 /* FileExtension.swift */; }; 1B15166C79C7C05CE491AD2C /* CSSProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E6147EF790DE532CE1699D /* CSSProperties.swift */; }; @@ -157,6 +156,7 @@ 5912EC9BB073282862F325F2 /* DocumentTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A00FF0C84822A134A353BD4 /* DocumentTypes.swift */; }; 594CE84C2B11169AA0B86615 /* HTMLResourceContentIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34AB954525AC159166C96A36 /* HTMLResourceContentIterator.swift */; }; 5A9AEC4A9AE686ED74E9B8BF /* RWPMFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE4559CE100932572C843E5 /* RWPMFormatSniffer.swift */; }; + 5CA678E3D17B036E6C25BF6A /* GuidedNavigationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210A7C677948BD92A79901CB /* GuidedNavigationService.swift */; }; 5CE009E1F701CF5A02FF637D /* PDFOutlineNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5D7B566F794F356878AE8E0 /* PDFOutlineNode.swift */; }; 5D2820C8209F82051C36A93F /* DataResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C7A5494480CD5A896B1F388 /* DataResource.swift */; }; 5DE027530786CFB542965AC6 /* EPUBLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339637CCF01E665F4CB78B01 /* EPUBLayout.swift */; }; @@ -255,9 +255,11 @@ 9AF316DF0B1CD4452D785EBC /* KeyModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0812058DB4FBFDF0A862E57E /* KeyModifiers.swift */; }; 9B0369F8C0187528486440F4 /* CompositeInputObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8E202B8A16B960AE73CABF /* CompositeInputObserver.swift */; }; 9BC4D1F2958D2F7D7BDB88DA /* CursorList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C361F965E7A7962CA3E4C0BA /* CursorList.swift */; }; + 9C261B735546F26B01C58569 /* SMILGuidedNavigationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA0EFD6B48F11ADF92C4F76 /* SMILGuidedNavigationService.swift */; }; 9DB9674C11DF356966CBFA79 /* EPUBNavigatorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC9ACC1EB3903149EBF21BC0 /* EPUBNavigatorViewModel.swift */; }; 9E6522796719FF1F16C243E7 /* MinizipContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E8D322A523DA324E3E2E59 /* MinizipContainer.swift */; }; 9E76790BAFFF08F0BFEA1BB0 /* DefaultPublicationParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5BBF2FA3188DFFCF7B88A75 /* DefaultPublicationParser.swift */; }; + 9E89A68913325E781336977A /* ReadiumGuidedNavigationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11478ACC1A5E05A48B783D1A /* ReadiumGuidedNavigationService.swift */; }; A036CCF310EB7408408FFF00 /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87629BF68F1EDBF06FC0AD54 /* ImageViewController.swift */; }; A040DE6F2D9A6B8D16B063B9 /* SwiftSoup.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE09289EB0FEA5FEC8506B1F /* SwiftSoup.xcframework */; }; A116A1DB98A28C79CFD93EDE /* EditingAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5C6D0C5860E802EDA23068C /* EditingAction.swift */; }; @@ -288,6 +290,7 @@ B1008DFBDE3E33CA552E0E26 /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8FCFFDA8AA421435CB40DE /* Optional.swift */; }; B22857E75D32AF3810D4E074 /* WebViewServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B673858EDF2BBCB316C28CBE /* WebViewServer.swift */; }; B23C740199DCBD23BDF0670F /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2085E9C042F54271D5B9555 /* Container.swift */; }; + B4162DF42D6D45176516A0FF /* SMILParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6968B9B371DF703669D90A9D /* SMILParser.swift */; }; B49522888052E9F41D0DD013 /* ZIPFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AA8BD22E2F5495286C0A1C /* ZIPFormatSniffer.swift */; }; B4D55F234AD2CB5728184346 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F669F31B0B6EC690C48916EC /* Bundle.swift */; }; B57EC602072D4276D502B80D /* AudiobookFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1674CCC0BA8B1F4E2D2B3A4C /* AudiobookFormatSniffer.swift */; }; @@ -531,6 +534,7 @@ 103E0171A3CDEFA1B1F1F180 /* WKWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKWebView.swift; sourceTree = ""; }; 10894CC9684584098A22D8FA /* URLExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtensions.swift; sourceTree = ""; }; 10FB29EDCCE5910C869295F1 /* Either.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Either.swift; sourceTree = ""; }; + 11478ACC1A5E05A48B783D1A /* ReadiumGuidedNavigationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumGuidedNavigationService.swift; sourceTree = ""; }; 11EC0100045C12EDDFE694E8 /* PreferencesEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesEditor.swift; sourceTree = ""; }; 125BAF5FDFA097BA5CC63539 /* StringExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = ""; }; 13C2DC41BEDB70085FCBBC00 /* PDFPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFPreferences.swift; sourceTree = ""; }; @@ -546,6 +550,7 @@ 1EBC685D4A0E07997088DD2D /* DataCompression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCompression.swift; sourceTree = ""; }; 1F89BC365BDD19BE84F4D3B5 /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; 1FBFAC2D57DE7EBB4E2F31BE /* ReadiumNavigatorLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumNavigatorLocalizedString.swift; sourceTree = ""; }; + 210A7C677948BD92A79901CB /* GuidedNavigationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidedNavigationService.swift; sourceTree = ""; }; 211A98219D7D1583E829DEC2 /* Layout+EPUB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Layout+EPUB.swift"; sourceTree = ""; }; 212E89D9F2CC639C3E1F81C3 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 218BE3110D2886B252A769A2 /* UTI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTI.swift; sourceTree = ""; }; @@ -662,6 +667,7 @@ 68D2804AD0439307575B3073 /* MappedPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MappedPreference.swift; sourceTree = ""; }; 68FF131876FA3A63025F2662 /* Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Language.swift; sourceTree = ""; }; 69212974E62EF509BC1F0C7A /* UnknownAbsoluteURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnknownAbsoluteURL.swift; sourceTree = ""; }; + 6968B9B371DF703669D90A9D /* SMILParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMILParser.swift; sourceTree = ""; }; 69E17C4870C64264819EB227 /* ReadiumZIPFoundation.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReadiumZIPFoundation.xcframework; path = ../../Carthage/Build/ReadiumZIPFoundation.xcframework; sourceTree = ""; }; 6BC71BAFF7A20D7903E6EE4D /* Properties+EPUB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+EPUB.swift"; sourceTree = ""; }; 6D80848AADD20D4384D9AF59 /* HTMLFontFamilyDeclaration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFontFamilyDeclaration.swift; sourceTree = ""; }; @@ -734,6 +740,7 @@ 9C2F9F4D29EBDE812891418F /* HTTPURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPURL.swift; sourceTree = ""; }; 9D2B24C3150D502382AAC939 /* ReadiumStreamer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumStreamer.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9D8FCFFDA8AA421435CB40DE /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; + 9DA0EFD6B48F11ADF92C4F76 /* SMILGuidedNavigationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMILGuidedNavigationService.swift; sourceTree = ""; }; 9DDB25FC1693613B72DFDB6E /* OpdsMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpdsMetadata.swift; sourceTree = ""; }; 9E3543F628B017E9BF65DD08 /* StringSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringSearchService.swift; sourceTree = ""; }; 9EA3A43B7709F7539F9410CD /* PaginationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationView.swift; sourceTree = ""; }; @@ -792,7 +799,6 @@ C2085E9C042F54271D5B9555 /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; C2C93C33347DC0A41FE15AC6 /* License.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = License.swift; sourceTree = ""; }; C361F965E7A7962CA3E4C0BA /* CursorList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorList.swift; sourceTree = ""; }; - C38A7D45005927987BFEA228 /* SMILParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMILParser.swift; sourceTree = ""; }; C4BFD453E8BF6FA24F340EE0 /* HTTPContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPContainer.swift; sourceTree = ""; }; C4C94659A8749299DBE3628D /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; C51A36BFDC79EB5377D69582 /* CSSLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSLayout.swift; sourceTree = ""; }; @@ -1348,6 +1354,7 @@ 47FCB3FC286AD1053854444A /* Content */, A4A409DF92515874F2F0DF6B /* Content Protection */, 3723879A352B0300CCC0006E /* Cover */, + B72442C0FC8CBF11B86B1C5D /* GuidedNavigation */, 3118D7E15D685347720A0651 /* Locator */, 5BC52D8F4F854FDA56D10A8E /* Positions */, F818D082B369A3D4BE617D46 /* Search */, @@ -1476,11 +1483,11 @@ 4363E8A92B1EA9AF2561DCE9 /* NCXParser.swift */, 0FC49AFB32B525AAC5BF7612 /* OPFMeta.swift */, 61575203A3BEB8E218CAFE38 /* OPFParser.swift */, - C38A7D45005927987BFEA228 /* SMILParser.swift */, 364F18D0F750A1D98A381654 /* XMLNamespace.swift */, 00753735EE68A5BCEF35D787 /* Extensions */, 5EC23231F487889071301718 /* Resource Transformers */, 36AE60D6C483E1F4BBE7AAE0 /* Services */, + B4C160CA74CFBD0073F81A57 /* SMIL */, ); path = EPUB; sourceTree = ""; @@ -1820,9 +1827,27 @@ path = Toolkit; sourceTree = ""; }; + B4C160CA74CFBD0073F81A57 /* SMIL */ = { + isa = PBXGroup; + children = ( + 9DA0EFD6B48F11ADF92C4F76 /* SMILGuidedNavigationService.swift */, + 6968B9B371DF703669D90A9D /* SMILParser.swift */, + ); + path = SMIL; + sourceTree = ""; + }; + B72442C0FC8CBF11B86B1C5D /* GuidedNavigation */ = { + isa = PBXGroup; + children = ( + 210A7C677948BD92A79901CB /* GuidedNavigationService.swift */, + ); + path = GuidedNavigation; + sourceTree = ""; + }; B74FB52A54096777BE883182 /* Readium */ = { isa = PBXGroup; children = ( + 11478ACC1A5E05A48B783D1A /* ReadiumGuidedNavigationService.swift */, E6E97CCA91F910315C260373 /* ReadiumWebPubParser.swift */, ); path = Readium; @@ -2536,8 +2561,10 @@ E5D440B49453AC615946E7FB /* PDFPositionsService.swift in Sources */, 2F8F8B6A05F8E124BA9D6B22 /* PublicationOpener.swift in Sources */, 67F1C7C3D434D2AA542376E3 /* PublicationParser.swift in Sources */, + 9E89A68913325E781336977A /* ReadiumGuidedNavigationService.swift in Sources */, C9FBD23E459FB395377E149E /* ReadiumWebPubParser.swift in Sources */, - 19F22A0A2E7339052A888CCE /* SMILParser.swift in Sources */, + 9C261B735546F26B01C58569 /* SMILGuidedNavigationService.swift in Sources */, + B4162DF42D6D45176516A0FF /* SMILParser.swift in Sources */, 4AE70F783C07D9938B40E792 /* StringExtension.swift in Sources */, DEF5B692764428697150AA8A /* XMLNamespace.swift in Sources */, ); @@ -2715,6 +2742,7 @@ 66018235ED40B89D27EE9F33 /* Group.swift in Sources */, 007A17CE92F8F2CFEBD91534 /* GuidedNavigationDocument.swift in Sources */, 9A5AE6CF737280C031231519 /* GuidedNavigationObject.swift in Sources */, + 5CA678E3D17B036E6C25BF6A /* GuidedNavigationService.swift in Sources */, A8F8C4F2C0795BACE0A8C62C /* HREFNormalizer.swift in Sources */, A348284A6738CD705288CB8C /* HTMLFormatSniffer.swift in Sources */, 594CE84C2B11169AA0B86615 /* HTMLResourceContentIterator.swift in Sources */, diff --git a/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift index 920552a075..1e75c4abb9 100644 --- a/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift +++ b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift @@ -24,12 +24,9 @@ import Testing )) } - @Test("full JSON with links and guided") + @Test("full JSON with guided") func fullJSON() throws { let sut = try GuidedNavigationDocument(json: [ - "links": [ - ["href": "https://example.com/manifest.json", "type": "application/webpub+json", "rel": "self"], - ], "guided": [ ["textref": "chapter1.html"], ["audioref": "track.mp3"], @@ -37,15 +34,12 @@ import Testing ]) #expect(sut?.guided.count == 2) - #expect(sut?.links.count == 1) } @Test("missing guided throws") func missingGuided() throws { #expect(throws: JSONError.self) { - try GuidedNavigationDocument(json: [ - "links": [], - ]) + try GuidedNavigationDocument(json: [:]) } } diff --git a/Tests/SharedTests/Publication/LinkArrayTests.swift b/Tests/SharedTests/Publication/LinkArrayTests.swift index d8cc24fc5a..9ea0cbbe73 100644 --- a/Tests/SharedTests/Publication/LinkArrayTests.swift +++ b/Tests/SharedTests/Publication/LinkArrayTests.swift @@ -238,6 +238,31 @@ class LinkArrayTests: XCTestCase { XCTAssertFalse(links.allAreHTML) } + /// Checks if any link matches the given media type. + func testAnyMatchingMediaType() throws { + let links = try [ + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), + ] + + XCTAssertTrue(links.anyMatchingMediaType(.html)) + } + + /// Checks if any link matches the given media type, when it's not the case. + func testAnyMatchingMediaTypeFalse() { + let links = [ + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: .text), + ] + + XCTAssertFalse(links.anyMatchingMediaType(.html)) + } + + /// Checks if any link matches the given media type in an empty collection. + func testAnyMatchingMediaTypeEmpty() { + XCTAssertFalse([Link]().anyMatchingMediaType(.html)) + } + /// Checks if all the links match the given media type. func testAllMatchesMediaType() throws { let links = try [ diff --git a/Tests/StreamerTests/Parser/EPUB/SMIL/SMILGuidedNavigationServiceTests.swift b/Tests/StreamerTests/Parser/EPUB/SMIL/SMILGuidedNavigationServiceTests.swift new file mode 100644 index 0000000000..c975cc1e27 --- /dev/null +++ b/Tests/StreamerTests/Parser/EPUB/SMIL/SMILGuidedNavigationServiceTests.swift @@ -0,0 +1,107 @@ +// +// 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 +@testable import ReadiumStreamer +import Testing + +@Suite class SMILGuidedNavigationServiceTests { + let smilData = """ + + + + + + + + + + + + + + + + """ + + let smilLink = Link( + href: "OEBPS/chapter01.smil", + mediaType: .smil + ) + + /// Reading order link with a SMIL alternate. + lazy var linkWithSMIL = Link( + href: "OEBPS/chapter01.xhtml", + mediaType: .html, + alternates: [smilLink] + ) + + /// Reading order link without any SMIL alternate. + let linkWithoutSMIL = Link( + href: "OEBPS/chapter02.xhtml", + mediaType: .html + ) + + func makeService(readingOrder: [Link], container: Container? = nil) -> SMILGuidedNavigationService { + let container = container ?? SingleResourceContainer( + resource: DataResource(string: smilData), + at: smilLink.url() + ) + + return SMILGuidedNavigationService(readingOrder: readingOrder, container: container) + } + + // MARK: - hasGuidedNavigation(for:) + + @Test func hasGuidedNavigationFalseForLinkWithoutSMIL() { + let service = makeService(readingOrder: [linkWithoutSMIL]) + #expect(service.hasGuidedNavigation(for: linkWithoutSMIL) == false) + } + + @Test func hasGuidedNavigationTrueForLinkWithSMIL() { + let service = makeService(readingOrder: [linkWithSMIL]) + #expect(service.hasGuidedNavigation(for: linkWithSMIL) == true) + } + + @Test func hasGuidedNavigationPublicationLevelTrueWhenAnyLinkHasSMIL() { + let service = makeService(readingOrder: [linkWithoutSMIL, linkWithSMIL]) + #expect(service.hasGuidedNavigation == true) + } + + @Test func hasGuidedNavigationPublicationLevelFalseWhenNoLinkHasSMIL() { + let service = makeService(readingOrder: [linkWithoutSMIL]) + #expect(service.hasGuidedNavigation == false) + } + + // MARK: - guidedNavigationDocument(for:) + + @Test func returnsNilForLinkWithoutSMIL() async throws { + let service = makeService(readingOrder: [linkWithoutSMIL]) + let result = await service.guidedNavigationDocument(for: linkWithoutSMIL) + #expect(try result.get() == nil) + } + + @Test func returnsFailureWhenSMILMissingFromContainer() async { + let service = makeService(readingOrder: [linkWithSMIL], container: EmptyContainer()) + let result = await service.guidedNavigationDocument(for: linkWithSMIL) + #expect { + try result.get() + } throws: { error in + guard let readError = error as? ReadError, case .decoding = readError else { return false } + return true + } + } + + @Test func returnsDocumentForLinkWithSMIL() async throws { + let service = makeService(readingOrder: [linkWithSMIL]) + let doc = try await service.guidedNavigationDocument(for: linkWithSMIL).get() + #expect(doc != nil) + #expect(doc?.guided.isEmpty == false) + } +} diff --git a/Tests/StreamerTests/Parser/EPUB/SMILParserTests.swift b/Tests/StreamerTests/Parser/EPUB/SMIL/SMILParserTests.swift similarity index 98% rename from Tests/StreamerTests/Parser/EPUB/SMILParserTests.swift rename to Tests/StreamerTests/Parser/EPUB/SMIL/SMILParserTests.swift index 9dfb4fcb0e..3d2061fe00 100644 --- a/Tests/StreamerTests/Parser/EPUB/SMILParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/SMIL/SMILParserTests.swift @@ -18,7 +18,7 @@ import Testing /// relative HREFs like `chapter01.xhtml` resolve against `OEBPS/`. static func parse(_ name: String) throws -> GuidedNavigationDocument? { let data = fixtures.data(at: name) - let url = RelativeURL(string: "OEBPS/chapter01.smil")! + let url = AnyURL(string: "OEBPS/chapter01.smil")! return try SMILParser.parseGuidedNavigationDocument(smilData: data, at: url) } @@ -238,7 +238,6 @@ import Testing @Test func bodyChildrenBecomeTopLevelGuided() throws { let doc = try SMILParserTests.parse("basic.smil") // basic.smil has one top-level seq in the body - #expect(doc?.links == []) #expect(doc?.guided.count == 1) } @@ -246,7 +245,7 @@ import Testing // Fuzi parses leniently, so malformed/non-SMIL content produces no // and the parser returns nil rather than throwing. let badData = Data("not xml at all".utf8) - let url = try #require(RelativeURL(string: "OEBPS/chapter01.smil")) + let url = try #require(AnyURL(string: "OEBPS/chapter01.smil")) let doc = try SMILParser.parseGuidedNavigationDocument(smilData: badData, at: url) #expect(doc == nil) } @@ -258,7 +257,7 @@ import Testing """.data(using: .utf8)! - let url = try #require(RelativeURL(string: "OEBPS/chapter01.smil")) + let url = try #require(AnyURL(string: "OEBPS/chapter01.smil")) let doc = try SMILParser.parseGuidedNavigationDocument(smilData: xml, at: url) #expect(doc == nil) } diff --git a/Tests/StreamerTests/Parser/Readium/ReadiumGuidedNavigationServiceTests.swift b/Tests/StreamerTests/Parser/Readium/ReadiumGuidedNavigationServiceTests.swift new file mode 100644 index 0000000000..a583ea03ae --- /dev/null +++ b/Tests/StreamerTests/Parser/Readium/ReadiumGuidedNavigationServiceTests.swift @@ -0,0 +1,108 @@ +// +// 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 +@testable import ReadiumStreamer +import Testing + +@Suite class ReadiumGuidedNavigationServiceTests { + /// Per-resource GN alternate link. + lazy var gnLink = Link( + href: "guided.json", + mediaType: .readiumGuidedNavigationDocument + ) + + /// Reading order link with a per-resource GN alternate. + lazy var linkWithGN = Link( + href: "chapter01.xhtml", + mediaType: .html, + alternates: [gnLink] + ) + + /// Reading order link without any GN alternate. + lazy var linkWithoutGN = Link( + href: "chapter02.xhtml", + mediaType: .html + ) + + func makeService( + readingOrder: [Link], + guided: [[String: Any]]? = nil + ) -> ReadiumGuidedNavigationService { + let manifest = Manifest( + metadata: Metadata(title: "Test"), + readingOrder: readingOrder + ) + + let container: Container + if let guided = guided { + let json: [String: Any] = ["guided": guided] + let gnd = try! JSONSerialization.data(withJSONObject: json) + container = SingleResourceContainer( + resource: DataResource(data: gnd), + at: gnLink.url() + ) + } else { + container = EmptyContainer() + } + + return ReadiumGuidedNavigationService(manifest: manifest, container: container) + } + + // MARK: - hasGuidedNavigation(for:) + + @Test func hasGuidedNavigationFalseForLinkWithoutGN() { + let service = makeService(readingOrder: [linkWithoutGN]) + #expect(service.hasGuidedNavigation(for: linkWithoutGN) == false) + } + + @Test func hasGuidedNavigationTrueForLinkWithGNAlternate() { + let service = makeService(readingOrder: [linkWithGN]) + #expect(service.hasGuidedNavigation(for: linkWithGN) == true) + } + + @Test func hasGuidedNavigationPublicationLevelTrueWhenAnyLinkHasGN() { + let service = makeService(readingOrder: [linkWithoutGN, linkWithGN]) + #expect(service.hasGuidedNavigation == true) + } + + @Test func hasGuidedNavigationPublicationLevelFalseWhenNoLinkHasGN() { + let service = makeService(readingOrder: [linkWithoutGN]) + #expect(service.hasGuidedNavigation == false) + } + + // MARK: - guidedNavigationDocument(for:) — per-resource + + @Test func returnsNilForLinkWithoutGN() async throws { + let service = makeService(readingOrder: [linkWithoutGN]) + let result = await service.guidedNavigationDocument(for: linkWithoutGN) + #expect(try result.get() == nil) + } + + @Test func returnsFailureWhenGNDocumentMissingFromContainer() async { + let service = makeService(readingOrder: [linkWithGN]) + let result = await service.guidedNavigationDocument(for: linkWithGN) + #expect { + try result.get() + } throws: { error in + guard let readError = error as? ReadError, case .decoding = readError else { return false } + return true + } + } + + @Test func returnsDocumentFromPerResourceAlternate() async throws { + let guided: [[String: Any]] = [ + ["textref": "chapter01.xhtml#s1"], + ] + let service = makeService(readingOrder: [linkWithGN], guided: guided) + + let doc = try await service.guidedNavigationDocument(for: linkWithGN).get() + #expect(try doc == GuidedNavigationDocument(guided: [ + #require(GuidedNavigationObject(refs: .init(text: AnyURL(string: "chapter01.xhtml#s1")))), + ])) + } +} From 43e377abf93e79377944fba177382310e429c588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 24 Feb 2026 18:00:25 +0100 Subject: [PATCH 46/55] Automate CocoaPods podspec generation (#734) --- .github/workflows/checks.yml | 6 + BuildTools/Package.swift | 8 +- .../Sources/GeneratePodspecs/Specs.swift | 1 + .../Sources/GeneratePodspecs/main.swift | 101 ++++++++++++ MAINTAINING.md | 63 +++++++- Makefile | 7 +- .../ReadiumAdapterGCDWebServer.podspec | 5 +- .../CocoaPods/ReadiumAdapterLCPSQLite.podspec | 6 +- Support/CocoaPods/ReadiumInternal.podspec | 3 + Support/CocoaPods/ReadiumLCP.podspec | 10 +- Support/CocoaPods/ReadiumNavigator.podspec | 5 +- Support/CocoaPods/ReadiumOPDS.podspec | 5 +- Support/CocoaPods/ReadiumShared.podspec | 35 +++-- Support/CocoaPods/ReadiumStreamer.podspec | 9 +- Support/CocoaPods/Specs.swift | 145 ++++++++++++++++++ 15 files changed, 378 insertions(+), 31 deletions(-) create mode 120000 BuildTools/Sources/GeneratePodspecs/Specs.swift create mode 100644 BuildTools/Sources/GeneratePodspecs/main.swift create mode 100644 Support/CocoaPods/Specs.swift diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 73e36c597f..f294ecca12 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -33,6 +33,12 @@ jobs: # Check that the Carthage project is up to date. make carthage-project git diff --exit-code Support/Carthage/Readium.xcodeproj + - name: Check CocoaPods podspecs + run: | + # Check that the podspecs are up to date. + make podspecs + git diff --exit-code Support/CocoaPods/ + if git ls-files --others --exclude-standard Support/CocoaPods/ | grep -q .; then echo "Untracked podspec files found. Run 'make podspecs' and commit the result."; exit 1; fi - name: Build run: | set -eo pipefail diff --git a/BuildTools/Package.swift b/BuildTools/Package.swift index 66488a67b4..87b47e52de 100644 --- a/BuildTools/Package.swift +++ b/BuildTools/Package.swift @@ -13,5 +13,11 @@ let package = Package( dependencies: [ .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.59.1"), ], - targets: [.target(name: "BuildTools", path: "")] + targets: [ + .target(name: "BuildTools", path: "", exclude: ["Sources"]), + .target( + name: "GeneratePodspecs", + path: "Sources/GeneratePodspecs" + ), + ] ) diff --git a/BuildTools/Sources/GeneratePodspecs/Specs.swift b/BuildTools/Sources/GeneratePodspecs/Specs.swift new file mode 120000 index 0000000000..ebd3c6f83a --- /dev/null +++ b/BuildTools/Sources/GeneratePodspecs/Specs.swift @@ -0,0 +1 @@ +../../../Support/CocoaPods/Specs.swift \ No newline at end of file diff --git a/BuildTools/Sources/GeneratePodspecs/main.swift b/BuildTools/Sources/GeneratePodspecs/main.swift new file mode 100644 index 0000000000..25f91fe4e2 --- /dev/null +++ b/BuildTools/Sources/GeneratePodspecs/main.swift @@ -0,0 +1,101 @@ +// +// 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. +// + +// Generates Support/CocoaPods/*.podspec from the module definitions in Specs.swift. +// Run from the repo root via: swift run --package-path BuildTools GeneratePodspecs + +import Foundation + +let outputDir = URL(fileURLWithPath: "Support/CocoaPods") + +guard FileManager.default.fileExists(atPath: outputDir.path) else { + fputs("Error: '\(outputDir.path)' not found. Run this tool from the repository root.\n", stderr) + exit(1) +} + +for module in modules { + let content = render(module) + let dest = outputDir.appendingPathComponent("\(module.name).podspec") + do { + try content.write(to: dest, atomically: true, encoding: .utf8) + print("Wrote \(module.name).podspec") + } catch { + fputs("Error: failed to write \(dest.path): \(error)\n", stderr) + exit(1) + } +} + +func render(_ m: ModuleSpec) -> String { + var lines: [String] = [] + + lines.append("# This file is generated by `make podspecs`. Do not edit manually.") + lines.append("# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate.") + lines.append("") + lines.append("Pod::Spec.new do |s|") + lines.append("") + lines.append(" s.name = \"\(m.name)\"") + lines.append(" s.version = \"\(version)\"") + lines.append(" s.license = \"BSD 3-Clause License\"") + lines.append(" s.summary = \"\(m.summary)\"") + lines.append(" s.homepage = \"http://readium.github.io\"") + lines.append(" s.author = { \"Readium\" => \"contact@readium.org\" }") + lines.append(" s.source = { :git => \"https://github.com/readium/swift-toolkit.git\", :tag => s.version }") + lines.append(" s.requires_arc = true") + + if !m.resourceBundles.isEmpty { + // Sort by key for deterministic output. + let sorted = m.resourceBundles.sorted { $0.key < $1.key } + lines.append(" s.resource_bundles = {") + for (bundleName, patterns) in sorted { + if patterns.count == 1 { + lines.append(" '\(bundleName)' => ['\(patterns[0])'],") + } else { + lines.append(" '\(bundleName)' => [") + for pattern in patterns { + lines.append(" '\(pattern)',") + } + lines.append(" ],") + } + } + lines.append(" }") + } + + lines.append(" s.source_files = \"\(m.sourcePath)/**/*.{m,h,swift}\"") + lines.append(" s.swift_version = '\(swiftVersion)'") + lines.append(" s.platform = :ios") + lines.append(" s.ios.deployment_target = \"\(iosTarget)\"") + + if !m.frameworks.isEmpty { + lines.append(" s.frameworks = \"\(m.frameworks.joined(separator: "\", \""))\"") + } + + if !m.libraries.isEmpty { + let quoted = m.libraries.map { "'\($0)'" }.joined(separator: ", ") + lines.append(" s.libraries = \(quoted)") + } + + if !m.xcconfig.isEmpty { + let sorted = m.xcconfig.sorted { $0.key < $1.key } + let pairs = sorted.map { "'\($0.key)' => '\($0.value)'" }.joined(separator: ", ") + lines.append(" s.xcconfig = { \(pairs) }") + } + + if !m.dependencies.isEmpty { + lines.append("") + for dep in m.dependencies { + switch dep { + case let .readium(name): + lines.append(" s.dependency '\(name)', '~> \(version)'") + case let .pod(name, constraint): + lines.append(" s.dependency '\(name)', '\(constraint)'") + } + } + } + + lines.append("") + lines.append("end") + return lines.joined(separator: "\n") + "\n" +} diff --git a/MAINTAINING.md b/MAINTAINING.md index 6ef1f51714..9525662e5c 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -7,7 +7,65 @@ To bump the minimum required iOS version, update these files: - `README.md`, section "Minimum Requirements" - `Package.swift` - `Support/Carthage/project.yml` -- `Support/CocoaPods/*.podspec` +- `Support/CocoaPods/*.podspec` — edit `iosTarget` in `Support/CocoaPods/Specs.swift`, then run `make podspecs` and commit the generated files + +## Creating a New Package + +A new package is a separately distributable SPM library product. It requires updates to four places. + +### 1. `Package.swift` + +Add a new product and its source/test targets: + +```swift +// products: +.library(name: "Readium", targets: ["Readium"]), + +// targets: +.target( + name: "Readium", + dependencies: ["ReadiumShared", "ReadiumNavigator"], + path: "Sources/" +), +.testTarget( + name: "ReadiumTests", + dependencies: ["Readium"], + path: "Tests/Tests" +), +``` + +### 2. `Support/CocoaPods/Readium.podspec` + +Add an entry to `Support/CocoaPods/Specs.swift` and run `make podspecs` to generate the podspec file. + +### 3. `Support/Carthage/project.yml` + +Add a new target and scheme: + +```yaml +targets: + Readium: + type: framework + platform: iOS + deploymentTarget: "15.0" + sources: + - path: ../../Sources/ + dependencies: + - target: ReadiumShared + - target: ReadiumNavigator + settings: + PRODUCT_BUNDLE_IDENTIFIER: org.readium.swift-toolkit.audio-navigator + INFOPLIST_FILE: Info.plist + +schemes: + Readium: + build: + targets: + Readium: all +``` + +> [!WARNING] +> The module name must follow the `Readium` convention, and the iOS deployment target / Swift version must match the values in all other packages. ## Releasing a New Version @@ -28,8 +86,7 @@ You are ready to release a new version of the Swift toolkit? Great, follow these 5. Update the [migration guide](Documentation/Migration%20Guide.md) in case of breaking changes. 6. Issue the new release. 1. Create a branch with the same name as the future tag, from `develop`. - 2. Bump the version numbers in the `Support/CocoaPods/*.podspec` files. - * :warning: Don't forget to bump the version numbers of the Readium dependencies as well. + 2. Bump `version` in `Support/CocoaPods/Specs.swift`, run `make podspecs`, and commit the generated files. 3. Bump the version numbers in `README.md`, and check the "Minimum Requirements" section. 4. Bump the version numbers in `TestApp/Sources/Info.plist`. 5. Close the version in the `CHANGELOG.md`, [for example](https://github.com/readium/swift-toolkit/pull/353/commits/a0714589b3da928dd923ba78f379116715797333#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4ed). diff --git a/Makefile b/Makefile index 052ed745f3..9acdc56a04 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,8 @@ SCRIPTS_PATH := Sources/Navigator/EPUB/Scripts help: @echo "Usage: make \n\n\ - carthage-proj\t\tGenerate the Carthage Xcode project\n\ + carthage-project\tGenerate the Carthage Xcode project\n\ + podspecs\t\tGenerate the CocoaPods podspecs\n\ scripts\t\tBundle the Navigator EPUB scripts\n\ test\t\t\tRun unit tests\n\ lint-format\t\tVerify formatting\n\ @@ -10,6 +11,10 @@ help: update-locales\tUpdate the localization files\n\ " +.PHONY: podspecs +podspecs: + swift run --package-path BuildTools GeneratePodspecs + .PHONY: carthage-project carthage-project: rm -rf **/.DS_Store diff --git a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec index 3a6d1df858..89ae887023 100644 --- a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec +++ b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec @@ -1,3 +1,6 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumAdapterGCDWebServer" @@ -14,8 +17,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumShared', '~> 3.7.0' s.dependency 'ReadiumInternal', '~> 3.7.0' + s.dependency 'ReadiumShared', '~> 3.7.0' s.dependency 'ReadiumGCDWebServer', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec index 2948703547..f2b3456443 100644 --- a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec +++ b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec @@ -1,3 +1,6 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumAdapterLCPSQLite" @@ -14,8 +17,9 @@ Pod::Spec.new do |s| s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumLCP', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.7.0' s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumLCP', '~> 3.7.0' s.dependency 'SQLite.swift', '~> 0.15.0' end diff --git a/Support/CocoaPods/ReadiumInternal.podspec b/Support/CocoaPods/ReadiumInternal.podspec index 55aab319db..2149efa005 100644 --- a/Support/CocoaPods/ReadiumInternal.podspec +++ b/Support/CocoaPods/ReadiumInternal.podspec @@ -1,3 +1,6 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumInternal" diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index fa65467001..cb63b588bd 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -1,3 +1,6 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumLCP" @@ -18,10 +21,11 @@ Pod::Spec.new do |s| s.swift_version = '5.10' s.platform = :ios s.ios.deployment_target = "15.0" - s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2'} - - s.dependency 'ReadiumShared' , '~> 3.7.0' + s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.dependency 'ReadiumInternal', '~> 3.7.0' + s.dependency 'ReadiumShared', '~> 3.7.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' s.dependency 'CryptoSwift', '~> 1.8.0' + end diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index 7538dcfde5..86741e481a 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -1,3 +1,6 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumNavigator" @@ -19,8 +22,8 @@ Pod::Spec.new do |s| s.platform = :ios s.ios.deployment_target = "15.0" - s.dependency 'ReadiumShared', '~> 3.7.0' s.dependency 'ReadiumInternal', '~> 3.7.0' + s.dependency 'ReadiumShared', '~> 3.7.0' s.dependency 'DifferenceKit', '~> 1.0' s.dependency 'SwiftSoup', '~> 2.7.0' diff --git a/Support/CocoaPods/ReadiumOPDS.podspec b/Support/CocoaPods/ReadiumOPDS.podspec index 09dc4d3c01..d4141ae288 100644 --- a/Support/CocoaPods/ReadiumOPDS.podspec +++ b/Support/CocoaPods/ReadiumOPDS.podspec @@ -1,3 +1,6 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumOPDS" @@ -14,8 +17,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumShared', '~> 3.7.0' s.dependency 'ReadiumInternal', '~> 3.7.0' + s.dependency 'ReadiumShared', '~> 3.7.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index 2ebb22d01a..bf93ae2243 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -1,28 +1,31 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| - - s.name = "ReadiumShared" - s.version = "3.7.0" - s.license = "BSD 3-Clause License" - s.summary = "Readium Shared" - s.homepage = "http://readium.github.io" - s.author = { "Readium" => "contact@readium.org" } - s.source = { :git => 'https://github.com/readium/swift-toolkit.git', :tag => s.version } - s.requires_arc = true + + s.name = "ReadiumShared" + s.version = "3.7.0" + s.license = "BSD 3-Clause License" + s.summary = "Readium Shared" + s.homepage = "http://readium.github.io" + s.author = { "Readium" => "contact@readium.org" } + s.source = { :git => "https://github.com/readium/swift-toolkit.git", :tag => s.version } + s.requires_arc = true s.resource_bundles = { - "ReadiumShared" => ["Sources/Shared/Resources/**"], + 'ReadiumShared' => ['Sources/Shared/Resources/**'], } s.source_files = "Sources/Shared/**/*.{m,h,swift}" s.swift_version = '5.10' - s.platform = :ios + s.platform = :ios s.ios.deployment_target = "15.0" - s.frameworks = "CoreServices" - s.libraries = "xml2" - s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - + s.frameworks = "CoreServices" + s.libraries = 'xml2' + s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + + s.dependency 'ReadiumInternal', '~> 3.7.0' s.dependency 'Minizip', '~> 1.0.0' s.dependency 'SwiftSoup', '~> 2.7.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' - s.dependency 'ReadiumInternal', '~> 3.7.0' end diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index bdd65139cd..57820d4349 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -1,3 +1,6 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumStreamer" @@ -18,12 +21,12 @@ Pod::Spec.new do |s| s.swift_version = '5.10' s.platform = :ios s.ios.deployment_target = "15.0" - s.libraries = 'z', 'xml2' + s.libraries = 'z', 'xml2' s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.dependency 'ReadiumFuzi', '~> 4.0.0' - s.dependency 'ReadiumShared', '~> 3.7.0' s.dependency 'ReadiumInternal', '~> 3.7.0' + s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumFuzi', '~> 4.0.0' s.dependency 'CryptoSwift', '~> 1.8.0' end diff --git a/Support/CocoaPods/Specs.swift b/Support/CocoaPods/Specs.swift new file mode 100644 index 0000000000..268fc0234c --- /dev/null +++ b/Support/CocoaPods/Specs.swift @@ -0,0 +1,145 @@ +// +// 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. +// + +/// Readium toolkit version — bump this when releasing a new version, then run `make podspecs`. +let version = "3.7.0" + +/// Minimum iOS deployment target shared by all modules. +let iosTarget = "15.0" + +/// Swift version requirement shared by all modules. +let swiftVersion = "5.10" + +// MARK: - Data model + +struct ModuleSpec { + let name: String + /// Path to source files, relative to the repo root (e.g. "Sources/Shared"). + let sourcePath: String + let summary: String + var frameworks: [String] = [] + var libraries: [String] = [] + var xcconfig: [String: String] = [:] + /// Key = bundle name, values = glob patterns relative to repo root. + var resourceBundles: [String: [String]] = [:] + var dependencies: [Dependency] = [] +} + +enum Dependency { + /// A sibling Readium pod at the same version (e.g. `~> 3.7.0`). + case readium(String) + /// An external pod with an explicit version constraint. + case pod(String, String) +} + +// MARK: - Module Definitions (ordered by podspec push order) + +let modules: [ModuleSpec] = [ + ModuleSpec( + name: "ReadiumInternal", + sourcePath: "Sources/Internal", + summary: "Private utilities used by the Readium modules", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"] + ), + ModuleSpec( + name: "ReadiumShared", + sourcePath: "Sources/Shared", + summary: "Readium Shared", + frameworks: ["CoreServices"], + libraries: ["xml2"], + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + resourceBundles: ["ReadiumShared": ["Sources/Shared/Resources/**"]], + dependencies: [ + .readium("ReadiumInternal"), + .pod("Minizip", "~> 1.0.0"), + .pod("SwiftSoup", "~> 2.7.0"), + .pod("ReadiumFuzi", "~> 4.0.0"), + .pod("ReadiumZIPFoundation", "~> 3.0.1"), + ] + ), + ModuleSpec( + name: "ReadiumStreamer", + sourcePath: "Sources/Streamer", + summary: "Readium Streamer", + libraries: ["z", "xml2"], + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + resourceBundles: ["ReadiumStreamer": [ + "Sources/Streamer/Resources/**", + "Sources/Streamer/Assets", + ]], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("ReadiumFuzi", "~> 4.0.0"), + .pod("CryptoSwift", "~> 1.8.0"), + ] + ), + ModuleSpec( + name: "ReadiumNavigator", + sourcePath: "Sources/Navigator", + summary: "Readium Navigator", + resourceBundles: ["ReadiumNavigator": [ + "Sources/Navigator/Resources/**", + "Sources/Navigator/EPUB/Assets", + ]], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("DifferenceKit", "~> 1.0"), + .pod("SwiftSoup", "~> 2.7.0"), + ] + ), + ModuleSpec( + name: "ReadiumOPDS", + sourcePath: "Sources/OPDS", + summary: "Readium OPDS", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("ReadiumFuzi", "~> 4.0.0"), + ] + ), + ModuleSpec( + name: "ReadiumLCP", + sourcePath: "Sources/LCP", + summary: "Readium LCP", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + resourceBundles: ["ReadiumLCP": [ + "Sources/LCP/Resources/**", + "Sources/LCP/**/*.xib", + ]], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("ReadiumZIPFoundation", "~> 3.0.1"), + .pod("CryptoSwift", "~> 1.8.0"), + ] + ), + ModuleSpec( + name: "ReadiumAdapterGCDWebServer", + sourcePath: "Sources/Adapters/GCDWebServer", + summary: "Adapter to use GCDWebServer as an HTTP server in Readium", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("ReadiumGCDWebServer", "~> 4.0.0"), + ] + ), + ModuleSpec( + name: "ReadiumAdapterLCPSQLite", + sourcePath: "Sources/Adapters/LCPSQLite", + summary: "Adapter to use SQLite.swift for the Readium LCP repositories", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .readium("ReadiumLCP"), + .pod("SQLite.swift", "~> 0.15.0"), + ] + ), +] From bf4faf3cbc1e92a41a095f973b91228aeb576dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 24 Feb 2026 19:01:04 +0100 Subject: [PATCH 47/55] Refactor `ReadResult` decoding for Swift 6 (#735) --- .../Content Protection/EncryptionParser.swift | 3 +- .../LCPContentProtection.swift | 3 +- .../{Streamable.swift => ReadResult.swift} | 8 +- .../HTMLResourceContentIterator.swift | 3 +- .../Services/Positions/PositionsService.swift | 3 +- Sources/Shared/Toolkit/Data/ReadError.swift | 2 - Sources/Shared/Toolkit/Data/ReadResult.swift | 111 +++++++++++++ .../Resource/ResourceContentExtractor.swift | 9 +- Sources/Shared/Toolkit/Data/Streamable.swift | 3 + .../Toolkit/Format/FormatSnifferBlob.swift | 2 +- .../Format/Sniffers/EPUBFormatSniffer.swift | 5 +- .../Format/Sniffers/RPFFormatSniffer.swift | 3 +- Sources/Shared/Toolkit/XML/XML.swift | 14 +- .../ReadiumGuidedNavigationService.swift | 3 +- .../Parser/Readium/ReadiumWebPubParser.swift | 25 +-- Support/Carthage/.xcodegen | 3 +- .../Readium.xcodeproj/project.pbxproj | 12 +- .../Publication/PublicationTests.swift | 2 +- .../Positions/PositionsServiceTests.swift | 2 +- .../Toolkit/Data/ReadResultTests.swift | 154 ++++++++++++++++++ 20 files changed, 327 insertions(+), 43 deletions(-) rename Sources/LCP/Toolkit/{Streamable.swift => ReadResult.swift} (71%) create mode 100644 Sources/Shared/Toolkit/Data/ReadResult.swift create mode 100644 Tests/SharedTests/Toolkit/Data/ReadResultTests.swift diff --git a/Sources/LCP/Content Protection/EncryptionParser.swift b/Sources/LCP/Content Protection/EncryptionParser.swift index a6ee2fddf4..20868edba2 100644 --- a/Sources/LCP/Content Protection/EncryptionParser.swift +++ b/Sources/LCP/Content Protection/EncryptionParser.swift @@ -21,7 +21,8 @@ private func parseRPFEncryptionData(in container: Container) async -> ReadResult } return await manifestResource - .readAsJSONObject() + .read() + .asJSONObject() .flatMap { json in do { return try .success(Manifest(json: json)) diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index add52e9569..1f57f4a4f3 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -53,7 +53,8 @@ final class LCPContentProtection: ContentProtection, Loggable { return .failure(.assetNotSupported(DebugError("The asset does not appear to be an LCP License"))) } - return await asset.resource.readAsLCPL() + return await asset.resource.read() + .asLCPL() .mapError { .reading($0) } .asyncFlatMap { licenseDocument in await assetRetriever.retrieve(link: licenseDocument.publicationLink) diff --git a/Sources/LCP/Toolkit/Streamable.swift b/Sources/LCP/Toolkit/ReadResult.swift similarity index 71% rename from Sources/LCP/Toolkit/Streamable.swift rename to Sources/LCP/Toolkit/ReadResult.swift index db0eb2f5bb..7107631acb 100644 --- a/Sources/LCP/Toolkit/Streamable.swift +++ b/Sources/LCP/Toolkit/ReadResult.swift @@ -7,10 +7,10 @@ import Foundation import ReadiumShared -extension Streamable { - /// Reads the whole content as a LCP License Document. - func readAsLCPL() async -> ReadResult { - await read().flatMap { data in +extension ReadResult { + /// Decodes the data as a LCP License Document. + func asLCPL() -> ReadResult { + flatMap { data in do { return try .success(LicenseDocument(data: data)) } catch { diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift index 7430628381..34bc82eafe 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift @@ -99,7 +99,8 @@ public class HTMLResourceContentIterator: ContentIterator { private lazy var elementsTask = Task { await resource - .readAsString() + .read() + .asString() .eraseToAnyError() .tryMap { try SwiftSoup.parse($0) } .tryMap { try parse(document: $0, locator: locator, beforeMaxLength: beforeMaxLength) } diff --git a/Sources/Shared/Publication/Services/Positions/PositionsService.swift b/Sources/Shared/Publication/Services/Positions/PositionsService.swift index cdbba9e563..0c7ace733c 100644 --- a/Sources/Shared/Publication/Services/Positions/PositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PositionsService.swift @@ -105,7 +105,8 @@ public extension Publication { private func positionsFromManifest() async -> ReadResult<[Locator]> { await links.firstWithMediaType(.readiumPositions) .flatMap { get($0) }? - .readAsJSONObject() + .read() + .asJSONObject() .map { [Locator](json: $0["positions"]) } ?? .success([]) } diff --git a/Sources/Shared/Toolkit/Data/ReadError.swift b/Sources/Shared/Toolkit/Data/ReadError.swift index d3372842e7..3e0052a5b9 100644 --- a/Sources/Shared/Toolkit/Data/ReadError.swift +++ b/Sources/Shared/Toolkit/Data/ReadError.swift @@ -6,8 +6,6 @@ import Foundation -public typealias ReadResult = Result - /// Errors occurring while reading a resource. public enum ReadError: Error { /// An error occurred while trying to access the content. diff --git a/Sources/Shared/Toolkit/Data/ReadResult.swift b/Sources/Shared/Toolkit/Data/ReadResult.swift new file mode 100644 index 0000000000..18dbb0f625 --- /dev/null +++ b/Sources/Shared/Toolkit/Data/ReadResult.swift @@ -0,0 +1,111 @@ +// +// 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 + +public typealias ReadResult = Result + +public extension ReadResult { + /// Decodes the data as a `T` using the given `decoder`. + /// + /// - Returns: The decoded `T`, or a `ReadError.decoding` error. + func decode(_ decoder: (Data) throws -> T) -> ReadResult { + flatMap { data in + do { + return try .success(decoder(data)) + } catch { + return .failure(.decoding(error)) + } + } + } + + /// Decodes the data as a `String`. + func asString(encoding: String.Encoding = .utf8) -> ReadResult { + decode { try $0.asString(encoding: encoding) } + } + + /// Decodes the data as a JSON value. + func asJSON(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + decode { try $0.asJSON(options: options) } + } + + /// Decodes the data as a JSON object. + func asJSONObject(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: Any]> { + asJSON(options: options) + } + + /// Decodes the data as an XML document. + func asXML(using factory: XMLDocumentFactory, namespaces: [XMLNamespace] = []) -> ReadResult { + decode { try $0.asXML(using: factory, namespaces: namespaces) } + } +} + +public extension ReadResult { + /// Decodes the data as a `T` using the given `decoder`. + /// + /// - Returns: `nil` if the data is absent, the decoded `T` if data is + /// present, or a `ReadError.decoding` error if decoding fails. + func decode(_ decoder: (Data) throws -> T) -> ReadResult { + flatMap { data in + guard let data = data else { + return .success(nil) + } + do { + return try .success(decoder(data)) + } catch { + return .failure(.decoding(error)) + } + } + } + + /// Decodes the data as a `String`. + func asString(encoding: String.Encoding = .utf8) -> ReadResult { + decode { try $0.asString(encoding: encoding) } + } + + /// Decodes the data as a JSON value. + func asJSON(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + decode { try $0.asJSON(options: options) } + } + + /// Decodes the data as a JSON object. + func asJSONObject(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: Any]?> { + asJSON(options: options) + } + + /// Decodes the data as an XML document. + func asXML(using factory: XMLDocumentFactory, namespaces: [XMLNamespace] = []) -> ReadResult { + decode { try $0.asXML(using: factory, namespaces: namespaces) } + } +} + +private extension Data { + /// Decodes the data as a `String`. + func asString(encoding: String.Encoding = .utf8) throws -> String { + guard let string = String(data: self, encoding: encoding) else { + throw DebugError("Not a valid \(encoding) string") + } + return string + } + + /// Decodes the data as a JSON value. + func asJSON(options: JSONSerialization.ReadingOptions = []) throws -> T { + guard let json = try JSONSerialization.jsonObject(with: self, options: options) as? T else { + throw JSONError.parsing(T.self) + } + return json + } + + /// Decodes the data as a JSON object. + func asJSONObject(options: JSONSerialization.ReadingOptions = []) throws -> [String: Any] { + try asJSON(options: options) + } + + /// Decodes the data as an XML document. + func asXML(using factory: XMLDocumentFactory, namespaces: [XMLNamespace] = []) throws -> XMLDocument { + try factory.open(data: self, namespaces: namespaces) + } +} diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift index 0b9c6b3f28..bf91db37cf 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift @@ -46,11 +46,12 @@ class _HTMLResourceContentExtractor: _ResourceContentExtractor { private let xmlFactory = DefaultXMLDocumentFactory() func extractText(of resource: Resource) async -> ReadResult { - await resource.readAsString() + await resource.read() + .asString() .asyncFlatMap { content in do { // First try to parse a valid XML document, then fallback on SwiftSoup, which is slower. - var text = await parse(xml: content) + var text = parse(xml: content) ?? parse(html: content) ?? "" @@ -69,8 +70,8 @@ class _HTMLResourceContentExtractor: _ResourceContentExtractor { /// /// This is much more efficient than using SwiftSoup, but will fail when encountering /// invalid HTML documents. - private func parse(xml: String) async -> String? { - guard let document = try? await xmlFactory.open(string: xml, namespaces: [.xhtml]) else { + private func parse(xml: String) -> String? { + guard let document = try? xmlFactory.open(string: xml, namespaces: [.xhtml]) else { return nil } diff --git a/Sources/Shared/Toolkit/Data/Streamable.swift b/Sources/Shared/Toolkit/Data/Streamable.swift index b3df3d3905..d21decb2ff 100644 --- a/Sources/Shared/Toolkit/Data/Streamable.swift +++ b/Sources/Shared/Toolkit/Data/Streamable.swift @@ -56,6 +56,7 @@ public extension Streamable { } /// Reads the whole content as a `String`. + @available(*, deprecated, message: "Use `read().asString()` instead") func readAsString(encoding: String.Encoding = .utf8) async -> ReadResult { await read().flatMap { guard let string = String(data: $0, encoding: encoding) else { @@ -66,6 +67,7 @@ public extension Streamable { } /// Reads the whole content as a JSON value. + @available(*, deprecated, message: "Use `read().asJSON()` instead") func readAsJSON(options: JSONSerialization.ReadingOptions = []) async -> ReadResult { await read().flatMap { do { @@ -80,6 +82,7 @@ public extension Streamable { } /// Reads the whole content as a JSON object. + @available(*, deprecated, message: "Use `read().asJSONObject()` instead") func readAsJSONObject(options: JSONSerialization.ReadingOptions = []) async -> ReadResult<[String: Any]> { await readAsJSON() } diff --git a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift index 3da2c94f10..bf8a474049 100644 --- a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift +++ b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift @@ -74,7 +74,7 @@ public actor FormatSnifferBlob { if xml == nil { xml = await read().asyncMap { await $0.asyncFlatMap { - try? await xmlDocumentFactory.open(data: $0, namespaces: []) + try? xmlDocumentFactory.open(data: $0, namespaces: []) } } } diff --git a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift index 617c670af0..5aa775bace 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift @@ -36,7 +36,8 @@ public struct EPUBFormatSniffer: FormatSniffer { return .success(nil) } - return await resource.readAsString() + return await resource.read() + .asString() .asyncFlatMap { mimetype in if MediaType.epub.matches(MediaType(mimetype.trimmingCharacters(in: .whitespacesAndNewlines))) { var format = format @@ -66,7 +67,7 @@ public struct EPUBFormatSniffer: FormatSniffer { } return await resource.read() - .asyncMap { try? await xmlDocumentFactory.open(data: $0, namespaces: []) } + .asyncMap { try? xmlDocumentFactory.open(data: $0, namespaces: []) } .map { document in guard let document = document else { return format diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift index 0a65324db0..64b280a1b6 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift @@ -31,7 +31,8 @@ public struct RPFFormatSniffer: FormatSniffer { return .success(nil) } - return await resource.readAsJSONObject() + return await resource.read() + .asJSONObject() .map { guard let manifest = try? Manifest(json: $0) else { return nil diff --git a/Sources/Shared/Toolkit/XML/XML.swift b/Sources/Shared/Toolkit/XML/XML.swift index 81f6f33045..b4827080b1 100644 --- a/Sources/Shared/Toolkit/XML/XML.swift +++ b/Sources/Shared/Toolkit/XML/XML.swift @@ -79,27 +79,29 @@ public protocol XMLDocumentFactory { /// Opens an XML document from its raw data content. /// /// - Parameter namespaces: List of namespace prefixes to declare in the document. - func open(data: Data, namespaces: [XMLNamespace]) async throws -> XMLDocument + func open(data: Data, namespaces: [XMLNamespace]) throws -> XMLDocument /// Opens an XML document from its raw string content. /// /// - Parameter namespaces: List of namespace prefixes to declare in the document. - func open(string: String, namespaces: [XMLNamespace]) async throws -> XMLDocument + func open(string: String, namespaces: [XMLNamespace]) throws -> XMLDocument } public class DefaultXMLDocumentFactory: XMLDocumentFactory, Loggable { public init() {} public func open(file: FileURL, namespaces: [XMLNamespace]) async throws -> XMLDocument { - warnIfMainThread() - return try await open(string: String(contentsOf: file.url), namespaces: namespaces) + let string = try await Task.detached(priority: Task.currentPriority) { + try String(contentsOf: file.url) + }.value + return try open(string: string, namespaces: namespaces) } - public func open(string: String, namespaces: [XMLNamespace]) async throws -> XMLDocument { + public func open(string: String, namespaces: [XMLNamespace]) throws -> XMLDocument { try FuziXMLDocument(string: string, namespaces: namespaces) } - public func open(data: Data, namespaces: [XMLNamespace]) async throws -> XMLDocument { + public func open(data: Data, namespaces: [XMLNamespace]) throws -> XMLDocument { try FuziXMLDocument(data: data, namespaces: namespaces) } } diff --git a/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift b/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift index 5da1d4d4f1..56797167d7 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift @@ -73,7 +73,8 @@ actor ReadiumGuidedNavigationService: GuidedNavigationService { return .failure(.decoding("Guided Navigation Document not found at \(gnURL)")) } - return await resource.readAsJSONObject() + return await resource.read() + .asJSONObject() .flatMap { json in do { return try .success(GuidedNavigationDocument(json: json)) diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index bad35d4c06..48a3c0977d 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -53,7 +53,8 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { return .failure(.formatNotSupported) } - return await resource.readAsRWPM(warnings: warnings) + return await resource.read() + .asRWPM(warnings: warnings) .flatMap { manifest in let baseURL = manifest.baseURL if baseURL == nil { @@ -98,7 +99,8 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { return .failure(.reading(.decoding("Cannot find a manifest.json file in the RPF package."))) } - return await manifestResource.readAsRWPM(warnings: warnings) + return await manifestResource.read() + .asRWPM(warnings: warnings) .flatMap(checkProfileRequirements(of:)) .map { manifest in var manifest = manifest @@ -163,16 +165,17 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { } } -private extension Streamable { - /// Reads the whole content as a Readium Web Pub Manifest. - func readAsRWPM(warnings: WarningLogger?) async -> ReadResult { - await readAsJSON().flatMap { - do { - return try .success(Manifest(json: $0, warnings: warnings)) - } catch { - return .failure(.decoding(error)) +private extension ReadResult { + /// Decodes the data as a Readium Web Pub Manifest. + func asRWPM(warnings: WarningLogger?) -> ReadResult { + asJSONObject() + .flatMap { data in + do { + return try .success(Manifest(json: data, warnings: warnings)) + } catch { + return .failure(.decoding(error)) + } } - } } } diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 59511fbcf6..c36b6312c0 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -396,7 +396,7 @@ ../../Sources/LCP/Toolkit/Bundle.swift ../../Sources/LCP/Toolkit/DataCompression.swift ../../Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift -../../Sources/LCP/Toolkit/Streamable.swift +../../Sources/LCP/Toolkit/ReadResult.swift ../../Sources/Navigator ../../Sources/Navigator/Audiobook ../../Sources/Navigator/Audiobook/AudioNavigator.swift @@ -718,6 +718,7 @@ ../../Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift ../../Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift ../../Sources/Shared/Toolkit/Data/ReadError.swift +../../Sources/Shared/Toolkit/Data/ReadResult.swift ../../Sources/Shared/Toolkit/Data/Resource ../../Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift ../../Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 477db5d9de..14cdf8562b 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -113,7 +113,6 @@ 3BB313823F043BA2C7D7D2F7 /* Locator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7D07E66B7E820D1A509A27 /* Locator.swift */; }; 3C4847FD7D5C5ABCF71A3E7B /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388D85FB7475709CB6CEA59E /* URL.swift */; }; 3CAF13341C4AFE25CBB7B116 /* PDFPreferencesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B80A1477F527C0B2142005E /* PDFPreferencesEditor.swift */; }; - 3D594DCB0A9FA1F50E4B69B3 /* Streamable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739566E777BA37891BCECB95 /* Streamable.swift */; }; 3E195F4601612E7B4B9CB232 /* DirectoryContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48435C1A16C23C5BBB9C590C /* DirectoryContainer.swift */; }; 3E9F244ACDA938D330B9EAEA /* Subject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98CD4C99103DC795E44F56AE /* Subject.swift */; }; 3ECB25D3B226C7059D4A922A /* InputObservableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FF49B3B34628DC67CF8255 /* InputObservableViewController.swift */; }; @@ -349,6 +348,7 @@ D5AF26F18E98CEF06AEC0329 /* SQLiteLCPLicenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17ACAC3E8F61DA108DCC9F51 /* SQLiteLCPLicenseRepository.swift */; }; D65BEF9DAF1FEB1A5BEED700 /* EPUBParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C234075C7F7573BA54B77D /* EPUBParser.swift */; }; D7B3500D49A188A0D6B5E7CA /* Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A5B12925813FB40C41034 /* Presentation.swift */; }; + D7B6E14061A795365DE89E80 /* ReadResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28C40377AC93BCB71B0C08A6 /* ReadResult.swift */; }; D7FB0CC13190A17DAB7D7DB1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 866AEA533E1F119928F17990 /* Localizable.strings */; }; D81ECD34F39E58E30E7E13B2 /* PublicationCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0A149FC97C747F55F6463C /* PublicationCollection.swift */; }; D84BF71D6840FE62D7701073 /* JSONFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8A0C50FD8E808C7A9F4D87 /* JSONFormatSniffer.swift */; }; @@ -360,6 +360,7 @@ DD04CA793E06BBAD6A75329F /* EPUBPreferences+Legacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF31AEFB5FF0E7892C6D903E /* EPUBPreferences+Legacy.swift */; }; DD8E2E0D394399A51F295380 /* Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF92954C8C8C3EC50C835CBA /* Link.swift */; }; DDD0C8AC27EF8D1A893DF6CC /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529B55BE6996FCDC1082BF0A /* JSON.swift */; }; + DEC70D5DEEB5AE9F5A4183E4 /* ReadResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A10B4EF2A391FCE83C3FBCC /* ReadResult.swift */; }; DEF1AA526DDAF2D5EA3A6594 /* FileURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA3AA272772FCF7D6268A74 /* FileURL.swift */; }; DEF375FF574461E670FF45B9 /* ProgressionStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4C26FFA10656866E167F4 /* ProgressionStrategy.swift */; }; DEF5B692764428697150AA8A /* XMLNamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 364F18D0F750A1D98A381654 /* XMLNamespace.swift */; }; @@ -564,6 +565,7 @@ 281F650E9B9CEE601D8125EE /* Metadata+EPUB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+EPUB.swift"; sourceTree = ""; }; 2828D89EBB52CCA782ED1146 /* ReadiumFuzi.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReadiumFuzi.xcframework; path = ../../Carthage/Build/ReadiumFuzi.xcframework; sourceTree = ""; }; 28792F801221D49F61B92CF8 /* TDM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDM.swift; sourceTree = ""; }; + 28C40377AC93BCB71B0C08A6 /* ReadResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadResult.swift; sourceTree = ""; }; 294E01A2E6FF25539EBC1082 /* Properties+Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Archive.swift"; sourceTree = ""; }; 29AD63CD2A41586290547212 /* NavigationDocumentParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationDocumentParser.swift; sourceTree = ""; }; 2AF56CF04F94B7BE45631897 /* LCPContentProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPContentProtection.swift; sourceTree = ""; }; @@ -675,7 +677,6 @@ 714F1696AC76F6AFEA1924D5 /* NSRegularExpression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSRegularExpression.swift; sourceTree = ""; }; 7214B2366A4E024517FF8C76 /* HTTPRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequest.swift; sourceTree = ""; }; 72922E22040CEFB3B7BBCDAF /* LoggerStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerStub.swift; sourceTree = ""; }; - 739566E777BA37891BCECB95 /* Streamable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Streamable.swift; sourceTree = ""; }; 74F646B746EB27124F9456F8 /* ReadingProgression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgression.swift; sourceTree = ""; }; 761D7DFCF307078B7283A14E /* TextTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextTokenizer.swift; sourceTree = ""; }; 76638D3D1220E4C2620B9A80 /* Properties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Properties.swift; sourceTree = ""; }; @@ -688,6 +689,7 @@ 78FFDF8CF77437EDB41E4547 /* FailureResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailureResource.swift; sourceTree = ""; }; 791CEAC3DA5ED971DAE984CB /* LCPObservableAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPObservableAuthentication.swift; sourceTree = ""; }; 79CE7AFF3ECCD705A80685BB /* MinizipArchiveOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinizipArchiveOpener.swift; sourceTree = ""; }; + 7A10B4EF2A391FCE83C3FBCC /* ReadResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadResult.swift; sourceTree = ""; }; 7BB152578CBA091A41A51B25 /* Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Language.swift; sourceTree = ""; }; 7BBD54FD376456C1925316BC /* Cancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cancellable.swift; sourceTree = ""; }; 7C2787EBE9D5565DA8593711 /* Properties+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Presentation.swift"; sourceTree = ""; }; @@ -1044,6 +1046,7 @@ isa = PBXGroup; children = ( C0453DB464236F684621BEA7 /* ReadError.swift */, + 7A10B4EF2A391FCE83C3FBCC /* ReadResult.swift */, 622CB8B75A568846FECA44D6 /* Streamable.swift */, 582C2D4E376AE5490527B94F /* Asset */, 96BD1423AB18BDEEC9A0388F /* Container */, @@ -1822,7 +1825,7 @@ F64FBE3CA5C1B0C73A22E86D /* Bundle.swift */, 1EBC685D4A0E07997088DD2D /* DataCompression.swift */, 9883F57707AC488197F4312E /* ReadiumLCPLocalizedString.swift */, - 739566E777BA37891BCECB95 /* Streamable.swift */, + 28C40377AC93BCB71B0C08A6 /* ReadResult.swift */, ); path = Toolkit; sourceTree = ""; @@ -2649,12 +2652,12 @@ 92570B878B678E9E9138C94F /* Links.swift in Sources */, 2207C27B96F098AAF8B31F2C /* PassphrasesService.swift in Sources */, BAC8616BD37C22BC5541959A /* PotentialRights.swift in Sources */, + D7B6E14061A795365DE89E80 /* ReadResult.swift in Sources */, 969961137E590BAEFBEB9CAB /* ReadiumLCPLocalizedString.swift in Sources */, AADE9BC2642DEAD9B2936FB6 /* ResourceLicenseContainer.swift in Sources */, 6FEE606C7126F68B5018CAD0 /* Rights.swift in Sources */, 21B27CD89562506DDC1D62D1 /* Signature.swift in Sources */, 077AD829863BD952DEBFB5A0 /* StatusDocument.swift in Sources */, - 3D594DCB0A9FA1F50E4B69B3 /* Streamable.swift in Sources */, 18217BC157557A5DDA4BA119 /* User.swift in Sources */, 69AA254E4A39D9B49FDFD648 /* UserKey.swift in Sources */, ); @@ -2815,6 +2818,7 @@ 5A9AEC4A9AE686ED74E9B8BF /* RWPMFormatSniffer.swift in Sources */, ED67A0EFAE830F72846BF9C0 /* Range.swift in Sources */, A6136BE75CC1F1A78A5021E2 /* ReadError.swift in Sources */, + DEC70D5DEEB5AE9F5A4183E4 /* ReadResult.swift in Sources */, B676871C6BC2D08EC8279B8D /* ReadingProgression.swift in Sources */, 3AD9E86BB1621CF836919E33 /* ReadiumLocalizedString.swift in Sources */, 31909E8E0CB313AA7C390762 /* RelativeURL.swift in Sources */, diff --git a/Tests/SharedTests/Publication/PublicationTests.swift b/Tests/SharedTests/Publication/PublicationTests.swift index b131568908..efbeef12ba 100644 --- a/Tests/SharedTests/Publication/PublicationTests.swift +++ b/Tests/SharedTests/Publication/PublicationTests.swift @@ -241,7 +241,7 @@ class PublicationTests: XCTestCase { container: SingleResourceContainer(resource: DataResource(string: "hello"), at: link.url()) ) - let result = try await publication.get(link)?.readAsString().get() + let result = try await publication.get(link)?.read().asString().get() XCTAssertEqual(result, "hello") } diff --git a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift index 6a795bfe39..66e4c27525 100644 --- a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift @@ -125,7 +125,7 @@ class PositionsServiceTests: XCTestCase { let resource = try service.get(XCTUnwrap(AnyURL(string: "~readium/positions"))) - let result = try await resource?.readAsString().get() + let result = try await resource?.read().asString().get() XCTAssertEqual( result, """ diff --git a/Tests/SharedTests/Toolkit/Data/ReadResultTests.swift b/Tests/SharedTests/Toolkit/Data/ReadResultTests.swift new file mode 100644 index 0000000000..354afdacec --- /dev/null +++ b/Tests/SharedTests/Toolkit/Data/ReadResultTests.swift @@ -0,0 +1,154 @@ +// +// 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 +@testable import ReadiumShared +import Testing + +@Suite enum ReadResultDataTests { + static let accessError: ReadError = .access(.fileSystem(.fileNotFound(nil))) + + @Suite("decode") struct Decode { + @Test("success") func success() { + let result: ReadResult = .success(Data([0x41, 0x42])) + let decoded: ReadResult = result.decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == .success("AB")) + } + + @Test("decoding failure wraps in ReadError.decoding") + func decodingFailure() { + let result: ReadResult = .success(Data([0xFF])) + let decoded: ReadResult = result.decode { _ in throw DebugError("bad") } + guard case .failure(.decoding) = decoded else { + Issue.record("Expected ReadError.decoding, got \(decoded)") + return + } + } + + @Test("read error is preserved unchanged") + func readErrorPreserved() { + let decoded: ReadResult = accessError.asResult().decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == accessError.asResult()) + } + } + + @Suite("asString") struct AsString { + @Test("UTF-8 success") func utf8() throws { + let result: ReadResult = try .success(#require("hello".data(using: .utf8))) + #expect(result.asString() == .success("hello")) + } + + @Test("custom encoding") func customEncoding() throws { + let result: ReadResult = try .success(#require("café".data(using: .isoLatin1))) + #expect(result.asString(encoding: .isoLatin1) == .success("café")) + } + + @Test("invalid encoding produces ReadError.decoding") + func invalidEncoding() { + // 0x80 alone is invalid UTF-8 + let result: ReadResult = .success(Data([0x80])) + guard case .failure(.decoding) = result.asString() else { + Issue.record("Expected ReadError.decoding for invalid UTF-8") + return + } + } + } + + @Suite("asJSONObject") struct AsJSONObject { + @Test("valid JSON object") func valid() throws { + let result: ReadResult = try .success(#require(#"{"key":"value"}"#.data(using: .utf8))) + let decoded: ReadResult<[String: Any]> = result.asJSONObject() + #expect(try decoded.get()["key"] as? String == "value") + } + + @Test("invalid JSON produces ReadError.decoding") + func invalidJSON() throws { + let result: ReadResult = try .success(#require("not json".data(using: .utf8))) + let decoded: ReadResult<[String: Any]> = result.asJSONObject() + guard case .failure(.decoding) = decoded else { + Issue.record("Expected ReadError.decoding for invalid JSON") + return + } + } + + @Test("JSON array root produces ReadError.decoding") + func wrongType() throws { + let result: ReadResult = try .success(#require("[1,2,3]".data(using: .utf8))) + let decoded: ReadResult<[String: Any]> = result.asJSONObject() + guard case .failure(.decoding) = decoded else { + Issue.record("Expected ReadError.decoding when JSON root is not an object") + return + } + } + } +} + +@Suite enum ReadResultOptionalDataTests { + static let accessError: ReadError = .access(.fileSystem(.fileNotFound(nil))) + + @Suite("decode") struct Decode { + @Test("nil data passes through as success(nil)") + func nilPassthrough() { + let result: ReadResult = .success(nil) + let decoded: ReadResult = result.decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == .success(nil)) + } + + @Test("present data is decoded") func dataPresent() { + let result: ReadResult = .success("hello".data(using: .utf8)) + let decoded: ReadResult = result.decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == .success("hello")) + } + + @Test("decoding failure wraps in ReadError.decoding") + func decodingFailure() { + let result: ReadResult = .success(Data([0xFF])) + let decoded: ReadResult = result.decode { _ in throw DebugError("bad") } + guard case .failure(.decoding) = decoded else { + Issue.record("Expected ReadError.decoding, got \(decoded)") + return + } + } + + @Test("read error is preserved unchanged") + func readErrorPreserved() { + let decoded: ReadResult = accessError.asResult().decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == accessError.asResult()) + } + } + + @Suite("asString") struct AsString { + @Test("nil passthrough") func nilPassthrough() { + let result: ReadResult = .success(nil) + #expect(result.asString() == .success(nil)) + } + + @Test("present data is decoded") func dataPresent() { + let result: ReadResult = .success("world".data(using: .utf8)) + #expect(result.asString() == .success("world")) + } + } + + @Suite("asJSONObject") struct AsJSONObject { + @Test("nil passthrough") func nilPassthrough() throws { + let result: ReadResult = .success(nil) + let decoded: ReadResult<[String: Any]?> = result.asJSONObject() + #expect(try decoded.get() == nil) + } + + @Test("present data is decoded") func dataPresent() throws { + let result: ReadResult = .success(#"{"k":1}"#.data(using: .utf8)) + let decoded: ReadResult<[String: Any]?> = result.asJSONObject() + #expect(try decoded.get()?["k"] as? Int == 1) + } + } +} + +private extension ReadError { + func asResult() -> ReadResult { + .failure(self) + } +} From 3878f7d9edd98c703bef4450ccc6449d03722ec6 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:53:20 -0600 Subject: [PATCH 48/55] Create DocC based documentation site (#716) --- .github/workflows/docs.yml | 78 +++++++ .gitignore | 4 + BuildTools/Scripts/generate-docs.sh | 210 ++++++++++++++++++ Package.swift | 1 + .../Adapters/GCDWebServer/GCDHTTPServer.swift | 4 +- .../SQLiteLCPLicenseRepository.swift | 2 +- Sources/Internal/JSON.swift | 4 +- .../Authentications/LCPAuthenticating.swift | 4 +- Sources/LCP/LCPLicense.swift | 6 +- Sources/LCP/LCPService.swift | 23 +- .../Decorator/DecorableNavigator.swift | 4 +- .../EPUB/CSS/HTMLFontFamilyDeclaration.swift | 6 +- Sources/Navigator/Input/InputObservable.swift | 10 +- .../Input/InputObservableViewController.swift | 2 +- Sources/Navigator/VisualNavigator.swift | 14 +- Sources/OPDS/OPDS1Parser.swift | 17 +- Sources/OPDS/OPDS2Parser.swift | 12 +- Sources/OPDS/OPDSParser.swift | 5 +- Sources/Shared/Logger/Logger.swift | 5 +- .../AccessibilityMetadataDisplayGuide.swift | 2 +- .../Services/Content/ContentTokenizer.swift | 6 +- .../Data/Resource/BufferingResource.swift | 4 +- .../Sniffers/DefaultFormatSniffer.swift | 6 +- .../Toolkit/HTTP/DefaultHTTPClient.swift | 3 + Sources/Shared/Toolkit/URL/AnyURL.swift | 2 +- Sources/Shared/Toolkit/URL/RelativeURL.swift | 2 +- Sources/Shared/Toolkit/URL/URLProtocol.swift | 4 +- Sources/Shared/Toolkit/XML/XML.swift | 15 +- .../Streamer/Parser/PublicationParser.swift | 2 +- .../Parser/Readium/ReadiumWebPubParser.swift | 9 +- Sources/Streamer/PublicationOpener.swift | 16 +- docs/NavigatorOverview.md | 17 ++ docs/Readium.md | 28 +++ 33 files changed, 461 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100755 BuildTools/Scripts/generate-docs.sh create mode 100644 docs/NavigatorOverview.md create mode 100644 docs/Readium.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..7de4ca71b4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,78 @@ +name: Documentation + +on: + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: write + +# Allow only one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build-and-deploy: + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Determine Version + id: versioning + run: | + git fetch --tags --force + VERSION=$(git describe --tag --match "[0-9]*" --abbrev=0) + echo "READIUM_VERSION=$VERSION" >> $GITHUB_OUTPUT + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "folder=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + else + echo "folder=latest" >> $GITHUB_OUTPUT + fi + + - name: Generate Documentation + run: | + chmod +x BuildTools/Scripts/generate-docs.sh + ./BuildTools/Scripts/generate-docs.sh ${{ steps.versioning.outputs.READIUM_VERSION }} + ./BuildTools/Scripts/generate-docs.sh latest + + - name: Setup Root Redirect + run: | + cat < docs-site/swift-toolkit/index.html + + + + + + + +

Redirecting to latest documentation...

+ + EOF + + - name: Deploy Versioned Folder 🚀 + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: docs-site/swift-toolkit/${{ steps.versioning.outputs.READIUM_VERSION }} + target-folder: ${{ steps.versioning.outputs.READIUM_VERSION }} + clean: true + + - name: Deploy Latest Folder 🚀 + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: docs-site/swift-toolkit/latest + target-folder: latest + clean: true + + - name: Deploy Root Redirect 🚀 + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: docs-site/swift-toolkit + target-folder: . + clean: false diff --git a/.gitignore b/.gitignore index 36ed6959c8..99e5a89a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ out/ ## Claude Code .claude CLAUDE.md + +# DocC generation +.build-docs +docs-site diff --git a/BuildTools/Scripts/generate-docs.sh b/BuildTools/Scripts/generate-docs.sh new file mode 100755 index 0000000000..5fc3a65de9 --- /dev/null +++ b/BuildTools/Scripts/generate-docs.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# ============================================================================= +# Readium Swift Toolkit - Documentation Generator +# ============================================================================= +# This script automates the creation of a static DocC documentation site. +# It handles: +# 1. Cross-compiling the Swift package for the iOS Simulator. +# 2. Generating symbol graphs (API metadata) for all modules. +# 3. filtering out 3rd-party dependencies from the docs. +# 4. Assembling the DocC catalog from the 'docs/' folder. +# 5. Converting everything into a static HTML website. +# ============================================================================= + +set -e # Exit immediately if any command exits with a non-zero status. + +# ----------------------------------------------------------------------------- +# 1. Configuration +# ----------------------------------------------------------------------------- +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" +cd "$PROJECT_ROOT" + +echo "📂 Working directory set to: $(pwd)" + +DOC_VERSION="${1:-latest}" +REPO_NAME="swift-toolkit" +# The final folder where the static HTML site will be generated. +OUTPUT_ROOT="docs-site" +# The site inside is nested in a folder matching the repo name. +# This emulates GitHub Pages URL structure (e.g., username.github.io/swift-toolkit/). +SITE_DIR="$OUTPUT_ROOT/$REPO_NAME/$DOC_VERSION" +# A temporary directory for intermediate build artifacts. +TEMP_DIR=".build-docs" +# The location of the "virtual" DocC catalog. +DOCC_CATALOG_DIR="$TEMP_DIR/Readium.docc" +# Where SwiftPM will dump the raw symbol graph JSON files. +SYMBOL_GRAPHS_DIR="$TEMP_DIR/symbol-graphs" + +# ----------------------------------------------------------------------------- +# 2. Argument Parsing +# ----------------------------------------------------------------------------- +SERVE_SITE=false +for arg in "$@"; do + if [ "$arg" == "--serve" ]; then + SERVE_SITE=true + fi +done + +# ----------------------------------------------------------------------------- +# 3. Cleanup +# ----------------------------------------------------------------------------- +# Remove previous outputs to ensure a clean build. +# This prevents stale files or old symbols from appearing in the new site. +rm -rf "$SITE_DIR" +mkdir -p "$DOCC_CATALOG_DIR" +mkdir -p "$SYMBOL_GRAPHS_DIR" +mkdir -p "$SITE_DIR" + +echo "⚙️ Configuring build environment..." + +# ----------------------------------------------------------------------------- +# 4. Environment Setup (Cross-Compilation) +# ----------------------------------------------------------------------------- +# DocC requires a build to generate symbol graphs. +# Because this project imports 'UIKit', it CANNOT be built with macOS. +# It must cross-compile with the iOS Simulator. + +# Find the path to the iOS Simulator SDK on the current machine. +SDK_PATH=$(xcrun --sdk iphonesimulator --show-sdk-path) + +# Determine the host architecture (arm64 for Apple Silicon, x86_64 for Intel) +# and construct a target triple for the compiler (e.g., arm64-apple-ios15.0-simulator). +HOST_ARCH=$(uname -m) +TARGET_TRIPLE="${HOST_ARCH}-apple-ios15.0-simulator" + +echo " • SDK: $SDK_PATH" +echo " • Target: $TARGET_TRIPLE" + +# ----------------------------------------------------------------------------- +# 5. Build & Symbol Generation +# ----------------------------------------------------------------------------- +echo "🔧 Patching Package.swift for macOS compatibility..." +# Define a cleanup function to restore the original file on exit/error +restore_package() { + if [ -f Package.swift.orig ]; then + mv Package.swift.orig Package.swift + fi +} +trap restore_package EXIT + +# Back up the original file +cp Package.swift Package.swift.orig + +# Inject .macOS(.v11) into the platforms array +# This satisfies the dependency graph validation for ReadiumZIPFoundation +sed -i '' 's/\(\.iOS("[^"]*")\)]/\1, .macOS(.v11)]/' Package.swift + +echo "🧹 Cleaning build artifacts..." +# Delete the .build folder to force SwiftPM to re-emit symbol graphs. +# If this isn't done, incremental builds might skip the documentation step. +rm -rf .build + +echo "⚙️ Building symbol graphs..." +# Run 'swift build' with specific flags: +# --sdk / --triple: Forces the build to target iOS Simulator (enabling UIKit). +# -Xswiftc -emit-symbol-graph: Tells the Swift compiler to generate documentation data. +# -Xswiftc -emit-symbol-graph-dir: Tells it where to save the .symbols.json files. +swift build \ + --sdk "$SDK_PATH" \ + --triple "$TARGET_TRIPLE" \ + -Xswiftc -emit-symbol-graph \ + -Xswiftc -emit-symbol-graph-dir -Xswiftc "$SYMBOL_GRAPHS_DIR" + +# ----------------------------------------------------------------------------- +# 6. Filter Dependencies +# ----------------------------------------------------------------------------- +echo "🧹 Filtering dependencies..." +# SwiftPM generates documentation for EVERYTHING in the dependency graph. +# Only Readium modules go in the sidebar. +# Find all .symbols.json files that do NOT start with "Readium" and delete them. +find "$SYMBOL_GRAPHS_DIR" -type f -name "*.symbols.json" ! -name "Readium*" -delete + +# ----------------------------------------------------------------------------- +# 7. Prepare Documentation Catalog +# ----------------------------------------------------------------------------- +echo "📄 Preparing documentation catalog..." + +# We create a temporary DocC bundle structure +# and copy the contents of the 'docs' folder into it. +if [ -d "docs" ]; then + cp -R docs/* "$DOCC_CATALOG_DIR/" +else + echo "⚠️ Warning: 'docs' folder not found. Site may be empty." +fi + +# Validation: Ensure the root landing page exists. +# Without this file, DocC will fail or produce an empty root. +if [ ! -f "$DOCC_CATALOG_DIR/Readium.md" ]; then + echo "❌ Error: docs/Readium.md is missing." + echo " Please create this file with @TechnologyRoot metadata." + exit 1 +fi + +echo "🚀 Generating site..." + +# ----------------------------------------------------------------------------- +# 8. DocC Conversion (Static Site Generation) +# ----------------------------------------------------------------------------- +# Find the 'docc' tool inside Xcode. +DOCC_EXEC=$(xcrun --find docc) + +# Run the conversion: +# --additional-symbol-graph-dir: Where the filtered symbols are stored. +# --transform-for-static-hosting: Generates a site compatible with GitHub Pages. +# --hosting-base-path: Critical for GitHub Pages. Sets the root URL path (e.g. /swift-toolkit/). +$DOCC_EXEC convert "$DOCC_CATALOG_DIR" \ + --additional-symbol-graph-dir "$SYMBOL_GRAPHS_DIR" \ + --output-dir "$SITE_DIR" \ + --fallback-display-name "Readium" \ + --transform-for-static-hosting \ + --hosting-base-path "$REPO_NAME/$DOC_VERSION" + +echo "✅ Documentation generated at: $SITE_DIR" + +# ----------------------------------------------------------------------------- +# 9. Add SPA Routing (Fixes Root & Deep Links) +# ----------------------------------------------------------------------------- +echo "twisted_rightwards_arrows Adding 404 redirect for SPA routing..." + +# This script handles the redirect for both the root path AND deep links. +cat < "$SITE_DIR/404.html" + + + + + Redirecting... + + + + +

Redirecting to documentation...

+ + +EOF + +cp "$SITE_DIR/index.html" "$SITE_DIR/404.html" + +# ----------------------------------------------------------------------------- +# 10. Local Preview +# ----------------------------------------------------------------------------- +if [ "$SERVE_SITE" = true ]; then + URL="http://localhost:8080/$REPO_NAME/$DOC_VERSION/documentation/readium" + echo "🌍 Serving at $URL" + + # Open the browser + open "$URL" 2>/dev/null || true + + # Run a simple Python HTTP server to serve the static files. + # Serve from OUTPUT_ROOT so the subdirectory /swift-toolkit/ exists. + python3 -m http.server -d "$OUTPUT_ROOT" 8080 +else + echo " Run 'BuildTools/Scripts/generate-docs.sh --serve' to preview." +fi diff --git a/Package.swift b/Package.swift index 2d0a2d9dc8..0e49b56442 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( .package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.1"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.0"), .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), ], targets: [ .target( diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift index b7ab9e8cda..6624771b58 100644 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift @@ -46,7 +46,9 @@ public class GCDHTTPServer: HTTPServer, Loggable { /// Creates a new instance of the HTTP server. /// - /// - Parameter logLevel: See `ReadiumGCDWebServer.setLogLevel`. + /// - Parameters: + /// - assetRetriever: The retriever used to fetch assets for the server. + /// - logLevel: See `ReadiumGCDWebServer.setLogLevel`. public init( assetRetriever: AssetRetriever, logLevel: Int = 3 diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift index f42c112f36..9dba0b82cf 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift @@ -125,7 +125,7 @@ public class LCPSQLiteLicenseRepository: LCPLicenseRepository, Loggable { /// /// This migration transfers consumable rights (print/copy counts) and /// device registration status to the target repository. The full - /// ``LicenseDocument`` is not stored in SQLite and will be automatically + /// `LicenseDocument` is not stored in SQLite and will be automatically /// added to the target repository when each publication is opened /// for the first time after migration. /// diff --git a/Sources/Internal/JSON.swift b/Sources/Internal/JSON.swift index cc9ad7a80a..95063a091f 100644 --- a/Sources/Internal/JSON.swift +++ b/Sources/Internal/JSON.swift @@ -89,7 +89,9 @@ public func parseRaw(_ json: Any?) -> T? { /// let values1: [String] = parseArray(json["multiple"]) /// let values2: [String] = parseArray(json["single"], allowingSingle: true) /// -/// - Parameter allowingSingle: If true, then allows the parsing of both a single value and an array. +/// - Parameters: +/// - json: The JSON object to parse, typically an `Array` or a single value. +/// - allowingSingle: If true, then allows the parsing of both a single value and an array. public func parseArray(_ json: Any?, allowingSingle: Bool = false) -> [T] { if let values = json as? [T] { return values diff --git a/Sources/LCP/Authentications/LCPAuthenticating.swift b/Sources/LCP/Authentications/LCPAuthenticating.swift index f4b6ec3564..6fdd1c8da6 100644 --- a/Sources/LCP/Authentications/LCPAuthenticating.swift +++ b/Sources/LCP/Authentications/LCPAuthenticating.swift @@ -19,11 +19,9 @@ public protocol LCPAuthenticating { /// - reason: Reason why the passphrase is requested. It should be used to prompt the user. /// - allowUserInteraction: Indicates whether the user can be prompted for their passphrase. /// If your implementation requires it and `allowUserInteraction` is false, terminate - /// quickly by sending `nil` to the completion block. + /// quickly by returning `nil`. /// - sender: Free object that can be used by reading apps to give some UX context when /// presenting dialogs. For example, the host `UIViewController`. - /// - completion: Used to return the retrieved passphrase. If the user cancelled, send nil. - /// The passphrase may be already hashed. @MainActor func retrievePassphrase( for license: LCPAuthenticatedLicense, diff --git a/Sources/LCP/LCPLicense.swift b/Sources/LCP/LCPLicense.swift index 3db0ba6e9e..5edfe2be16 100644 --- a/Sources/LCP/LCPLicense.swift +++ b/Sources/LCP/LCPLicense.swift @@ -42,8 +42,10 @@ public protocol LCPLicense: UserRights { /// Renews the loan by starting a renew LSD interaction. /// - /// - Parameter prefersWebPage: Indicates whether the loan should be renewed through a web page if available, - /// instead of programmatically. + /// - Parameters: + /// - delegate: The delegate used to handle the user interactions required during the renewal process. + /// - prefersWebPage: Indicates whether the loan should be renewed through a web page if available, + /// instead of programmatically. func renewLoan( with delegate: LCPRenewDelegate, prefersWebPage: Bool diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index 6f31dfc8f9..5ab0018f55 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -21,12 +21,18 @@ public final class LCPService: Loggable { private let licenses: LicensesService private let assetRetriever: AssetRetriever - /// - Parameter deviceName: Device name used when registering a license to an LSD server. - /// If not provided, the device name will be the default `UIDevice.current.name`. - /// - Parameter deviceId: Device ID used when registering a license to an LSD server. - /// You must ensure the identifier is unique and stable for the device (persist and - /// reuse across app launches). If not provided, the device ID will be generated as - /// a random UUID. + /// - Parameters: + /// - client: The LCP client used for core license operations. + /// - licenseRepository: Repository for managing stored licenses. + /// - passphraseRepository: Repository for managing user passphrases. + /// - assetRetriever: The retriever used to fetch protected assets. + /// - httpClient: The HTTP client used for network requests to LSD/LCP servers. + /// - deviceName: Device name used when registering a license to an LSD server. + /// If not provided, the device name will be the default `UIDevice.current.name`. + /// - deviceId: Device ID used when registering a license to an LSD server. + /// You must ensure the identifier is unique and stable for the device (persist and + /// reuse across app launches). If not provided, the device ID will be generated as + /// a random UUID. public init( client: LCPClient, licenseRepository: LCPLicenseRepository, @@ -95,14 +101,15 @@ public final class LCPService: Loggable { /// Opens the LCP license of a protected publication, to access its DRM /// metadata and decipher its content. /// - /// If the updated license cannot be stored into the ``Asset``, you'll get + /// If the updated license cannot be stored into the `Asset`, you'll get /// an exception if the license points to a LSD server that cannot be /// reached, for instance because no Internet gateway is available. /// - /// Updated licenses can currently be stored only into ``Asset``s whose + /// Updated licenses can currently be stored only into `Asset`s whose /// source property points to a `file://` URL. /// /// - Parameters: + /// - asset: The asset whose license is to be retrieved. /// - authentication: Used to retrieve the user passphrase if it is not /// already known. The request will be cancelled if no passphrase is /// found in the LCP passphrase storage and in the given diff --git a/Sources/Navigator/Decorator/DecorableNavigator.swift b/Sources/Navigator/Decorator/DecorableNavigator.swift index 11aadae536..9881aee46a 100644 --- a/Sources/Navigator/Decorator/DecorableNavigator.swift +++ b/Sources/Navigator/Decorator/DecorableNavigator.swift @@ -27,7 +27,9 @@ public protocol DecorableNavigator { /// Registers new callbacks for decoration interactions in the given `group`. /// - /// - Parameter onActivated: Called when the user activates the decoration, e.g. with a click or tap. + /// - Parameters: + /// - group: The name of the decoration group to observe. + /// - onActivated: Called when the user activates the decoration, e.g. with a click or tap. func observeDecorationInteractions(inGroup group: String, onActivated: @escaping OnActivatedCallback) /// Called when the user activates a decoration, e.g. with a click or tap. diff --git a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift index 85a3b94507..186e7985d2 100644 --- a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift +++ b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift @@ -114,8 +114,10 @@ public struct CSSFontFace { /// Returns a new CSSFontFace after adding a linked source for this font /// face. /// - /// - Parameter preload: Indicates whether this source will be declared for - /// preloading in the HTML using ``. + /// - Parameters: + /// - file: The URL to the font file to be added as a source. + /// - preload: Indicates whether this source will be declared for + /// preloading in the HTML using ``. public func addingSource(file: FileURL, preload: Bool = false) -> Self { var copy = self copy.sources.append((file, preload)) diff --git a/Sources/Navigator/Input/InputObservable.swift b/Sources/Navigator/Input/InputObservable.swift index ed18b3edd8..363c0ab49c 100644 --- a/Sources/Navigator/Input/InputObservable.swift +++ b/Sources/Navigator/Input/InputObservable.swift @@ -9,19 +9,19 @@ import Foundation /// A type broadcasting user input events (e.g. touch or keyboard events) to /// a set of observers. @MainActor public protocol InputObservable { - /// Registers a new ``InputObserver`` for the observable receiver. + /// Registers a new `InputObserver` for the observable receiver. /// /// - Returns: An opaque token which can be used to remove the observer with - /// `removeInputObserver`. + /// `removeObserver`. @discardableResult func addObserver(_ observer: InputObserving) -> InputObservableToken - /// Unregisters an ``InputObserver`` from this receiver using the given - /// `token` returned by `addInputObserver`. + /// Unregisters an `InputObserver` from this receiver using the given + /// `token` returned by `addObserver`. func removeObserver(_ token: InputObservableToken) } -/// A token which can be used to remove an ``InputObserver`` from an +/// A token which can be used to remove an `InputObserver` from an /// ``InputObservable``. public struct InputObservableToken: Hashable, Identifiable { public let id: AnyHashable diff --git a/Sources/Navigator/Input/InputObservableViewController.swift b/Sources/Navigator/Input/InputObservableViewController.swift index 47dd7ad4de..fa6ba1744d 100644 --- a/Sources/Navigator/Input/InputObservableViewController.swift +++ b/Sources/Navigator/Input/InputObservableViewController.swift @@ -6,7 +6,7 @@ import UIKit -/// Base implementation of ``UIViewController`` which implements +/// Base implementation of `UIViewController` which implements /// ``InputObservable`` to forward UIKit touches and presses events to /// observers. open class InputObservableViewController: UIViewController, InputObservable { diff --git a/Sources/Navigator/VisualNavigator.swift b/Sources/Navigator/VisualNavigator.swift index 6c9a508e5c..c9bf1ccca5 100644 --- a/Sources/Navigator/VisualNavigator.swift +++ b/Sources/Navigator/VisualNavigator.swift @@ -19,20 +19,18 @@ public protocol VisualNavigator: Navigator, InputObservable { /// Moves to the left content portion (eg. page) relative to the reading /// progression direction. /// - /// - Parameter completion: Called when the transition is completed. - /// - Returns: Whether the navigator is able to move to the previous - /// content portion. The completion block is only called if true was - /// returned. + /// - Parameter options: Options for moving the content to the left. + /// - Returns: Whether the navigator was able to move to the left content + /// portion. @discardableResult func goLeft(options: NavigatorGoOptions) async -> Bool /// Moves to the right content portion (eg. page) relative to the reading /// progression direction. /// - /// - Parameter completion: Called when the transition is completed. - /// - Returns: Whether the navigator is able to move to the previous - /// content portion. The completion block is only called if true was - /// returned. + /// - Parameter options: Options for moving the content to the right. + /// - Returns: Whether the navigator was able to move to the right content + /// portion. @discardableResult func goRight(options: NavigatorGoOptions) async -> Bool diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index d9c3a96727..012966f250 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -30,7 +30,10 @@ struct MimeTypeParameters { public class OPDS1Parser: Loggable { /// Parse an OPDS feed or publication. /// Feed can only be v1 (XML). - /// - parameter url: The feed URL + /// - Parameters: + /// - url: The feed URL. + /// - completion: A closure called when the parsing is complete, returning the parsed data + /// or an error if the operation failed. public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { URLSession.shared.dataTask(with: url) { data, response, error in guard let data = data, let response = response else { @@ -219,8 +222,11 @@ public class OPDS1Parser: Loggable { /// Parse an OPDS publication. /// Publication can only be v1 (XML). - /// - parameter document: The XMLDocument data - /// - Returns: The resulting Publication + /// - Parameters: + /// - document: The XMLDocument data. + /// - feedURL: The base URL of the feed, used to resolve relative links. + /// - Returns: The resulting `Publication`, or `nil` if the entry couldn't be parsed. + /// - Throws: An error if the XML parsing or validation fails. public static func parseEntry(document: ReadiumFuzi.XMLDocument, feedURL: URL) throws -> Publication? { guard let root = document.root else { throw OPDS1ParserError.rootNotFound @@ -229,7 +235,10 @@ public class OPDS1Parser: Loggable { } /// Fetch an Open Search template from an OPDS feed. - /// - parameter feed: The OPDS feed + /// - Parameters: + /// - feed: The OPDS feed to search for the template. + /// - completion: A closure called with the OpenSearch template as a `String` if found, + /// or an `Error` if the fetch or parsing failed. public static func fetchOpenSearchTemplate(feed: Feed, completion: @escaping (String?, Error?) -> Void) { guard let openSearchHref = feed.links.firstWithRel(.search)?.href, let openSearchURL = URL(string: openSearchHref) diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index 80a7f73224..b3f706b4c8 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -21,7 +21,10 @@ public enum OPDS2ParserError: Error { public class OPDS2Parser: Loggable { /// Parse an OPDS feed or publication. /// Feed can only be v2 (JSON). - /// - parameter url: The feed URL + /// - Parameters: + /// - url: The feed URL. + /// - completion: A closure called when the parsing is complete, returning the + /// parsed `ParseData` on success, or an `Error` if the operation failed. public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { URLSession.shared.dataTask(with: url) { data, response, error in guard let data = data, let response = response else { @@ -77,8 +80,11 @@ public class OPDS2Parser: Loggable { /// Parse an OPDS feed. /// Feed can only be v2 (JSON). - /// - parameter jsonDict: The json top level dictionary - /// - Returns: The resulting Feed + /// - Parameters: + /// - feedURL: The URL of the feed being parsed, used to resolve relative links. + /// - jsonDict: The JSON top-level dictionary. + /// - Returns: The resulting `Feed` object. + /// - Throws: An error if the JSON structure is invalid or missing required OPDS fields. public static func parse(feedURL: URL, jsonDict: [String: Any]) throws -> Feed { guard let metadataDict = jsonDict["metadata"] as? [String: Any] else { throw OPDS2ParserError.metadataNotFound diff --git a/Sources/OPDS/OPDSParser.swift b/Sources/OPDS/OPDSParser.swift index 99139d004b..80031b9397 100644 --- a/Sources/OPDS/OPDSParser.swift +++ b/Sources/OPDS/OPDSParser.swift @@ -17,7 +17,10 @@ public enum OPDSParser { /// Parse an OPDS feed or publication. /// Feed can be v1 (XML) or v2 (JSON). - /// - parameter url: The feed URL + /// - Parameters: + /// - url: The feed URL. + /// - completion: A closure called when the parsing is complete, returning the + /// parsed `ParseData` on success, or an `Error` if the operation failed. public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { feedURL = url diff --git a/Sources/Shared/Logger/Logger.swift b/Sources/Shared/Logger/Logger.swift index f64192c07e..c92a9bc24f 100644 --- a/Sources/Shared/Logger/Logger.swift +++ b/Sources/Shared/Logger/Logger.swift @@ -9,7 +9,10 @@ import Foundation /// Initialize the Logger. /// Default logger is the `LoggerStub` class /// -/// - Parameter customLogger: The Logger that will be used for printing logs. +/// - Parameters: +/// - level: The minimum severity level for logs to be processed. +/// - customLogger: The Logger that will be used for printing logs. +/// Defaults to a `LoggerStub` which may perform no-op logging. public func ReadiumEnableLog(withMinimumSeverityLevel level: SeverityLevel, customLogger: LoggerType = LoggerStub()) { Logger.sharedInstance.setupLogger(logger: customLogger) Logger.sharedInstance.setMinimumSeverityLevel(at: level) diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift index a5c2fca739..44708469be 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift @@ -962,7 +962,7 @@ public struct AccessibilityDisplayStatement: Sendable, Equatable, Identifiable { /// family and font size, spaces between paragraphs, sentences, words, and /// letters, as well as color of background and text) /// - /// Some statements contain HTTP links; so we use an ``NSAttributedString``. + /// Some statements contain HTTP links; so we use an `NSAttributedString`. /// /// - Parameter descriptive: When true, will return the long descriptive /// statement. diff --git a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift index be87b88d61..7445039461 100644 --- a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift +++ b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift @@ -11,7 +11,11 @@ public typealias ContentTokenizer = Tokenizer /// A `ContentTokenizer` using a `TextTokenizer` to split the text of the `Content`. /// -/// - Parameter contextSnippetLength: Length of `before` and `after` snippets in the produced `Locator`s. +/// - Parameters: +/// - defaultLanguage: The language used by the tokenizer if the content doesn't specify one. +/// - contextSnippetLength: Length of `before` and `after` snippets in the produced `Locator`s. +/// - textTokenizerFactory: A closure providing the underlying `TextTokenizer` to use +/// for a given language. public func makeTextContentTokenizer( defaultLanguage: Language?, contextSnippetLength: Int = 50, diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index f35e459903..fa8fe234f7 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -26,7 +26,9 @@ public actor BufferingResource: Resource, Loggable { /// `Resource`, with the range it covers. private var buffer: Buffer - /// - Parameter bufferSize: Size of the buffer chunks to read. + /// - Parameters: + /// - resource: The underlying `Resource` to be read. + /// - bufferSize: Size of the buffer chunks to read, in bytes. public init(resource: Resource, bufferSize: Int = 256 * 1024) { precondition(bufferSize > 0) self.resource = resource diff --git a/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift index 57f936a7ce..2dcc9212fb 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift @@ -9,8 +9,10 @@ import Foundation /// Default implementation of ``FormatSniffer`` guessing as well as possible all /// formats known by Readium. public final class DefaultFormatSniffer: CompositeFormatSniffer { - /// - Parameter additionalSniffers: Additional sniffers to be used to guess - /// content format. + /// - Parameters: + /// - xmlDocumentFactory: Used to parse XML content when sniffing formats that require + /// XML inspection. Defaults to `DefaultXMLDocumentFactory()`. + /// - additionalSniffers: Additional sniffers to be used to guess content format. public init( xmlDocumentFactory: XMLDocumentFactory = DefaultXMLDocumentFactory(), additionalSniffers: [FormatSniffer] = [] diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index a7b2c6c64c..defe7b5650 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -122,6 +122,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { /// - additionalHeaders: A dictionary of additional headers to send with requests. For example, `User-Agent`. /// - requestTimeout: The timeout interval to use when waiting for additional data. /// - resourceTimeout: The maximum amount of time that a resource request should be allowed to take. + /// - delegate: An optional delegate to handle common HTTP events. /// - configure: Callback used to configure further the `URLSessionConfiguration` object. public convenience init( userAgent: String? = nil, @@ -160,7 +161,9 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { /// Creates a `DefaultHTTPClient` with a custom configuration. /// /// - Parameters: + /// - configuration: The `URLSessionConfiguration` to use for all requests. /// - userAgent: Default user agent issued with requests. + /// - delegate: An optional delegate to handle common HTTP events. public init( configuration: URLSessionConfiguration, userAgent: String? = nil, diff --git a/Sources/Shared/Toolkit/URL/AnyURL.swift b/Sources/Shared/Toolkit/URL/AnyURL.swift index 66d863f7b9..3f5ea72c48 100644 --- a/Sources/Shared/Toolkit/URL/AnyURL.swift +++ b/Sources/Shared/Toolkit/URL/AnyURL.swift @@ -17,7 +17,7 @@ public enum AnyURL: URLProtocol { /// A relative URL. case relative(RelativeURL) - /// Creates an ``AnyURL`` from a Foundation ``URL``. + /// Creates an ``AnyURL`` from a Foundation `URL`. public init(url: URL) { if let url = RelativeURL(url: url) { self = .relative(url) diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index e31c7be7cd..e2fcc40248 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -10,7 +10,7 @@ import Foundation public struct RelativeURL: URLProtocol, Hashable { public let url: URL - /// Creates a ``RelativeURL`` from a standard Swift ``URL``. + /// Creates a ``RelativeURL`` from a standard Swift `URL`. public init?(url: URL) { guard url.scheme == nil else { return nil diff --git a/Sources/Shared/Toolkit/URL/URLProtocol.swift b/Sources/Shared/Toolkit/URL/URLProtocol.swift index 392c23d2fe..a7ca67afe8 100644 --- a/Sources/Shared/Toolkit/URL/URLProtocol.swift +++ b/Sources/Shared/Toolkit/URL/URLProtocol.swift @@ -9,10 +9,10 @@ import ReadiumInternal /// A type that can represent a URL. public protocol URLProtocol: URLConvertible, Sendable, CustomStringConvertible { - /// Creates a new instance of this type from a Foundation ``URL``. + /// Creates a new instance of this type from a Foundation `URL`. init?(url: URL) - /// Returns a foundation ``URL`` for this URL representation. + /// Returns a foundation `URL` for this URL representation. var url: URL { get } } diff --git a/Sources/Shared/Toolkit/XML/XML.swift b/Sources/Shared/Toolkit/XML/XML.swift index b4827080b1..25dd4fe184 100644 --- a/Sources/Shared/Toolkit/XML/XML.swift +++ b/Sources/Shared/Toolkit/XML/XML.swift @@ -73,17 +73,26 @@ public protocol XMLElement: XMLNode { public protocol XMLDocumentFactory { /// Opens an XML document from a local file path. /// - /// - Parameter namespaces: List of namespace prefixes to declare in the document. + /// - Parameters: + /// - file: The local file URL of the XML document. + /// - namespaces: List of namespace prefixes to declare in the document. + /// - Throws: An error if the file cannot be read or the XML is malformed. func open(file: FileURL, namespaces: [XMLNamespace]) async throws -> XMLDocument /// Opens an XML document from its raw data content. /// - /// - Parameter namespaces: List of namespace prefixes to declare in the document. + /// - Parameters: + /// - data: The raw data containing the XML content. + /// - namespaces: List of namespace prefixes to declare in the document. + /// - Throws: An error if the XML parsing fails. func open(data: Data, namespaces: [XMLNamespace]) throws -> XMLDocument /// Opens an XML document from its raw string content. /// - /// - Parameter namespaces: List of namespace prefixes to declare in the document. + /// - Parameters: + /// - string: The string containing the XML content. + /// - namespaces: List of namespace prefixes to declare in the document. + /// - Throws: An error if the XML parsing fails. func open(string: String, namespaces: [XMLNamespace]) throws -> XMLDocument } diff --git a/Sources/Streamer/Parser/PublicationParser.swift b/Sources/Streamer/Parser/PublicationParser.swift index 7d68072d77..3f2685cbc1 100644 --- a/Sources/Streamer/Parser/PublicationParser.swift +++ b/Sources/Streamer/Parser/PublicationParser.swift @@ -9,7 +9,7 @@ import ReadiumShared /// Parses a Publication from an asset. public protocol PublicationParser { - /// Constructs a ``Publication.Builder`` to build a ``Publication`` from a + /// Constructs a `Publication.Builder` to build a `Publication` from a /// publication asset. /// /// - Parameters: diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index 48a3c0977d..6afa363606 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -23,9 +23,12 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { private let httpClient: HTTPClient private let epubReflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy - /// - Parameter epubReflowablePositionsStrategy: Strategy used to calculate - /// the number of positions in a reflowable resource of a web publication - /// conforming to the EPUB profile. + /// - Parameters: + /// - pdfFactory: Factory used to open PDF documents, if available. + /// - httpClient: The HTTP client used to fetch remote resources. + /// - epubReflowablePositionsStrategy: Strategy used to calculate + /// the number of positions in a reflowable resource of a web publication + /// conforming to the EPUB profile. public init(pdfFactory: PDFDocumentFactory?, httpClient: HTTPClient, epubReflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy = .recommended) { self.pdfFactory = pdfFactory self.httpClient = httpClient diff --git a/Sources/Streamer/PublicationOpener.swift b/Sources/Streamer/PublicationOpener.swift index 3b7d13ccc8..cf2be56964 100644 --- a/Sources/Streamer/PublicationOpener.swift +++ b/Sources/Streamer/PublicationOpener.swift @@ -7,14 +7,14 @@ import Foundation import ReadiumShared -/// Opens a ``Publication`` from an ``Asset``. +/// Opens a `Publication` from an `Asset`. /// /// - Parameters: -/// - parser: Parses the content of a publication ``Asset``. +/// - parser: Parses the content of a publication `Asset`. /// - contentProtections: Opens DRM-protected publications. /// - onCreatePublication: Called on every parsed `Publication.Builder`. It /// can be used to modify the manifest, the root container or the list of -/// service factories of a ``Publication``. +/// service factories of a `Publication`. public class PublicationOpener { private let parser: PublicationParser private let contentProtections: [ContentProtection] @@ -30,15 +30,15 @@ public class PublicationOpener { self.onCreatePublication = onCreatePublication } - /// Opens a ``Publication`` from the given asset. + /// Opens a `Publication` from the given asset. /// /// If you are opening the publication to render it in a Navigator, you - /// must set ``allowUserInteraction`` to true to prompt the user for its + /// must set `allowUserInteraction` to true to prompt the user for its /// credentials when the publication is protected. However, set it to false - /// if you just want to import the ``Publication`` without reading its + /// if you just want to import the `Publication` without reading its /// content, to avoid prompting the user. /// - /// The ``warnings`` logger can be used to observe non-fatal parsing + /// The `warnings` logger can be used to observe non-fatal parsing /// warnings, caused by publication authoring mistakes. This can be useful /// to warn users of potential rendering issues. /// @@ -50,7 +50,7 @@ public class PublicationOpener { /// attempt to unlock a publication, for example a password. /// - onCreatePublication: Transformation which will be applied on the /// Publication Builder. It can be used to modify the manifest, the root - /// container or the list of service factories of the ``Publication``. + /// container or the list of service factories of the `Publication`. /// - warnings: Logger used to broadcast non-fatal parsing warnings. /// - sender: Free object that can be used by reading apps to give some /// UX context when presenting dialogs. diff --git a/docs/NavigatorOverview.md b/docs/NavigatorOverview.md new file mode 100644 index 0000000000..c46745c11e --- /dev/null +++ b/docs/NavigatorOverview.md @@ -0,0 +1,17 @@ +# Navigator Overview + +@Metadata { + @PageKind(article) +} + +Learn about the architecture, configuration, and usage of the Readium Navigator. + +## Topics + +### Essentials + +- +- +- +- +- diff --git a/docs/Readium.md b/docs/Readium.md new file mode 100644 index 0000000000..02832d84d6 --- /dev/null +++ b/docs/Readium.md @@ -0,0 +1,28 @@ +# Readium + +@Metadata { + @TechnologyRoot +} + +The Readium Swift Toolkit is a toolkit for ebooks, audiobooks, and comics written in Swift & Kotlin. + +## Topics + +### Guides + +- +- +- +- +- +- +- +- + +### API Reference + +- +- +- +- +- From 51022b639459a715927c8ba33d5e3280d4c91544 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:59:00 -0600 Subject: [PATCH 49/55] Update CSS to v2 CSS is managed through npm now. Run `make scripts` to add them to node_modules. The copy-webpack-plugin handles moving them from node_modules to the readium-css folder. --- Makefile | 9 - .../EPUB/Assets/Static/readium-css/ReadMe.md | 6 +- .../Static/readium-css/ReadiumCSS-after.css | 240 +++++---------- .../Static/readium-css/ReadiumCSS-before.css | 54 ++-- .../Static/readium-css/ReadiumCSS-default.css | 17 +- .../cjk-horizontal/ReadiumCSS-after.css | 191 +++--------- .../cjk-horizontal/ReadiumCSS-before.css | 26 +- .../cjk-horizontal/ReadiumCSS-default.css | 17 +- .../cjk-vertical/ReadiumCSS-after.css | 191 +++--------- .../cjk-vertical/ReadiumCSS-before.css | 29 +- .../cjk-vertical/ReadiumCSS-default.css | 17 +- .../readium-css/rtl/ReadiumCSS-after.css | 211 ++++---------- .../readium-css/rtl/ReadiumCSS-before.css | 26 +- .../readium-css/rtl/ReadiumCSS-default.css | 17 +- .../readium-css/webPub/ReadiumCSS-webPub.css | 275 ++++++++++++++++++ Sources/Navigator/EPUB/Scripts/package.json | 4 + Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml | 106 +++++++ .../Navigator/EPUB/Scripts/webpack.config.js | 24 ++ Support/Carthage/.xcodegen | 14 +- 19 files changed, 783 insertions(+), 691 deletions(-) create mode 100644 Sources/Navigator/EPUB/Assets/Static/readium-css/webPub/ReadiumCSS-webPub.css diff --git a/Makefile b/Makefile index 7fe23be35f..2fce9b1391 100644 --- a/Makefile +++ b/Makefile @@ -43,15 +43,6 @@ update-scripts: @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1) pnpm install --dir "$(SCRIPTS_PATH)" -.PHONY: update-css -update-css: - git clone https://github.com/readium/css.git readium-css - rm -rf "$(CSS_PATH)" - cp -r readium-css/css/dist "$(CSS_PATH)" - git -C readium-css rev-parse HEAD > "$(CSS_PATH)/HEAD" - rm -rf readium-css - rm -rf "$(CSS_PATH)/android-fonts-patch" - .PHONY: test test: # To limit to a particular test suite: -only-testing:ReadiumSharedTests diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadMe.md b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadMe.md index 518fe6289e..a5588f5585 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadMe.md +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadMe.md @@ -27,10 +27,6 @@ Disabled user settings: - `hyphens`; - `letter-spacing`. -Added user settings: - -- `font-variant-ligatures` (mapped to `--USER__ligatures` CSS variable). - ## CJK Chinese, Japanese, Korean, and Mongolian can be either written `horizontal-tb` or `vertical-*`. Consequently, there are stylesheets for horizontal and vertical writing modes. @@ -52,6 +48,7 @@ Disabled user settings: - `text-align`; - `hyphens`; +- `ligatures`; - paragraphs’ indent; - `word-spacing`. @@ -88,6 +85,7 @@ Disabled user settings: - `column-count` (number of columns); - `text-align`; - `hyphens`; +- `ligatures`; - paragraphs’ indent; - `word-spacing`. diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css index 9f837f48a1..2aa47e209e 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -20,7 +27,7 @@ --RS__pageGutter:0; - --RS__defaultLineLength:40rem; + --RS__defaultLineLength:100%; --RS__colGap:0; @@ -64,11 +71,14 @@ body{ width:100%; max-width:var(--RS__defaultLineLength) !important; - padding:0 var(--RS__pageGutter) !important; margin:0 auto !important; box-sizing:border-box; } +:root:not([style*="readium-scroll-on"]) body{ + padding:0 var(--RS__pageGutter) !important; +} + :root:not([style*="readium-noOverflow-on"]) body{ overflow:hidden; } @@ -133,145 +143,6 @@ body{ padding-right:var(--RS__scrollPaddingRight) !important; } -:root[style*="readium-night-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#0099E5; - - --RS__linkColor:#63caff; - - --RS__textColor:#FEFEFE; - - --RS__backgroundColor:#000000; -} - -:root[style*="readium-night-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; - border-color:currentcolor !important; -} - -:root[style*="readium-night-on"] svg text{ - fill:currentcolor !important; - stroke:none !important; -} - -:root[style*="readium-night-on"] a:link, -:root[style*="readium-night-on"] a:link *{ - color:var(--RS__linkColor) !important; -} - -:root[style*="readium-night-on"] a:visited, -:root[style*="readium-night-on"] a:visited *{ - color:var(--RS__visitedColor) !important; -} - -:root[style*="readium-night-on"] img[class*="gaiji"], -:root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, -:root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:invert(100%); - filter:invert(100%); -} - -:root[style*="readium-sepia-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#551A8B; - - --RS__linkColor:#0000EE; - - --RS__textColor:#121212; - - --RS__backgroundColor:#faf4e8; -} - -:root[style*="readium-sepia-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; -} - -:root[style*="readium-sepia-on"] a:link, -:root[style*="readium-sepia-on"] a:link *{ - color:var(--RS__linkColor); -} - -:root[style*="readium-sepia-on"] a:visited, -:root[style*="readium-sepia-on"] a:visited *{ - color:var(--RS__visitedColor); -} - -@media screen and (-ms-high-contrast: active){ - - :root{ - color:windowText !important; - background-color:window !important; - } - - :root :not(#\#):not(#\#):not(#\#), - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#){ - color:inherit !important; - background-color:inherit !important; - } - - .readiumCSS-mo-active-default{ - color:highlightText !important; - background-color:highlight !important; - } -} - -@media screen and (-ms-high-contrast: white-on-black){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (inverted-colors){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (monochrome){ -} - -@media screen and (prefers-reduced-motion){ -} - :root[style*="--USER__backgroundColor"]{ background-color:var(--USER__backgroundColor) !important; } @@ -346,7 +217,15 @@ body{ } :root[style*="--USER__textAlign"] body, -:root[style*="--USER__textAlign"] p:not(blockquote p):not(figcaption p):not(hgroup p), +:root[style*="--USER__textAlign"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +), :root[style*="--USER__textAlign"] li, :root[style*="--USER__textAlign"] dd{ text-align:var(--USER__textAlign) !important; @@ -383,39 +262,28 @@ body{ font-family:revert !important; } -:root[style*="AccessibleDfA"]{ - font-family:AccessibleDfA, Verdana, Tahoma, "Trebuchet MS", sans-serif !important; -} - -:root[style*="IA Writer Duospace"]{ - font-family:"IA Writer Duospace", Menlo, "DejaVu Sans Mono", "Bitstream Vera Sans Mono", Courier, monospace !important; -} - -:root[style*="AccessibleDfA"],:root[style*="IA Writer Duospace"], :root[style*="readium-a11y-on"]{ font-style:normal !important; font-weight:normal !important; } -:root[style*="AccessibleDfA"] *:not(code):not(var):not(kbd):not(samp),:root[style*="IA Writer Duospace"] *:not(code):not(var):not(kbd):not(samp), -:root[style*="readium-a11y-on"] *:not(code):not(var):not(kbd):not(samp){ +:root[style*="readium-a11y-on"] body *:not(code):not(var):not(kbd):not(samp){ font-family:inherit !important; font-style:inherit !important; font-weight:inherit !important; } -:root[style*="AccessibleDfA"] *,:root[style*="IA Writer Duospace"] *, -:root[style*="readium-a11y-on"] *{ +:root[style*="readium-a11y-on"] body *:not(a){ text-decoration:none !important; +} + +:root[style*="readium-a11y-on"] body *{ font-variant-caps:normal !important; font-variant-numeric:normal !important; font-variant-position:normal !important; } -:root[style*="AccessibleDfA"] sup,:root[style*="IA Writer Duospace"] sup, :root[style*="readium-a11y-on"] sup, -:root[style*="AccessibleDfA"] sub, -:root[style*="IA Writer Duospace"] sub, :root[style*="readium-a11y-on"] sub{ font-size:1rem !important; vertical-align:baseline !important; @@ -425,10 +293,36 @@ body{ zoom:var(--USER__fontSize) !important; } -:root[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ +:root:not([style*="readium-deprecatedFontSize-on"])[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ -webkit-text-size-adjust:var(--USER__fontSize) !important; } +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] table{ + zoom:calc(100% / var(--USER__fontSize)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] th{ + zoom:var(--USER__fontSize) !important; + } +} + @supports not (zoom: 1){ :root[style*="--USER__fontSize"]{ @@ -456,7 +350,15 @@ body{ margin-bottom:var(--USER__paraSpacing) !important; } -:root[style*="--USER__paraIndent"] p{ +:root[style*="--USER__paraIndent"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +){ text-indent:var(--USER__paraIndent) !important; } @@ -494,6 +396,14 @@ body{ font-variant:none; } +:root[style*="--USER__ligatures"]{ + font-variant-ligatures:var(--USER__ligatures) !important; +} + +:root[style*="--USER__ligatures"] *{ + font-variant-ligatures:inherit !important; +} + :root[style*="--USER__fontWeight"] body{ font-weight:var(--USER__fontWeight) !important; } diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css index 99ea6292fe..85c9f00a23 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -218,34 +225,6 @@ math{ --RS__lineHeightCompensation:1.167; } -@font-face{ - font-family:AccessibleDfA; - font-style:normal; - font-weight:normal; - src:local("AccessibleDfA"), url("fonts/AccessibleDfA-Regular.woff2") format("woff2"), url("fonts/AccessibleDfA-Regular.woff") format("woff"); -} - -@font-face{ - font-family:AccessibleDfA; - font-style:normal; - font-weight:bold; - src:local("AccessibleDfA"), url("fonts/AccessibleDfA-Bold.woff2") format("woff2"); -} - -@font-face{ - font-family:AccessibleDfA; - font-style:italic; - font-weight:normal; - src:local("AccessibleDfA"), url("fonts/AccessibleDfA-Italic.woff2") format("woff2"); -} - -@font-face{ - font-family:"IA Writer Duospace"; - font-style:normal; - font-weight:normal; - src:local("iAWriterDuospace-Regular"), url("fonts/iAWriterDuospace-Regular.ttf") format("truetype"); -} - body{ widows:2; orphans:2; @@ -421,6 +400,15 @@ img, svg|svg, video{ break-inside:avoid; } +@supports (zoom: 1) and (not ((-webkit-column-axis: horizontal) and (-webkit-column-progression: normal))){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] img, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] svg|svg, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] video{ + zoom:calc(100% / var(--USER__fontSize)); + } +} + audio{ max-width:100%; -webkit-column-break-inside:avoid; diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css index a95fcd4bc7..1d2df4acc0 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css index f4bcc17f67..e48936f8cf 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -20,7 +27,7 @@ --RS__pageGutter:0; - --RS__defaultLineLength:40rem; + --RS__defaultLineLength:100%; --RS__colGap:0; @@ -64,11 +71,14 @@ body{ width:100%; max-width:var(--RS__defaultLineLength) !important; - padding:0 var(--RS__pageGutter) !important; margin:0 auto !important; box-sizing:border-box; } +:root:not([style*="readium-scroll-on"]) body{ + padding:0 var(--RS__pageGutter) !important; +} + :root:not([style*="readium-noOverflow-on"]) body{ overflow:hidden; } @@ -133,145 +143,6 @@ body{ padding-right:var(--RS__scrollPaddingRight) !important; } -:root[style*="readium-night-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#0099E5; - - --RS__linkColor:#63caff; - - --RS__textColor:#FEFEFE; - - --RS__backgroundColor:#000000; -} - -:root[style*="readium-night-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; - border-color:currentcolor !important; -} - -:root[style*="readium-night-on"] svg text{ - fill:currentcolor !important; - stroke:none !important; -} - -:root[style*="readium-night-on"] a:link, -:root[style*="readium-night-on"] a:link *{ - color:var(--RS__linkColor) !important; -} - -:root[style*="readium-night-on"] a:visited, -:root[style*="readium-night-on"] a:visited *{ - color:var(--RS__visitedColor) !important; -} - -:root[style*="readium-night-on"] img[class*="gaiji"], -:root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, -:root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:invert(100%); - filter:invert(100%); -} - -:root[style*="readium-sepia-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#551A8B; - - --RS__linkColor:#0000EE; - - --RS__textColor:#121212; - - --RS__backgroundColor:#faf4e8; -} - -:root[style*="readium-sepia-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; -} - -:root[style*="readium-sepia-on"] a:link, -:root[style*="readium-sepia-on"] a:link *{ - color:var(--RS__linkColor); -} - -:root[style*="readium-sepia-on"] a:visited, -:root[style*="readium-sepia-on"] a:visited *{ - color:var(--RS__visitedColor); -} - -@media screen and (-ms-high-contrast: active){ - - :root{ - color:windowText !important; - background-color:window !important; - } - - :root :not(#\#):not(#\#):not(#\#), - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#){ - color:inherit !important; - background-color:inherit !important; - } - - .readiumCSS-mo-active-default{ - color:highlightText !important; - background-color:highlight !important; - } -} - -@media screen and (-ms-high-contrast: white-on-black){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (inverted-colors){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (monochrome){ -} - -@media screen and (prefers-reduced-motion){ -} - :root[style*="--USER__backgroundColor"]{ background-color:var(--USER__backgroundColor) !important; } @@ -353,10 +224,36 @@ body{ zoom:var(--USER__fontSize) !important; } -:root[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ +:root:not([style*="readium-deprecatedFontSize-on"])[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ -webkit-text-size-adjust:var(--USER__fontSize) !important; } +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] table{ + zoom:calc(100% / var(--USER__fontSize)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] th{ + zoom:var(--USER__fontSize) !important; + } +} + @supports not (zoom: 1){ :root[style*="--USER__fontSize"]{ diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css index 6a99eca103..85c9f00a23 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -393,6 +400,15 @@ img, svg|svg, video{ break-inside:avoid; } +@supports (zoom: 1) and (not ((-webkit-column-axis: horizontal) and (-webkit-column-progression: normal))){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] img, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] svg|svg, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] video{ + zoom:calc(100% / var(--USER__fontSize)); + } +} + audio{ max-width:100%; -webkit-column-break-inside:avoid; diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-default.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-default.css index 83c9fce6be..f85fd8b9df 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-default.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-default.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-after.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-after.css index 601def5e8d..cdd18565d9 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-after.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-after.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -20,7 +27,7 @@ --RS__pageGutter:0; - --RS__defaultLineLength:40rem; + --RS__defaultLineLength:100%; --RS__colGap:0; @@ -75,11 +82,14 @@ body{ width:100%; max-height:var(--RS__defaultLineLength) !important; - padding:var(--RS__pageGutter) 0 !important; margin:auto 0 !important; box-sizing:border-box; } +:root:not([style*="readium-scroll-on"]) body{ + padding:var(--RS__pageGutter) 0 !important; +} + :root:not([style*="readium-noOverflow-on"]) body{ overflow:hidden; } @@ -140,145 +150,6 @@ body{ padding-right:var(--RS__scrollPaddingRight) !important; } -:root[style*="readium-night-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#0099E5; - - --RS__linkColor:#63caff; - - --RS__textColor:#FEFEFE; - - --RS__backgroundColor:#000000; -} - -:root[style*="readium-night-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; - border-color:currentcolor !important; -} - -:root[style*="readium-night-on"] svg text{ - fill:currentcolor !important; - stroke:none !important; -} - -:root[style*="readium-night-on"] a:link, -:root[style*="readium-night-on"] a:link *{ - color:var(--RS__linkColor) !important; -} - -:root[style*="readium-night-on"] a:visited, -:root[style*="readium-night-on"] a:visited *{ - color:var(--RS__visitedColor) !important; -} - -:root[style*="readium-night-on"] img[class*="gaiji"], -:root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, -:root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:invert(100%); - filter:invert(100%); -} - -:root[style*="readium-sepia-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#551A8B; - - --RS__linkColor:#0000EE; - - --RS__textColor:#121212; - - --RS__backgroundColor:#faf4e8; -} - -:root[style*="readium-sepia-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; -} - -:root[style*="readium-sepia-on"] a:link, -:root[style*="readium-sepia-on"] a:link *{ - color:var(--RS__linkColor); -} - -:root[style*="readium-sepia-on"] a:visited, -:root[style*="readium-sepia-on"] a:visited *{ - color:var(--RS__visitedColor); -} - -@media screen and (-ms-high-contrast: active){ - - :root{ - color:windowText !important; - background-color:window !important; - } - - :root :not(#\#):not(#\#):not(#\#), - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#){ - color:inherit !important; - background-color:inherit !important; - } - - .readiumCSS-mo-active-default{ - color:highlightText !important; - background-color:highlight !important; - } -} - -@media screen and (-ms-high-contrast: white-on-black){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (inverted-colors){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (monochrome){ -} - -@media screen and (prefers-reduced-motion){ -} - :root[style*="--USER__backgroundColor"]{ background-color:var(--USER__backgroundColor) !important; } @@ -338,10 +209,36 @@ body{ zoom:var(--USER__fontSize) !important; } -:root[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ +:root:not([style*="readium-deprecatedFontSize-on"])[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ -webkit-text-size-adjust:var(--USER__fontSize) !important; } +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] table{ + zoom:calc(100% / var(--USER__fontSize)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] th{ + zoom:var(--USER__fontSize) !important; + } +} + @supports not (zoom: 1){ :root[style*="--USER__fontSize"]{ diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css index 004a75a63f..2ed2433215 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -393,6 +400,16 @@ img, svg|svg, video{ break-inside:avoid; } +@supports (zoom: 1) and (not ((-webkit-column-axis: horizontal) and (-webkit-column-progression: normal))){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] img, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] svg|svg, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] video, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div{ + zoom:calc(100% / var(--USER__fontSize)); + } +} + audio{ max-width:100%; -webkit-column-break-inside:avoid; @@ -402,5 +419,5 @@ audio{ table{ max-height:var(--RS__maxMediaWidth); - box-sizing:var(--RS__boxSizingTable) + box-sizing:var(--RS__boxSizingTable); } \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css index 065c8c1b42..2d26579faf 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css index 1a2c8fa2ce..c5a1bef48c 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -20,7 +27,7 @@ --RS__pageGutter:0; - --RS__defaultLineLength:40rem; + --RS__defaultLineLength:100%; --RS__colGap:0; @@ -64,11 +71,14 @@ body{ width:100%; max-width:var(--RS__defaultLineLength) !important; - padding:0 var(--RS__pageGutter) !important; margin:0 auto !important; box-sizing:border-box; } +:root:not([style*="readium-scroll-on"]) body{ + padding:0 var(--RS__pageGutter) !important; +} + :root:not([style*="readium-noOverflow-on"]) body{ overflow:hidden; } @@ -133,145 +143,6 @@ body{ padding-right:var(--RS__scrollPaddingRight) !important; } -:root[style*="readium-night-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#0099E5; - - --RS__linkColor:#63caff; - - --RS__textColor:#FEFEFE; - - --RS__backgroundColor:#000000; -} - -:root[style*="readium-night-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; - border-color:currentcolor !important; -} - -:root[style*="readium-night-on"] svg text{ - fill:currentcolor !important; - stroke:none !important; -} - -:root[style*="readium-night-on"] a:link, -:root[style*="readium-night-on"] a:link *{ - color:var(--RS__linkColor) !important; -} - -:root[style*="readium-night-on"] a:visited, -:root[style*="readium-night-on"] a:visited *{ - color:var(--RS__visitedColor) !important; -} - -:root[style*="readium-night-on"] img[class*="gaiji"], -:root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, -:root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:invert(100%); - filter:invert(100%); -} - -:root[style*="readium-sepia-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#551A8B; - - --RS__linkColor:#0000EE; - - --RS__textColor:#121212; - - --RS__backgroundColor:#faf4e8; -} - -:root[style*="readium-sepia-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; -} - -:root[style*="readium-sepia-on"] a:link, -:root[style*="readium-sepia-on"] a:link *{ - color:var(--RS__linkColor); -} - -:root[style*="readium-sepia-on"] a:visited, -:root[style*="readium-sepia-on"] a:visited *{ - color:var(--RS__visitedColor); -} - -@media screen and (-ms-high-contrast: active){ - - :root{ - color:windowText !important; - background-color:window !important; - } - - :root :not(#\#):not(#\#):not(#\#), - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#){ - color:inherit !important; - background-color:inherit !important; - } - - .readiumCSS-mo-active-default{ - color:highlightText !important; - background-color:highlight !important; - } -} - -@media screen and (-ms-high-contrast: white-on-black){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (inverted-colors){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (monochrome){ -} - -@media screen and (prefers-reduced-motion){ -} - :root[style*="--USER__backgroundColor"]{ background-color:var(--USER__backgroundColor) !important; } @@ -346,7 +217,15 @@ body{ } :root[style*="--USER__textAlign"] body, -:root[style*="--USER__textAlign"] p:not(blockquote p):not(figcaption p):not(hgroup p), +:root[style*="--USER__textAlign"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +), :root[style*="--USER__textAlign"] li, :root[style*="--USER__textAlign"] dd{ text-align:var(--USER__textAlign) !important; @@ -367,10 +246,36 @@ body{ zoom:var(--USER__fontSize) !important; } -:root[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ +:root:not([style*="readium-deprecatedFontSize-on"])[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ -webkit-text-size-adjust:var(--USER__fontSize) !important; } +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] table{ + zoom:calc(100% / var(--USER__fontSize)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] th{ + zoom:var(--USER__fontSize) !important; + } +} + @supports not (zoom: 1){ :root[style*="--USER__fontSize"]{ @@ -398,7 +303,15 @@ body{ margin-bottom:var(--USER__paraSpacing) !important; } -:root[style*="--USER__paraIndent"] p{ +:root[style*="--USER__paraIndent"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +){ text-indent:var(--USER__paraIndent) !important; } diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css index 6a99eca103..85c9f00a23 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -393,6 +400,15 @@ img, svg|svg, video{ break-inside:avoid; } +@supports (zoom: 1) and (not ((-webkit-column-axis: horizontal) and (-webkit-column-progression: normal))){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] img, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] svg|svg, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] video{ + zoom:calc(100% / var(--USER__fontSize)); + } +} + audio{ max-width:100%; -webkit-column-break-inside:avoid; diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css index f2702105b7..8a0a18760e 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/webPub/ReadiumCSS-webPub.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/webPub/ReadiumCSS-webPub.css new file mode 100644 index 0000000000..5b38e8b580 --- /dev/null +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/webPub/ReadiumCSS-webPub.css @@ -0,0 +1,275 @@ +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. + * Use of this source code is governed by a BSD-style license which is detailed in the + * LICENSE file present in the project repository where this source code is maintained. + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ + +:root[style*="--USER__textAlign"]{ + text-align:var(--USER__textAlign); +} + +:root[style*="--USER__textAlign"] body, +:root[style*="--USER__textAlign"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +), +:root[style*="--USER__textAlign"] li, +:root[style*="--USER__textAlign"] dd{ + text-align:var(--USER__textAlign) !important; + -moz-text-align-last:auto !important; + -epub-text-align-last:auto !important; + text-align-last:auto !important; +} + +:root[style*="--USER__bodyHyphens"]{ + -webkit-hyphens:var(--USER__bodyHyphens) !important; + -moz-hyphens:var(--USER__bodyHyphens) !important; + -ms-hyphens:var(--USER__bodyHyphens) !important; + -epub-hyphens:var(--USER__bodyHyphens) !important; + hyphens:var(--USER__bodyHyphens) !important; +} + +:root[style*="--USER__bodyHyphens"] body, +:root[style*="--USER__bodyHyphens"] p, +:root[style*="--USER__bodyHyphens"] li, +:root[style*="--USER__bodyHyphens"] div, +:root[style*="--USER__bodyHyphens"] dd{ + -webkit-hyphens:inherit; + -moz-hyphens:inherit; + -ms-hyphens:inherit; + -epub-hyphens:inherit; + hyphens:inherit; +} + +:root[style*="--USER__fontFamily"]{ + font-family:var(--USER__fontFamily) !important; +} + +:root[style*="--USER__fontFamily"] *{ + font-family:revert !important; +} + +:root[style*="readium-a11y-on"]{ + font-style:normal !important; + font-weight:normal !important; +} + +:root[style*="readium-a11y-on"] body *:not(code):not(var):not(kbd):not(samp){ + font-family:inherit !important; + font-style:inherit !important; + font-weight:inherit !important; +} + +:root[style*="readium-a11y-on"] body *:not(a){ + text-decoration:none !important; +} + +:root[style*="readium-a11y-on"] body *{ + font-variant-caps:normal !important; + font-variant-numeric:normal !important; + font-variant-position:normal !important; +} + +:root[style*="readium-a11y-on"] sup, +:root[style*="readium-a11y-on"] sub{ + font-size:1rem !important; + vertical-align:baseline !important; +} + +:root:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] body{ + zoom:var(--USER__zoom) !important; +} + +:root[style*="readium-iOSPatch-on"][style*="--USER__zoom"] body{ + -webkit-text-size-adjust:var(--USER__zoom) !important; +} + +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] table{ + zoom:calc(100% / var(--USER__zoom)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] th{ + zoom:var(--USER__zoom) !important; + } +} + +:root[style*="--USER__lineHeight"]{ + line-height:var(--USER__lineHeight) !important; +} + +:root[style*="--USER__lineHeight"] body, +:root[style*="--USER__lineHeight"] p, +:root[style*="--USER__lineHeight"] li, +:root[style*="--USER__lineHeight"] div{ + line-height:inherit; +} + +:root[style*="--USER__paraSpacing"] p{ + margin-top:var(--USER__paraSpacing) !important; + margin-bottom:var(--USER__paraSpacing) !important; +} + +:root[style*="--USER__paraIndent"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +){ + text-indent:var(--USER__paraIndent) !important; +} + +:root[style*="--USER__paraIndent"] p *, +:root[style*="--USER__paraIndent"] p:first-letter{ + text-indent:0 !important; +} + +:root[style*="--USER__wordSpacing"] h1, +:root[style*="--USER__wordSpacing"] h2, +:root[style*="--USER__wordSpacing"] h3, +:root[style*="--USER__wordSpacing"] h4, +:root[style*="--USER__wordSpacing"] h5, +:root[style*="--USER__wordSpacing"] h6, +:root[style*="--USER__wordSpacing"] p, +:root[style*="--USER__wordSpacing"] li, +:root[style*="--USER__wordSpacing"] div, +:root[style*="--USER__wordSpacing"] dt, +:root[style*="--USER__wordSpacing"] dd{ + word-spacing:var(--USER__wordSpacing); +} + +:root[style*="--USER__letterSpacing"] h1, +:root[style*="--USER__letterSpacing"] h2, +:root[style*="--USER__letterSpacing"] h3, +:root[style*="--USER__letterSpacing"] h4, +:root[style*="--USER__letterSpacing"] h5, +:root[style*="--USER__letterSpacing"] h6, +:root[style*="--USER__letterSpacing"] p, +:root[style*="--USER__letterSpacing"] li, +:root[style*="--USER__letterSpacing"] div, +:root[style*="--USER__letterSpacing"] dt, +:root[style*="--USER__letterSpacing"] dd{ + letter-spacing:var(--USER__letterSpacing); + font-variant:none; +} + +:root[style*="--USER__fontWeight"] body{ + font-weight:var(--USER__fontWeight) !important; +} + +:root[style*="--USER__fontWeight"] b, +:root[style*="--USER__fontWeight"] strong{ + font-weight:bolder; +} + +:root[style*="--USER__fontWidth"] body{ + font-stretch:var(--USER__fontWidth) !important; +} + +:root[style*="--USER__fontOpticalSizing"] body{ + font-optical-sizing:var(--USER__fontOpticalSizing) !important; +} + +:root[style*="readium-noRuby-on"] body rt, +:root[style*="readium-noRuby-on"] body rp{ + display:none; +} + +:root[style*="--USER__ligatures"]{ + font-variant-ligatures:var(--USER__ligatures) !important; +} + +:root[style*="--USER__ligatures"] *{ + font-variant-ligatures:inherit !important; +} + +:root[style*="readium-iPadOSPatch-on"] body{ + -webkit-text-size-adjust:none; +} + +:root[style*="readium-iPadOSPatch-on"] p, +:root[style*="readium-iPadOSPatch-on"] h1, +:root[style*="readium-iPadOSPatch-on"] h2, +:root[style*="readium-iPadOSPatch-on"] h3, +:root[style*="readium-iPadOSPatch-on"] h4, +:root[style*="readium-iPadOSPatch-on"] h5, +:root[style*="readium-iPadOSPatch-on"] h6, +:root[style*="readium-iPadOSPatch-on"] li, +:root[style*="readium-iPadOSPatch-on"] th, +:root[style*="readium-iPadOSPatch-on"] td, +:root[style*="readium-iPadOSPatch-on"] dt, +:root[style*="readium-iPadOSPatch-on"] dd, +:root[style*="readium-iPadOSPatch-on"] pre, +:root[style*="readium-iPadOSPatch-on"] address, +:root[style*="readium-iPadOSPatch-on"] details, +:root[style*="readium-iPadOSPatch-on"] summary, +:root[style*="readium-iPadOSPatch-on"] figcaption, +:root[style*="readium-iPadOSPatch-on"] div:not(:has(p, h1, h2, h3, h4, h5, h6, li, th, td, dt, dd, pre, address, aside, details, figcaption, summary)), +:root[style*="readium-iPadOSPatch-on"] aside:not(:has(p, h1, h2, h3, h4, h5, h6, li, th, td, dt, dd, pre, address, aside, details, figcaption, summary)){ + -webkit-text-zoom:reset; +} + +:root[style*="readium-iPadOSPatch-on"] abbr, +:root[style*="readium-iPadOSPatch-on"] b, +:root[style*="readium-iPadOSPatch-on"] bdi, +:root[style*="readium-iPadOSPatch-on"] bdo, +:root[style*="readium-iPadOSPatch-on"] cite, +:root[style*="readium-iPadOSPatch-on"] code, +:root[style*="readium-iPadOSPatch-on"] dfn, +:root[style*="readium-iPadOSPatch-on"] em, +:root[style*="readium-iPadOSPatch-on"] i, +:root[style*="readium-iPadOSPatch-on"] kbd, +:root[style*="readium-iPadOSPatch-on"] mark, +:root[style*="readium-iPadOSPatch-on"] q, +:root[style*="readium-iPadOSPatch-on"] rp, +:root[style*="readium-iPadOSPatch-on"] rt, +:root[style*="readium-iPadOSPatch-on"] ruby, +:root[style*="readium-iPadOSPatch-on"] s, +:root[style*="readium-iPadOSPatch-on"] samp, +:root[style*="readium-iPadOSPatch-on"] small, +:root[style*="readium-iPadOSPatch-on"] span, +:root[style*="readium-iPadOSPatch-on"] strong, +:root[style*="readium-iPadOSPatch-on"] sub, +:root[style*="readium-iPadOSPatch-on"] sup, +:root[style*="readium-iPadOSPatch-on"] time, +:root[style*="readium-iPadOSPatch-on"] u, +:root[style*="readium-iPadOSPatch-on"] var{ + -webkit-text-zoom:normal; +} + +:root[style*="readium-iPadOSPatch-on"] p:not(:has(b, cite, em, i, q, s, small, span, strong)):first-line{ + -webkit-text-zoom:normal; +} \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Scripts/package.json b/Sources/Navigator/EPUB/Scripts/package.json index 4aad9b38ec..bd7814bf9a 100644 --- a/Sources/Navigator/EPUB/Scripts/package.json +++ b/Sources/Navigator/EPUB/Scripts/package.json @@ -7,6 +7,7 @@ "private": true, "scripts": { "bundle": "webpack", + "bundle:minify": "MINIFY_CSS=true webpack", "lint": "eslint src", "checkformat": "prettier --check '**/*.js'", "format": "prettier --list-different --write '**/*.js'" @@ -17,7 +18,10 @@ "devDependencies": { "@babel/core": "^7.23.9", "@babel/preset-env": "^7.23.9", + "@readium/css": "^2.0.0", "babel-loader": "^8.3.0", + "clean-css": "^5.3.3", + "copy-webpack-plugin": "^14.0.0", "eslint": "^7.32.0", "prettier": "2.3.1", "webpack": "^5.90.1", diff --git a/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml b/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml index 75eb2605e6..27b19169e8 100644 --- a/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml +++ b/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml @@ -25,9 +25,18 @@ devDependencies: '@babel/preset-env': specifier: ^7.23.9 version: 7.23.9(@babel/core@7.23.9) + '@readium/css': + specifier: ^2.0.0 + version: 2.0.0 babel-loader: specifier: ^8.3.0 version: 8.3.0(@babel/core@7.23.9)(webpack@5.90.1) + clean-css: + specifier: ^5.3.3 + version: 5.3.3 + copy-webpack-plugin: + specifier: ^14.0.0 + version: 14.0.0(webpack@5.90.1) eslint: specifier: ^7.32.0 version: 7.32.0 @@ -1305,6 +1314,10 @@ packages: resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} dev: false + /@readium/css@2.0.0: + resolution: {integrity: sha512-Cr+AlHf5JqdwWPQ3ihw4HzsN3BkYyhOnD/fx6/+Y1QrTr1cIj1/xpGmixYyxn3kfLME00CX+OfhJeScYB1ANIw==} + dev: true + /@types/eslint-scope@3.7.7: resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} dependencies: @@ -1512,6 +1525,17 @@ packages: hasBin: true dev: true + /ajv-formats@2.1.1(ajv@8.12.0): + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.12.0 + dev: true + /ajv-keywords@3.5.2(ajv@6.12.6): resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -1520,6 +1544,15 @@ packages: ajv: 6.12.6 dev: true + /ajv-keywords@5.1.0(ajv@8.12.0): + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + dependencies: + ajv: 8.12.0 + fast-deep-equal: 3.1.3 + dev: true + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -1726,6 +1759,13 @@ packages: engines: {node: '>=6.0'} dev: true + /clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + dependencies: + source-map: 0.6.1 + dev: true + /clone-deep@4.0.1: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} @@ -1781,6 +1821,20 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /copy-webpack-plugin@14.0.0(webpack@5.90.1): + resolution: {integrity: sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==} + engines: {node: '>= 20.9.0'} + peerDependencies: + webpack: ^5.1.0 + dependencies: + glob-parent: 6.0.2 + normalize-path: 3.0.0 + schema-utils: 4.3.3 + serialize-javascript: 7.0.3 + tinyglobby: 0.2.15 + webpack: 5.90.1(webpack-cli@5.1.4) + dev: true + /core-js-compat@3.35.1: resolution: {integrity: sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==} dependencies: @@ -2104,6 +2158,18 @@ packages: engines: {node: '>= 4.9.1'} dev: true + /fdir@6.5.0(picomatch@4.0.3): + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.3 + dev: true + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -2209,6 +2275,13 @@ packages: is-glob: 4.0.3 dev: true + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + /glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} dev: true @@ -2646,6 +2719,11 @@ packages: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} dev: true + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + /object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} dev: false @@ -2732,6 +2810,11 @@ packages: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} dev: true + /picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + dev: true + /pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -2907,6 +2990,16 @@ packages: ajv-keywords: 3.5.2(ajv@6.12.6) dev: true + /schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + ajv-keywords: 5.1.0(ajv@8.12.0) + dev: true + /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2926,6 +3019,11 @@ packages: randombytes: 2.1.0 dev: true + /serialize-javascript@7.0.3: + resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==} + engines: {node: '>=20.0.0'} + dev: true + /set-function-length@1.2.1: resolution: {integrity: sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==} engines: {node: '>= 0.4'} @@ -3142,6 +3240,14 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + dev: true + /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} diff --git a/Sources/Navigator/EPUB/Scripts/webpack.config.js b/Sources/Navigator/EPUB/Scripts/webpack.config.js index d676772c65..ea411824d9 100644 --- a/Sources/Navigator/EPUB/Scripts/webpack.config.js +++ b/Sources/Navigator/EPUB/Scripts/webpack.config.js @@ -1,4 +1,6 @@ const path = require("path"); +const CopyPlugin = require("copy-webpack-plugin"); +const CleanCSS = require("clean-css"); module.exports = { mode: "production", @@ -28,4 +30,26 @@ module.exports = { }, ], }, + plugins: [ + new CopyPlugin({ + patterns: [ + { + from: "node_modules/@readium/css/css/dist", + to: "../readium-css", + transform(content, path) { + if (path.endsWith(".css") && process.env.MINIFY_CSS === "true") { + return new CleanCSS({ + level: { + 1: { + specialComments: 0, + }, + }, + }).minify(content).styles; + } + return content; + }, + }, + ], + }), + ], }; diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index c36b6312c0..3658b8ec3c 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -331,6 +331,7 @@ ../../Sources/Internal/Measure.swift ../../Sources/Internal/UTI.swift ../../Sources/LCP +../../Sources/LCP/.DS_Store ../../Sources/LCP/Authentications ../../Sources/LCP/Authentications/LCPAuthenticating.swift ../../Sources/LCP/Authentications/LCPDialog.swift @@ -398,6 +399,7 @@ ../../Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift ../../Sources/LCP/Toolkit/ReadResult.swift ../../Sources/Navigator +../../Sources/Navigator/.DS_Store ../../Sources/Navigator/Audiobook ../../Sources/Navigator/Audiobook/AudioNavigator.swift ../../Sources/Navigator/Audiobook/Preferences @@ -414,6 +416,7 @@ ../../Sources/Navigator/DirectionalNavigationAdapter.swift ../../Sources/Navigator/EditingAction.swift ../../Sources/Navigator/EPUB +../../Sources/Navigator/EPUB/.DS_Store ../../Sources/Navigator/EPUB/Assets ../../Sources/Navigator/EPUB/Assets/fxl-spread-one.html ../../Sources/Navigator/EPUB/Assets/fxl-spread-two.html @@ -433,10 +436,14 @@ ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts -../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA.otf +../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA-Bold.woff2 +../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA-Italic.woff2 +../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA-Regular.woff +../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA-Regular.woff2 ../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/iAWriterDuospace-Regular.ttf ../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/LICENSE-AccessibleDfa ../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/LICENSE-IaWriterDuospace.md +../../Sources/Navigator/EPUB/Assets/Static/readium-css/HEAD ../../Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css @@ -446,6 +453,8 @@ ../../Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css +../../Sources/Navigator/EPUB/Assets/Static/readium-css/webPub +../../Sources/Navigator/EPUB/Assets/Static/readium-css/webPub/ReadiumCSS-webPub.css ../../Sources/Navigator/EPUB/Assets/Static/scripts ../../Sources/Navigator/EPUB/Assets/Static/scripts/.gitignore ../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js @@ -476,6 +485,7 @@ ../../Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift ../../Sources/Navigator/EPUB/Preferences/EPUBSettings.swift ../../Sources/Navigator/EPUB/Scripts +../../Sources/Navigator/EPUB/Scripts/.DS_Store ../../Sources/Navigator/EPUB/Scripts/.eslintrc.json ../../Sources/Navigator/EPUB/Scripts/.gitignore ../../Sources/Navigator/EPUB/Scripts/.prettierignore @@ -596,6 +606,7 @@ ../../Sources/OPDS/URLHelper.swift ../../Sources/OPDS/XMLNamespace.swift ../../Sources/Shared +../../Sources/Shared/.DS_Store ../../Sources/Shared/Logger ../../Sources/Shared/Logger/Loggable.swift ../../Sources/Shared/Logger/Logger.swift @@ -827,6 +838,7 @@ ../../Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift ../../Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift ../../Sources/Streamer +../../Sources/Streamer/.DS_Store ../../Sources/Streamer/Assets ../../Sources/Streamer/Assets/fonts ../../Sources/Streamer/Assets/fonts/OpenDyslexic-Regular.otf From 39616c9cebf174e1bd885f9a631c887c5ffde54f Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:30:03 -0600 Subject: [PATCH 50/55] Run format --- .../Navigator/EPUB/CSS/CSSProperties.swift | 4 ++- .../EPUB/Preferences/EPUBPreferences.swift | 20 ++++++++--- .../Preferences/EPUBPreferencesEditor.swift | 16 ++++++--- .../EPUB/Preferences/EPUBSettings.swift | 36 ++++++++++++++----- .../Common/Preferences/UserPreferences.swift | 2 +- 5 files changed, 58 insertions(+), 20 deletions(-) diff --git a/Sources/Navigator/EPUB/CSS/CSSProperties.swift b/Sources/Navigator/EPUB/CSS/CSSProperties.swift index 3181efb1e5..3881ac6711 100644 --- a/Sources/Navigator/EPUB/CSS/CSSProperties.swift +++ b/Sources/Navigator/EPUB/CSS/CSSProperties.swift @@ -528,7 +528,9 @@ public struct CSSPercent: CSSConvertible { self.value = value } - public func css() -> String? { (value * 100).css(unit: "%") } + public func css() -> String? { + (value * 100).css(unit: "%") + } } public protocol CSSColor: CSSConvertible {} diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index 59e10adf4a..d2a22aaf4a 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift @@ -225,16 +225,24 @@ public struct EPUBPreferences: ConfigurablePreferences { } @available(*, unavailable, message: "Use lineLength instead") - public var pageMargins: Double? { nil } + public var pageMargins: Double? { + nil + } @available(*, unavailable, message: "Not available anymore") - public var typeScale: Double? { nil } + public var typeScale: Double? { + nil + } @available(*, unavailable, message: "Not needed anymore") - public var publisherStyles: Bool? { nil } + public var publisherStyles: Bool? { + nil + } @available(*, unavailable, message: "Use invertImages or darkenImages instead") - public var imageFilter: ImageFilter? { nil } + public var imageFilter: ImageFilter? { + nil + } @available(*, unavailable, message: "Use the other initializer") public init( @@ -263,5 +271,7 @@ public struct EPUBPreferences: ConfigurablePreferences { typeScale: Double? = nil, verticalText: Bool? = nil, wordSpacing: Double? = nil - ) { fatalError() } + ) { + fatalError() + } } diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift index bdbe8e00f6..58a238e055 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift @@ -483,14 +483,22 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor { fatalError() } + public var pageMargins: AnyRangePreference { + fatalError() + } @available(*, unavailable, message: "Not available anymore") - public var typeScale: AnyRangePreference { fatalError() } + public var typeScale: AnyRangePreference { + fatalError() + } @available(*, unavailable, message: "Not needed anymore") - public var publisherStyles: AnyPreference { fatalError() } + public var publisherStyles: AnyPreference { + fatalError() + } @available(*, unavailable, message: "Use darkenImages and invertImages instead") - public var imageFilter: AnyEnumPreference { fatalError() } + public var imageFilter: AnyEnumPreference { + fatalError() + } } diff --git a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift index ddc75d87d4..ba374ce0e6 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift @@ -199,13 +199,19 @@ public struct EPUBSettings: ConfigurableSettings { } @available(*, unavailable, message: "Not supported anymore") - public var typeScale: Double? { nil } + public var typeScale: Double? { + nil + } @available(*, unavailable, message: "Use lineLength") - public var pageMargins: Double? { nil } + public var pageMargins: Double? { + nil + } @available(*, unavailable, message: "Not needed anymore") - public var publisherStyles: Bool? { nil } + public var publisherStyles: Bool? { + nil + } @available(*, unavailable, message: "Use the other initializer") public init( @@ -234,7 +240,9 @@ public struct EPUBSettings: ConfigurableSettings { typeScale: Double?, verticalText: Bool, wordSpacing: Double? - ) { fatalError() } + ) { + fatalError() + } } /// Default setting values for the EPUB navigator. @@ -307,16 +315,24 @@ public struct EPUBDefaults { } @available(*, unavailable, message: "Use lineLength instead") - public var pageMargins: Double? { nil } + public var pageMargins: Double? { + nil + } @available(*, unavailable, message: "Not supported anymore") - public var typeScale: Double? { nil } + public var typeScale: Double? { + nil + } @available(*, unavailable, message: "Not needed anymore") - public var publisherStyles: Bool? { nil } + public var publisherStyles: Bool? { + nil + } @available(*, unavailable, message: "Not supported anymore as a defaults") - public var imageFilter: ImageFilter? { nil } + public var imageFilter: ImageFilter? { + nil + } @available(*, unavailable, message: "Use the other initializer") public init( @@ -342,7 +358,9 @@ public struct EPUBDefaults { textNormalization: Bool? = nil, typeScale: Double? = nil, wordSpacing: Double? = nil - ) { fatalError() } + ) { + fatalError() + } } private extension Language { diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index 6d9a41b775..95ab1c1918 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -381,7 +381,7 @@ struct UserPreferences< stepperRow( title: "Columns", preference: columnCount, - commit: commit, + commit: commit ) } } From e8b601237215bcdbfc004d05b41a5187ff9678e2 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:00:47 -0600 Subject: [PATCH 51/55] Fix tests --- .../EPUB/CSS/CSSRSPropertiesTests.swift | 7 +- .../EPUB/CSS/CSSUserPropertiesTests.swift | 89 +++++++++---------- 2 files changed, 44 insertions(+), 52 deletions(-) diff --git a/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift b/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift index f77423b170..87fb112143 100644 --- a/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift @@ -33,7 +33,6 @@ class CSSRSPropertiesTests: XCTestCase { "--RS__visitedColor": nil, "--RS__primaryColor": nil, "--RS__secondaryColor": nil, - "--RS__typeScale": nil, "--RS__baseFontFamily": nil, "--RS__baseLineHeight": nil, "--RS__oldStyleTf": nil, @@ -53,7 +52,7 @@ class CSSRSPropertiesTests: XCTestCase { func testOverrideProperties() { let props = CSSRSProperties( - colCount: .one, + colCount: 1, overrides: [ "--RS__colCount": "2", "--RS__custom": "value", @@ -68,7 +67,7 @@ class CSSRSPropertiesTests: XCTestCase { XCTAssertEqual( CSSRSProperties( colWidth: CSSCmLength(1.2), - colCount: .two, + colCount: 2, colGap: CSSPtLength(2.3), pageGutter: CSSPcLength(3.4), flowSpacing: CSSMmLength(4.5), @@ -87,7 +86,6 @@ class CSSRSPropertiesTests: XCTestCase { visitedColor: CSSHexColor("#0000FF"), primaryColor: CSSHexColor("#FA4358"), secondaryColor: CSSHexColor("#CBC322"), - typeScale: 10.11, baseFontFamily: ["Palatino", "Comic Sans MS"], baseLineHeight: .length(CSSVhLength(11.12)), oldStyleTf: ["Old", "Style"], @@ -123,7 +121,6 @@ class CSSRSPropertiesTests: XCTestCase { "--RS__visitedColor": "#0000FF", "--RS__primaryColor": "#FA4358", "--RS__secondaryColor": "#CBC322", - "--RS__typeScale": "10.11000", "--RS__baseFontFamily": #"Palatino, "Comic Sans MS""#, "--RS__baseLineHeight": "11.12000vh", "--RS__oldStyleTf": #"Old, Style"#, diff --git a/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift b/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift index 61cdc151b1..6d2e4441fe 100644 --- a/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift @@ -15,19 +15,18 @@ class CSSUserPropertiesTests: XCTestCase { [ "--USER__view": nil, "--USER__colCount": nil, - "--USER__pageMargins": nil, "--USER__appearance": nil, + "--USER__blendImages": nil, "--USER__darkenImages": nil, "--USER__invertImages": nil, + "--USER__invertGaiji": nil, "--USER__textColor": nil, "--USER__backgroundColor": nil, - "--USER__fontOverride": nil, "--USER__fontFamily": nil, "--USER__fontSize": nil, - "--USER__advancedSettings": nil, - "--USER__typeScale": nil, "--USER__textAlign": nil, "--USER__lineHeight": nil, + "--USER__lineLength": nil, "--USER__paraSpacing": nil, "--USER__paraIndent": nil, "--USER__wordSpacing": nil, @@ -43,21 +42,20 @@ class CSSUserPropertiesTests: XCTestCase { XCTAssertEqual( CSSUserProperties( view: .scroll, - colCount: .auto, - pageMargins: 1.2, + colCount: 2, appearance: .night, - darkenImages: true, - invertImages: true, + blendImages: true, + darkenImages: CSSPercent(0.5), + invertImages: CSSPercent(0.5), + invertGaiji: CSSPercent(0.5), textColor: CSSHexColor("#FF0000"), backgroundColor: CSSHexColor("#00FF00"), - fontOverride: true, fontFamily: ["Times New"], - fontSize: CSSVMaxLength(2.3), - advancedSettings: true, - typeScale: 3.4, + fontSize: CSSPxLength(12), textAlign: .justify, - lineHeight: .length(CSSPtLength(4.5)), - paraSpacing: CSSPtLength(5.6), + lineLength: CSSPxLength(500), + lineHeight: .unitless(1.2), + paraSpacing: CSSPxLength(5.6), paraIndent: CSSRemLength(6.7), wordSpacing: CSSRemLength(7.8), letterSpacing: CSSRemLength(8.9), @@ -67,21 +65,20 @@ class CSSUserPropertiesTests: XCTestCase { ).cssProperties(), [ "--USER__view": "readium-scroll-on", - "--USER__colCount": "auto", - "--USER__pageMargins": "1.20000", + "--USER__colCount": "2", "--USER__appearance": "readium-night-on", - "--USER__darkenImages": "readium-darken-on", - "--USER__invertImages": "readium-invert-on", + "--USER__blendImages": "readium-blend-on", + "--USER__darkenImages": "50.00000%", + "--USER__invertImages": "50.00000%", + "--USER__invertGaiji": "50.00000%", "--USER__textColor": "#FF0000", "--USER__backgroundColor": "#00FF00", - "--USER__fontOverride": "readium-font-on", "--USER__fontFamily": "\"Times New\"", - "--USER__fontSize": "2.30000vmax", - "--USER__advancedSettings": "readium-advanced-on", - "--USER__typeScale": "3.40000", + "--USER__fontSize": "12.00000px", "--USER__textAlign": "justify", - "--USER__lineHeight": "4.50000pt", - "--USER__paraSpacing": "5.60000pt", + "--USER__lineLength": "500.00000px", + "--USER__lineHeight": "1.20000", + "--USER__paraSpacing": "5.60000px", "--USER__paraIndent": "6.70000rem", "--USER__wordSpacing": "7.80000rem", "--USER__letterSpacing": "8.90000rem", @@ -94,7 +91,7 @@ class CSSUserPropertiesTests: XCTestCase { func testOverrideUserProperties() { let props = CSSUserProperties( - colCount: .one, + colCount: 1, overrides: [ "--USER__colCount": "2", "--USER__custom": "value", @@ -113,10 +110,10 @@ class CSSUserPropertiesTests: XCTestCase { XCTAssertEqual( CSSUserProperties( view: .scroll, - colCount: .auto + colCount: 2 ).css(), """ - --USER__colCount: auto !important; + --USER__colCount: 2 !important; --USER__view: readium-scroll-on !important; """ @@ -127,21 +124,20 @@ class CSSUserPropertiesTests: XCTestCase { XCTAssertEqual( CSSUserProperties( view: .scroll, - colCount: .auto, - pageMargins: 1.2, + colCount: 2, appearance: .night, - darkenImages: true, - invertImages: true, + blendImages: true, + darkenImages: CSSPercent(0.5), + invertImages: CSSPercent(0.5), + invertGaiji: CSSPercent(0.5), textColor: CSSHexColor("#FF0000"), backgroundColor: CSSHexColor("#00FF00"), - fontOverride: true, fontFamily: ["Times New", "Comic Sans"], - fontSize: CSSVMaxLength(2.3), - advancedSettings: true, - typeScale: 3.4, + fontSize: CSSPxLength(12), textAlign: .justify, - lineHeight: .length(CSSPtLength(4.5)), - paraSpacing: CSSPtLength(5.6), + lineLength: CSSPxLength(500), + lineHeight: .unitless(1.2), + paraSpacing: CSSPxLength(5.6), paraIndent: CSSRemLength(6.7), wordSpacing: CSSRemLength(7.8), letterSpacing: CSSRemLength(8.9), @@ -151,25 +147,24 @@ class CSSUserPropertiesTests: XCTestCase { ).css(), """ --USER__a11yNormalize: readium-a11y-on !important; - --USER__advancedSettings: readium-advanced-on !important; --USER__appearance: readium-night-on !important; --USER__backgroundColor: #00FF00 !important; + --USER__blendImages: readium-blend-on !important; --USER__bodyHyphens: auto !important; - --USER__colCount: auto !important; - --USER__darkenImages: readium-darken-on !important; + --USER__colCount: 2 !important; + --USER__darkenImages: 50.00000% !important; --USER__fontFamily: "Times New", "Comic Sans" !important; - --USER__fontOverride: readium-font-on !important; - --USER__fontSize: 2.30000vmax !important; - --USER__invertImages: readium-invert-on !important; + --USER__fontSize: 12.00000px !important; + --USER__invertGaiji: 50.00000% !important; + --USER__invertImages: 50.00000% !important; --USER__letterSpacing: 8.90000rem !important; --USER__ligatures: common-ligatures !important; - --USER__lineHeight: 4.50000pt !important; - --USER__pageMargins: 1.20000 !important; + --USER__lineHeight: 1.20000 !important; + --USER__lineLength: 500.00000px !important; --USER__paraIndent: 6.70000rem !important; - --USER__paraSpacing: 5.60000pt !important; + --USER__paraSpacing: 5.60000px !important; --USER__textAlign: justify !important; --USER__textColor: #FF0000 !important; - --USER__typeScale: 3.40000 !important; --USER__view: readium-scroll-on !important; --USER__wordSpacing: 7.80000rem !important; From 617f9318599e0bbe07ebda5252f6633dc27df1e9 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:36:33 -0600 Subject: [PATCH 52/55] Add `JSONValue` and `UncheckedSendable` (#736) --- Sources/Shared/Toolkit/Data/ReadResult.swift | 38 +++ Sources/Shared/Toolkit/JSONValue.swift | 271 ++++++++++++++++++ .../Shared/Toolkit/UncheckedSendable.swift | 18 ++ Support/Carthage/.xcodegen | 2 + .../Readium.xcodeproj/project.pbxproj | 8 + .../SharedTests/Toolkit/JSONValueTests.swift | 238 +++++++++++++++ 6 files changed, 575 insertions(+) create mode 100644 Sources/Shared/Toolkit/JSONValue.swift create mode 100644 Sources/Shared/Toolkit/UncheckedSendable.swift create mode 100644 Tests/SharedTests/Toolkit/JSONValueTests.swift diff --git a/Sources/Shared/Toolkit/Data/ReadResult.swift b/Sources/Shared/Toolkit/Data/ReadResult.swift index 18dbb0f625..4ed0e3a3e8 100644 --- a/Sources/Shared/Toolkit/Data/ReadResult.swift +++ b/Sources/Shared/Toolkit/Data/ReadResult.swift @@ -41,6 +41,16 @@ public extension ReadResult { func asXML(using factory: XMLDocumentFactory, namespaces: [XMLNamespace] = []) -> ReadResult { decode { try $0.asXML(using: factory, namespaces: namespaces) } } + + /// Decodes the data as a `JSONValue`. + func asJSONValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + decode { try $0.asJSONValue(options: options) } + } + + /// Decodes the data as a JSON object. + func asJSONObjectValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: JSONValue]> { + decode { try $0.asJSONObjectValue(options: options) } + } } public extension ReadResult { @@ -80,6 +90,16 @@ public extension ReadResult { func asXML(using factory: XMLDocumentFactory, namespaces: [XMLNamespace] = []) -> ReadResult { decode { try $0.asXML(using: factory, namespaces: namespaces) } } + + /// Decodes the data as a `JSONValue`. + func asJSONValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + decode { try $0.asJSONValue(options: options) } + } + + /// Decodes the data as a JSON object. + func asJSONObjectValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: JSONValue]?> { + decode { try $0.asJSONObjectValue(options: options) } + } } private extension Data { @@ -108,4 +128,22 @@ private extension Data { func asXML(using factory: XMLDocumentFactory, namespaces: [XMLNamespace] = []) throws -> XMLDocument { try factory.open(data: self, namespaces: namespaces) } + + /// Decodes the data as a `JSONValue`. + func asJSONValue(options: JSONSerialization.ReadingOptions = []) throws -> JSONValue { + let json = try JSONSerialization.jsonObject(with: self, options: options) + guard let value = JSONValue(json) else { + throw JSONError.parsing(JSONValue.self) + } + return value + } + + /// Decodes the data as a JSON object. + func asJSONObjectValue(options: JSONSerialization.ReadingOptions = []) throws -> [String: JSONValue] { + let value = try asJSONValue(options: options) + guard let dict = value.object else { + throw JSONError.parsing([String: JSONValue].self) + } + return dict + } } diff --git a/Sources/Shared/Toolkit/JSONValue.swift b/Sources/Shared/Toolkit/JSONValue.swift new file mode 100644 index 0000000000..d311cc3f4d --- /dev/null +++ b/Sources/Shared/Toolkit/JSONValue.swift @@ -0,0 +1,271 @@ +// +// 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 CoreFoundation +import Foundation + +/// A type-safe JSON value. +/// +/// This enum is used to represent JSON values in a type-safe way, avoiding the +/// use of `any Sendable` or `Any`. It guarantees that the value is Sendable and +/// Hashable. +public enum JSONValue: Sendable, Hashable, Loggable { + case null + case bool(Bool) + case string(String) + case integer(Int) + case double(Double) + case array([JSONValue]) + case object([String: JSONValue]) + + /// Initializes a `JSONValue` from an `Any` value. + /// + /// This initializer attempts to convert the given value to a `JSONValue`. + /// It handles nested arrays and dictionaries recursively. + public init?(_ value: Any?) { + guard let value = value else { + return nil + } + + if let value = value as? JSONValue { + self = value + return + } + + // Fast path for typed collections + if let object = value as? [String: JSONValue] { + self = .object(object) + return + } + if let array = value as? [JSONValue] { + self = .array(array) + return + } + + // Check for specific types + if let string = value as? String { + self = .string(string) + return + } + + // On platforms with CoreFoundation (Apple), NSNumber bridges from Bool, Int, Double. + if let number = value as? NSNumber { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + self = .bool(number.boolValue) + return + } + if CFNumberIsFloatType(number) { + self = .double(number.doubleValue) + return + } + if number.compare(0) == .orderedAscending { + self = .integer(Int(clamping: number.int64Value)) + } else { + self = .integer(Int(clamping: number.uint64Value)) + } + return + } + + if let array = value as? [Any] { + self = .array(array.compactMap { + let element = JSONValue($0) + if element == nil { + Self.log(.warning, "JSONValue: unsupported element type \(type(of: $0))") + } + return element + }) + } else if let dict = value as? [String: Any] { + var object: [String: JSONValue] = [:] + for (key, val) in dict { + guard let jsonVal = JSONValue(val) else { + Self.log(.warning, "JSONValue: unsupported element type \(type(of: val))") + continue + } + object[key] = jsonVal + } + self = .object(object) + } else if value is NSNull { + self = .null + } else { + return nil + } + } + + /// Returns the raw value as `Any`. + /// + /// This property is useful for interoperability with APIs that expect + /// standard Swift types (e.g., `JSONSerialization`). + public var any: Any { + switch self { + case .null: + return NSNull() + case let .bool(value): + return value + case let .string(value): + return value + case let .integer(value): + return value + case let .double(value): + return value + case let .array(value): + return value.map(\.any) + case let .object(value): + return value.mapValues(\.any) + } + } + + public var bool: Bool? { + if case let .bool(v) = self { return v } + return nil + } + + public var string: String? { + if case let .string(v) = self { return v } + return nil + } + + public var integer: Int? { + if case let .integer(v) = self { return v } + return nil + } + + public var double: Double? { + if case let .double(v) = self { return v } + if case let .integer(v) = self { return Double(v) } + return nil + } + + public var array: [JSONValue]? { + if case let .array(v) = self { return v } + return nil + } + + public var object: [String: JSONValue]? { + if case let .object(v) = self { return v } + return nil + } +} + +// MARK: - ExpressibleByLiteral Conformance + +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .integer(value) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} + +// MARK: - Codable Conformance + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + // Always try to decode Nil first + if container.decodeNil() { + self = .null + return + } + + // Attempt to decode boolean + if let boolValue = try? container.decode(Bool.self) { + self = .bool(boolValue) + return + } + + // Attempt to decode Int + if let intValue = try? container.decode(Int.self) { + self = .integer(intValue) + return + } + + // Attempt to decode floating point numbers + if let doubleValue = try? container.decode(Double.self) { + self = .double(doubleValue) + return + } + + // Attempt to decode string + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + return + } + + // Attempt to decode array + if let arrayValue = try? container.decode([JSONValue].self) { + self = .array(arrayValue) + return + } + + // Attempt to decode object + if let objectValue = try? container.decode([String: JSONValue].self) { + self = .object(objectValue) + return + } + + // If all attempts fail, throw an error + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Data cannot be decoded as a valid JSONValue." + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .integer(value): + try container.encode(value) + case let .double(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } +} diff --git a/Sources/Shared/Toolkit/UncheckedSendable.swift b/Sources/Shared/Toolkit/UncheckedSendable.swift new file mode 100644 index 0000000000..34534a8bc6 --- /dev/null +++ b/Sources/Shared/Toolkit/UncheckedSendable.swift @@ -0,0 +1,18 @@ +// +// 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 + +/// A wrapper to force a value to be `Sendable`. +/// +/// **Warning**: Use this wrapper only if you are sure that the value is thread-safe. +package struct UncheckedSendable: @unchecked Sendable { + package let value: T + + package init(_ value: T) { + self.value = value + } +} diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index c36b6312c0..11ca0450ad 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -784,6 +784,7 @@ ../../Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift ../../Sources/Shared/Toolkit/HTTP/HTTPServer.swift ../../Sources/Shared/Toolkit/JSON.swift +../../Sources/Shared/Toolkit/JSONValue.swift ../../Sources/Shared/Toolkit/Language.swift ../../Sources/Shared/Toolkit/Logging ../../Sources/Shared/Toolkit/Logging/WarningLogger.swift @@ -800,6 +801,7 @@ ../../Sources/Shared/Toolkit/Tokenizer ../../Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift ../../Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift +../../Sources/Shared/Toolkit/UncheckedSendable.swift ../../Sources/Shared/Toolkit/URL ../../Sources/Shared/Toolkit/URL/Absolute URL ../../Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 14cdf8562b..c1e63b9d89 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -137,6 +137,7 @@ 4DAD724BAB72A5C6D2473770 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F89BC365BDD19BE84F4D3B5 /* Collection.swift */; }; 4DB4C10CB9AB5D38C56C1609 /* StringEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB11EA964FBB42D44C3E4A50 /* StringEncoding.swift */; }; 4E84353322A4CDBBCAD6C070 /* TailCachingResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571BBA35C6F496B007C5158C /* TailCachingResource.swift */; }; + 4F5523AB7E92BAA3AD01A88D /* JSONValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC3487F36FB4BD0BDA0F12E1 /* JSONValue.swift */; }; 4F9DAB2373AE0B6225D2C589 /* InputObserving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 421AE163B6A0248637504A07 /* InputObserving.swift */; }; 4FDA33D3776855A106D40A66 /* AudioPreferencesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C32FDF5E2D35BE71E45ED0 /* AudioPreferencesEditor.swift */; }; 50736D15B35B2C53140A9C14 /* ControlFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BC4119B8937D17ED80B1AB /* ControlFlow.swift */; }; @@ -175,6 +176,7 @@ 674BEEF110667C3051296E9B /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3481F848A616A9A825A4BD /* Double.swift */; }; 67F1C7C3D434D2AA542376E3 /* PublicationParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F609C27F073E40D662CFE093 /* PublicationParser.swift */; }; 682DFC1AF2BD7CAE0862B331 /* CryptoSwift.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E37F94C388A86CB8A34812A5 /* CryptoSwift.xcframework */; }; + 685388EA258ACA6C80979D85 /* UncheckedSendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49029874507A91EA6141EDD /* UncheckedSendable.swift */; }; 689BD78B3A08D8934F441033 /* FileSystemError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96FD34093B3C3E83827B70C /* FileSystemError.swift */; }; 694AAAD5C14BC33891458A4C /* DataCompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB32E55E1F3CAF1737979CC /* DataCompression.swift */; }; 69AA254E4A39D9B49FDFD648 /* UserKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC96A56AB406203898059B6C /* UserKey.swift */; }; @@ -769,6 +771,7 @@ AB528B8FB604E0953E345D26 /* Range.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Range.swift; sourceTree = ""; }; ABAF1D0444B94E2CDD80087D /* PDFKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFKit.swift; sourceTree = ""; }; AC150FB45A4AB33AF516AE09 /* DefaultArchiveOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultArchiveOpener.swift; sourceTree = ""; }; + AC3487F36FB4BD0BDA0F12E1 /* JSONValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValue.swift; sourceTree = ""; }; AC811653B33761089E270C4A /* Asset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Asset.swift; sourceTree = ""; }; AC8639886BD43362741AADD0 /* HREFNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HREFNormalizer.swift; sourceTree = ""; }; ACB32E55E1F3CAF1737979CC /* DataCompression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCompression.swift; sourceTree = ""; }; @@ -857,6 +860,7 @@ E233289C75C9F73E6E28DDB4 /* EPUBSpreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBSpreadView.swift; sourceTree = ""; }; E2D5DCD95C7B908BB6CA77C8 /* ResourceLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLicenseContainer.swift; sourceTree = ""; }; E37F94C388A86CB8A34812A5 /* CryptoSwift.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = CryptoSwift.xcframework; path = ../../Carthage/Build/CryptoSwift.xcframework; sourceTree = ""; }; + E49029874507A91EA6141EDD /* UncheckedSendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UncheckedSendable.swift; sourceTree = ""; }; E5D7B566F794F356878AE8E0 /* PDFOutlineNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFOutlineNode.swift; sourceTree = ""; }; E5DF154DCC73CFBDB0F919DE /* AbsoluteURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsoluteURL.swift; sourceTree = ""; }; E6CB6D3B390CC927AE547A5C /* DebugError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugError.swift; sourceTree = ""; }; @@ -1906,9 +1910,11 @@ 10FB29EDCCE5910C869295F1 /* Either.swift */, D555435E2BADB2B877FD50C7 /* FileExtension.swift */, EDA827FC94F5CB3F9032028F /* JSON.swift */, + AC3487F36FB4BD0BDA0F12E1 /* JSONValue.swift */, 68FF131876FA3A63025F2662 /* Language.swift */, 5BC6AE42A31D77B548CB0BB4 /* Observable.swift */, 38984FD65CFF1D54FF7F794F /* ReadiumLocalizedString.swift */, + E49029874507A91EA6141EDD /* UncheckedSendable.swift */, EE7B762C97CFC214997EC677 /* Weak.swift */, 371E5D46DEBBE58A793B2546 /* Archive */, 0B06420A6651D6D94BE937F3 /* Data */, @@ -2761,6 +2767,7 @@ 5E7924342D113D94AE3A098C /* InMemoryPositionsService.swift in Sources */, 39B1DDE3571AB3F3CC6824F4 /* JSON.swift in Sources */, D84BF71D6840FE62D7701073 /* JSONFormatSniffer.swift in Sources */, + 4F5523AB7E92BAA3AD01A88D /* JSONValue.swift in Sources */, 4D4915BB3847EF285362CF50 /* LCPLicenseFormatSniffer.swift in Sources */, 56CB87DACCA10F737710BFF6 /* Language.swift in Sources */, AD572C6A7AD031FEC40A0BD7 /* LanguageFormatSniffer.swift in Sources */, @@ -2847,6 +2854,7 @@ D4BBC0AD7652265497B5CD1C /* URLExtensions.swift in Sources */, 1600DB04CEACF97EE8AD9CEE /* URLProtocol.swift in Sources */, 222E5BC7A9E632DD6BB9A78E /* URLQuery.swift in Sources */, + 685388EA258ACA6C80979D85 /* UncheckedSendable.swift in Sources */, A1B834459A13B655624E6618 /* UnknownAbsoluteURL.swift in Sources */, A25F76D41A944B81CB911A63 /* UserRights.swift in Sources */, 3ECB525CEB712CEC5EFCD26D /* WarningLogger.swift in Sources */, diff --git a/Tests/SharedTests/Toolkit/JSONValueTests.swift b/Tests/SharedTests/Toolkit/JSONValueTests.swift new file mode 100644 index 0000000000..943f47688e --- /dev/null +++ b/Tests/SharedTests/Toolkit/JSONValueTests.swift @@ -0,0 +1,238 @@ +// +// 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 +@testable import ReadiumShared +import Testing + +@Suite struct JSONValueTests { + @Suite struct Initialization { + @Test func fromNil() { + #expect(JSONValue(nil as Any?) == nil) + } + + @Test func fromBool() { + #expect(JSONValue(true) == .bool(true)) + #expect(JSONValue(false) == .bool(false)) + } + + @Test func fromString() { + #expect(JSONValue("hello") == .string("hello")) + } + + @Test func fromInt() { + #expect(JSONValue(42) == .integer(42)) + #expect(JSONValue(-42) == .integer(-42)) + } + + @Test func fromUInt64() { + #expect(JSONValue(UInt64(42)) == .integer(42)) + #expect(JSONValue(UInt64.max) == .integer(Int.max)) + } + + @Test func fromDouble() { + #expect(JSONValue(3.14) == .double(3.14)) + } + + @Test func fromNSNull() { + #expect(JSONValue(NSNull()) == .null) + } + + @Test func fromNSNumber() { + #expect(JSONValue(NSNumber(value: true)) == .bool(true)) + #expect(JSONValue(NSNumber(value: 42)) == .integer(42)) + #expect(JSONValue(NSNumber(value: -42)) == .integer(-42)) + #expect(JSONValue(NSNumber(value: 3.14)) == .double(3.14)) + } + + @Test func fromNSNumberClamping() { + #expect(JSONValue(NSNumber(value: UInt64.max)) == .integer(Int.max)) + #expect(JSONValue(NSNumber(value: Int64.min)) == .integer(Int.min)) + } + + @Test func fromArray() { + let array: [Any] = ["hello", 42, true] + #expect(JSONValue(array) == .array([.string("hello"), .integer(42), .bool(true)])) + } + + @Test func fromObject() { + let dict: [String: Any] = ["key": "value", "count": 1] + #expect(JSONValue(dict) == .object(["key": .string("value"), "count": .integer(1)])) + } + + @Test func fromNestedCollections() { + let dict: [String: Any] = [ + "nested": [ + "array": [1, 2, 3] as [Any], + ] as [String: Any], + ] + #expect(JSONValue(dict) == .object([ + "nested": .object([ + "array": .array([.integer(1), .integer(2), .integer(3)]), + ]), + ])) + } + + @Test func fastPath() { + let original: JSONValue = .string("test") + #expect(JSONValue(original) == original) + + let object: [String: JSONValue] = ["k": .integer(1)] + #expect(JSONValue(object) == .object(object)) + + let array: [JSONValue] = [.bool(true)] + #expect(JSONValue(array) == .array(array)) + } + } + + @Suite struct Accessors { + @Test func integerAccessors() { + let val: JSONValue = .integer(42) + #expect(val.integer == 42) + #expect(val.double == 42.0) + #expect(val.string == nil) + } + + @Test func stringAccessors() { + let val: JSONValue = .string("test") + #expect(val.string == "test") + #expect(val.integer == nil) + } + } + + @Suite struct AnyConversion { + @Test func null() { + #expect(JSONValue.null.any is NSNull) + } + + @Test func bool() { + #expect(JSONValue.bool(true).any as? Bool == true) + } + + @Test func string() { + #expect(JSONValue.string("hello").any as? String == "hello") + } + + @Test func integer() { + #expect(JSONValue.integer(42).any as? Int == 42) + } + + @Test func double() { + #expect(JSONValue.double(3.14).any as? Double == 3.14) + } + + @Test func array() { + #expect((JSONValue.array([.integer(1)]).any as? [Int])?[0] == 1) + } + + @Test func object() { + #expect((JSONValue.object(["k": .integer(1)]).any as? [String: Int])?["k"] == 1) + } + } + + @Suite struct LiteralConformance { + @Test func nilLiteral() { + let val: JSONValue = nil + #expect(val == .null) + } + + @Test func boolLiteral() { + let val: JSONValue = true + #expect(val == .bool(true)) + } + + @Test func stringLiteral() { + let val: JSONValue = "hello" + #expect(val == .string("hello")) + } + + @Test func integerLiteral() { + let val: JSONValue = 42 + #expect(val == .integer(42)) + } + + @Test func floatLiteral() { + let val: JSONValue = 3.14 + #expect(val == .double(3.14)) + } + + @Test func arrayLiteral() { + let val: JSONValue = ["a", 1] + #expect(val == .array([.string("a"), .integer(1)])) + } + + @Test func dictionaryLiteral() { + let val: JSONValue = ["k": "v"] + #expect(val == .object(["k": .string("v")])) + } + } + + @Suite struct Codable { + @Test func roundTrip() throws { + let original: JSONValue = [ + "string": "value", + "int": 42, + "bool": true, + "null": nil, + "array": [1, 2, 3], + "object": ["k": "v"], + ] + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(original == decoded) + } + + @Test func decodesIntegerNotBool() throws { + let data = #"{"zero": 0, "one": 1, "two": 2}"#.data(using: .utf8)! + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == .object([ + "zero": .integer(0), + "one": .integer(1), + "two": .integer(2), + ])) + } + } + + @Suite struct ReadResultExtensions { + @Suite struct NonOptionalData { + @Test func asJSONValue() { + let data = #"{"foo": "bar"}"#.data(using: .utf8)! + let result: ReadResult = .success(data) + #expect(result.asJSONValue() == .success(.object(["foo": .string("bar")]))) + } + + @Test func asJSONObjectValue() { + let data = #"{"foo": "bar"}"#.data(using: .utf8)! + let result: ReadResult = .success(data) + #expect(result.asJSONObjectValue() == .success(["foo": .string("bar")])) + } + } + + @Suite struct OptionalData { + @Test func asJSONValue() { + let data = #"{"foo": "bar"}"#.data(using: .utf8)! + let result: ReadResult = .success(data) + #expect(result.asJSONValue() == .success(.object(["foo": .string("bar")]))) + } + + @Test func asJSONValueWithNilData() { + let result: ReadResult = .success(nil) + #expect(result.asJSONValue() == .success(nil)) + } + + @Test func asJSONObjectValue() { + let data = #"{"foo": "bar"}"#.data(using: .utf8)! + let result: ReadResult = .success(data) + #expect(result.asJSONObjectValue() == .success(["foo": .string("bar")])) + } + + @Test func asJSONObjectValueWithNilData() { + let result: ReadResult = .success(nil) + #expect(result.asJSONObjectValue() == .success(nil)) + } + } + } +} From d866ff2e6b25b6a9a4142fcecce12f8c83d87eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 4 Mar 2026 18:45:58 +0100 Subject: [PATCH 53/55] Improve wrapping of system errors (#741) --- .../Content Protection/EncryptionParser.swift | 2 +- Sources/LCP/LCPError.swift | 2 +- Sources/LCP/License/LCPError+wrap.swift | 11 ++- Sources/Shared/Toolkit/Data/ReadError.swift | 24 +++++++ .../Shared/Toolkit/File/FileResource.swift | 2 +- .../Shared/Toolkit/File/FileSystemError.swift | 70 +++++++++++++++++++ .../Toolkit/HTTP/DefaultHTTPClient.swift | 31 +------- Sources/Shared/Toolkit/HTTP/HTTPError.swift | 27 +++++++ .../ZIP/Minizip/MinizipContainer.swift | 4 +- .../ZIPFoundationContainer.swift | 6 +- Sources/Streamer/Parser/EPUB/EPUBParser.swift | 2 +- .../SMIL/SMILGuidedNavigationService.swift | 2 +- Sources/Streamer/Parser/PDF/PDFParser.swift | 2 +- .../LCPDFTableOfContentsService.swift | 2 +- TestApp/Sources/App/Readium.swift | 2 + .../Resources/en.lproj/Localizable.strings | 1 + 16 files changed, 141 insertions(+), 49 deletions(-) diff --git a/Sources/LCP/Content Protection/EncryptionParser.swift b/Sources/LCP/Content Protection/EncryptionParser.swift index 20868edba2..5b0c81b110 100644 --- a/Sources/LCP/Content Protection/EncryptionParser.swift +++ b/Sources/LCP/Content Protection/EncryptionParser.swift @@ -50,7 +50,7 @@ private func parseEPUBEncryptionData(in container: Container) async -> ReadResul return await encryptionResource.read() .asyncFlatMap { data -> ReadResult in do { - let doc = try await DefaultXMLDocumentFactory().open( + let doc = try DefaultXMLDocumentFactory().open( data: data, namespaces: [.enc, .ds, .comp] ) diff --git a/Sources/LCP/LCPError.swift b/Sources/LCP/LCPError.swift index 43dea18f66..9e156f3ad8 100644 --- a/Sources/LCP/LCPError.swift +++ b/Sources/LCP/LCPError.swift @@ -45,7 +45,7 @@ public enum LCPError: Error { case parsing(ParsingError) /// A network request failed with the given error. - case network(Error?) + case network(HTTPError?) /// An unexpected LCP error occured. Please post an issue on r2-lcp-swift with the error message and how to reproduce it. case runtime(String) diff --git a/Sources/LCP/License/LCPError+wrap.swift b/Sources/LCP/License/LCPError+wrap.swift index 6f0fd6d0d5..740fc8b3b4 100644 --- a/Sources/LCP/License/LCPError+wrap.swift +++ b/Sources/LCP/License/LCPError+wrap.swift @@ -25,19 +25,16 @@ extension LCPError { return .parsing(error) } - if let error = error as? HTTPError { + if let error = (error as? HTTPError) ?? HTTPError.wrap(error) { return .network(error) } let nsError = error as NSError - switch nsError.domain { - case "R2LCPClient.LCPClientError": + if nsError.domain == "R2LCPClient.LCPClientError" { return .licenseIntegrity(LCPClientError(rawValue: nsError.code) ?? .unknown) - case NSURLErrorDomain: - return .network(nsError) - default: - return .unknown(error) } + + return .unknown(error) } static func wrap(_ completion: @escaping (Result) -> Void) -> (Result) -> Void { diff --git a/Sources/Shared/Toolkit/Data/ReadError.swift b/Sources/Shared/Toolkit/Data/ReadError.swift index 3e0052a5b9..da5f3d597a 100644 --- a/Sources/Shared/Toolkit/Data/ReadError.swift +++ b/Sources/Shared/Toolkit/Data/ReadError.swift @@ -31,6 +31,17 @@ public enum ReadError: Error { public static func decoding(_ message: String, cause: Error? = nil) -> ReadError { .decoding(DebugError(message, cause: cause)) } + + /// Wraps a native error into a `ReadError`, if possible. + /// + /// Returns `nil` if the error cannot be mapped to a known `ReadError`. + public static func wrap(_ error: Error) -> ReadError? { + if let error = AccessError.wrap(error) { + return .access(error) + } else { + return nil + } + } } public enum AccessError: Error { @@ -42,4 +53,17 @@ public enum AccessError: Error { /// For extension purposes. This is not used in the Readium toolkit. case other(Error) + + /// Wraps a native error into an `AccessError`, if possible. + /// + /// Returns `nil` if the error cannot be mapped to a known `AccessError`. + public static func wrap(_ error: Error) -> AccessError? { + if let error = HTTPError.wrap(error) { + return .http(error) + } else if let error = FileSystemError.wrap(error) { + return .fileSystem(error) + } else { + return nil + } + } } diff --git a/Sources/Shared/Toolkit/File/FileResource.swift b/Sources/Shared/Toolkit/File/FileResource.swift index 3d5baac213..df88578017 100644 --- a/Sources/Shared/Toolkit/File/FileResource.swift +++ b/Sources/Shared/Toolkit/File/FileResource.swift @@ -30,7 +30,7 @@ public actor FileResource: Resource, Loggable { _length = .failure(.access(.fileSystem(.fileNotFound(nil)))) } } catch { - _length = .failure(.access(.fileSystem(.io(error)))) + _length = .failure(.access(.fileSystem(.wrap(error) ?? .io(error)))) } } return _length! diff --git a/Sources/Shared/Toolkit/File/FileSystemError.swift b/Sources/Shared/Toolkit/File/FileSystemError.swift index e5c2f77604..4e70f9f262 100644 --- a/Sources/Shared/Toolkit/File/FileSystemError.swift +++ b/Sources/Shared/Toolkit/File/FileSystemError.swift @@ -14,6 +14,76 @@ public enum FileSystemError: Error { /// You are not allowed to access this file. case forbidden(Error?) + /// The file storage is out of space. + case outOfSpace(Error?) + /// An unexpected IO error occurred on the file system. case io(Error?) + + /// Wraps a native error into a `FileSystemError`, if possible. + /// + /// Returns `nil` if the error is not related to the file system. + public static func wrap(_ error: Error) -> FileSystemError? { + if let error = error as? CocoaError { + return switch error.code { + case .fileNoSuchFile, .fileReadNoSuchFile: + .fileNotFound(error) + + case .fileReadNoPermission, .fileWriteNoPermission: + .forbidden(error) + + case .fileWriteOutOfSpace: + .outOfSpace(error) + + case + .fileLocking, + .fileReadCorruptFile, + .fileReadInvalidFileName, + .fileReadTooLarge, + .fileReadUnknown, + .fileReadUnsupportedScheme, + .fileWriteFileExists, + .fileWriteInapplicableStringEncoding, + .fileWriteInvalidFileName, + .fileWriteUnknown, + .fileWriteUnsupportedScheme, + .fileWriteVolumeReadOnly: + .io(error) + + default: + nil + } + } else if let error = error as? POSIXError { + return switch error.code { + case .ENOENT: + .fileNotFound(error) + case .EPERM, .EACCES, .EAUTH: + .forbidden(error) + case .ENOSPC, .EDQUOT: + .outOfSpace(error) + case + .EIO, + .ENXIO, + .EBADF, + .EBUSY, + .EEXIST, + .ENOTDIR, + .EISDIR, + .ENFILE, + .EMFILE, + .EFBIG, + .EROFS, + .EMLINK, + .ENAMETOOLONG, + .ELOOP, + .ENOTEMPTY, + .ESTALE, + .ENOLCK: + .io(error) + default: + nil + } + } + return nil + } } diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index defe7b5650..462070a657 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -489,7 +489,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { if case .failure = state { // No-op, we don't want to overwrite the failure state in this case. } else if let continuation = state.continuation { - state = .failure(continuation: continuation, error: HTTPError(error: error)) + state = .failure(continuation: continuation, error: .wrap(error) ?? .other(error)) } else { state = .finished } @@ -515,35 +515,6 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { } } -private extension HTTPError { - /// Maps a native `URLError` to `HTTPError`. - init(error: Error) { - switch error { - case let error as URLError: - switch error.code { - case .httpTooManyRedirects, .redirectToNonExistentLocation: - self = .redirection(error) - case .secureConnectionFailed, .clientCertificateRejected, .clientCertificateRequired, .appTransportSecurityRequiresSecureConnection, .userAuthenticationRequired: - self = .security(error) - case .badServerResponse, .zeroByteResource, .cannotDecodeContentData, .cannotDecodeRawData, .dataLengthExceedsMaximum: - self = .malformedResponse(error) - case .notConnectedToInternet, .networkConnectionLost: - self = .offline(error) - case .cannotConnectToHost, .cannotFindHost: - self = .unreachable(error) - case .timedOut: - self = .timeout(error) - case .cancelled, .userCancelledAuthentication: - self = .cancelled - default: - self = .other(error) - } - default: - self = .other(error) - } - } -} - private extension HTTPRequest { var urlRequest: URLRequest { var request = URLRequest(url: url.url) diff --git a/Sources/Shared/Toolkit/HTTP/HTTPError.swift b/Sources/Shared/Toolkit/HTTP/HTTPError.swift index 29e2914dbe..dbbe8cd843 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPError.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPError.swift @@ -60,4 +60,31 @@ public enum HTTPError: Error, Loggable { return try HTTPProblemDetails(data: body) } + + /// Wraps a native error into an `HTTPError`, if possible. + /// + /// Returns `nil` if the error is not related to HTTP. + public static func wrap(_ error: Error) -> HTTPError? { + guard let error = error as? URLError else { + return nil + } + return switch error.code { + case .httpTooManyRedirects, .redirectToNonExistentLocation: + .redirection(error) + case .secureConnectionFailed, .clientCertificateRejected, .clientCertificateRequired, .appTransportSecurityRequiresSecureConnection, .userAuthenticationRequired: + .security(error) + case .badServerResponse, .zeroByteResource, .cannotDecodeContentData, .cannotDecodeRawData, .dataLengthExceedsMaximum: + .malformedResponse(error) + case .notConnectedToInternet, .networkConnectionLost: + .offline(error) + case .cannotConnectToHost, .cannotFindHost: + .unreachable(error) + case .timedOut: + .timeout(error) + case .cancelled, .userCancelledAuthentication: + .cancelled + default: + .other(error) + } + } } diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift index 3d30e9d6e3..6a344e4085 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift @@ -42,7 +42,7 @@ final class MinizipContainer: Container, Loggable { return .success(Self(file: file, entries: entries)) } catch { - return .failure(.reading(.decoding(error))) + return .failure(.reading(.wrap(error) ?? .decoding(error))) } } @@ -126,7 +126,7 @@ private actor MinizipResource: Resource, Loggable { try consume(zipFile.readFromCurrentOffset(length: UInt64(range.count))) return .success(()) } catch { - return .failure(.decoding(error)) + return .failure(.wrap(error) ?? .decoding(error)) } } } diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift index b762ef786f..dcbb449938 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift @@ -35,7 +35,7 @@ final class ZIPFoundationContainer: Container, Loggable { return .success(Self(archiveFactory: archiveFactory, entries: entries)) } catch { - return .failure(.reading(.decoding(error))) + return .failure(.reading(.wrap(error) ?? .decoding(error))) } } @@ -120,7 +120,7 @@ private actor ZIPFoundationResource: Resource, Loggable { } return .success(()) } catch { - return .failure(.decoding(error)) + return .failure(.wrap(error) ?? .decoding(error)) } } } @@ -131,7 +131,7 @@ private actor ZIPFoundationResource: Resource, Loggable { do { _archive = try await .success(archiveFactory.make()) } catch { - _archive = .failure(.decoding(error)) + _archive = .failure(.wrap(error) ?? .decoding(error)) } } return _archive! diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index 4e72ce0e1a..d9d18e3aa4 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -77,7 +77,7 @@ public final class EPUBParser: PublicationParser { ) )) } catch { - return .failure(.reading(.decoding(error))) + return .failure(.reading(.wrap(error) ?? .decoding(error))) } } } diff --git a/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift b/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift index aff8b2deba..f03c7569d0 100644 --- a/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift +++ b/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift @@ -83,7 +83,7 @@ actor SMILGuidedNavigationService: GuidedNavigationService { ) ) } catch { - return .failure(.decoding(error)) + return .failure(.wrap(error) ?? .decoding(error)) } } } diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index f2ae7f371e..3914e2146c 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -73,7 +73,7 @@ public final class PDFParser: PublicationParser, Loggable { ) )) } catch { - return .failure(.reading(.decoding(error))) + return .failure(.reading(.wrap(error) ?? .decoding(error))) } } } diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift index 001acc8e6d..5e90a850f9 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift @@ -44,7 +44,7 @@ final class LCPDFTableOfContentsService: TableOfContentsService, PDFPublicationS let toc = try await pdfFactory.open(resource: resource, at: url, password: nil).tableOfContents() return .success(toc.linksWithDocumentHREF(url)) } catch { - return .failure(.decoding(error)) + return .failure(.wrap(error) ?? .decoding(error)) } } diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index b8989a75b5..c2232079de 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -129,6 +129,8 @@ extension ReadiumShared.FileSystemError: UserErrorConvertible { return "error_not_found".localized case .forbidden: return "error_forbidden".localized + case .outOfSpace: + return "error_out_of_space".localized case .io: return "error_io".localized } diff --git a/TestApp/Sources/Resources/en.lproj/Localizable.strings b/TestApp/Sources/Resources/en.lproj/Localizable.strings index 63053357df..e875e70b71 100644 --- a/TestApp/Sources/Resources/en.lproj/Localizable.strings +++ b/TestApp/Sources/Resources/en.lproj/Localizable.strings @@ -29,6 +29,7 @@ "error_io" = "A file system error occurred."; "error_network" = "A networking error occurred."; "error_not_found" = "The resource was not found."; +"error_out_of_space" = "Your storage is full. Please free up some space to continue."; "error_read" = "Failed to read the resource."; From 25b461cbc121950dcaa0700fce993596fb9eaae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 10 Mar 2026 16:19:45 +0100 Subject: [PATCH 54/55] Add Highlights and Decorations user guides (#743) --- .github/workflows/checks.yml | 3 + Cartfile | 3 +- Package.swift | 2 +- README.md | 3 +- Support/Carthage/.xcodegen | 2 +- .../Readium.xcodeproj/project.pbxproj | 6 +- Support/CocoaPods/ReadiumShared.podspec | 2 +- Support/CocoaPods/Specs.swift | 2 +- .../HTMLResourceContentIteratorTests.swift | 2 +- docs/Guides/Navigator/Decorations.md | 255 +++++++++++++++ docs/Guides/Navigator/Highlights.md | 309 ++++++++++++++++++ docs/Guides/Navigator/Navigator.md | 10 +- 12 files changed, 584 insertions(+), 15 deletions(-) create mode 100644 docs/Guides/Navigator/Decorations.md create mode 100644 docs/Guides/Navigator/Highlights.md diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f294ecca12..6b0c2758d0 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -47,6 +47,9 @@ jobs: run: | set -eo pipefail xcodebuild test-without-building -scheme "$scheme" -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi + - name: Print Swift package versions + run: | + jq -r '.pins[] | "\(.identity): \(.state.version)"' Package.resolved # navigator-ui-tests: # name: Navigator UI Tests diff --git a/Cartfile b/Cartfile index cdb0f6bb36..59de1200ad 100644 --- a/Cartfile +++ b/Cartfile @@ -4,6 +4,5 @@ github "ra1028/DifferenceKit" ~> 1.3.0 github "readium/Fuzi" ~> 4.0.0 github "readium/GCDWebServer" ~> 4.0.0 github "readium/ZIPFoundation" ~> 3.0.1 -# There's a regression with 2.7.4 in SwiftSoup, because they used iOS 13 APIs without bumping the deployment target. -github "scinfu/SwiftSoup" == 2.7.1 +github "scinfu/SwiftSoup" ~> 2.13.0 github "stephencelis/SQLite.swift" ~> 0.15.0 diff --git a/Package.swift b/Package.swift index 0e49b56442..278bde6ab4 100644 --- a/Package.swift +++ b/Package.swift @@ -29,7 +29,7 @@ let package = Package( .package(url: "https://github.com/readium/Fuzi.git", from: "4.0.0"), .package(url: "https://github.com/readium/GCDWebServer.git", from: "4.0.0"), .package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.1"), - .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.0"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.13.0"), .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), ], diff --git a/README.md b/README.md index 0fad0dab56..7e26202ea5 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,9 @@ Guides are available to help you make the most of the toolkit. * [Navigator](docs/Guides/Navigator/Navigator.md) - an overview of the Navigator to render a `Publication`'s content to the user * [Configuring the Navigator](docs/Guides/Navigator/Preferences.md) – setup and render Navigator user preferences (font size, colors, etc.) -* [Font families in the EPUB navigator](docs/Guides/Navigator/EPUB%20Fonts.md) – support custom font families with reflowable EPUB publications * [Integrating the Navigator with SwiftUI](docs/Guides/Navigator/SwiftUI.md) – glue to setup the Navigator in a SwiftUI application +* [Implementing Highlights](docs/Guides/Navigator/Highlights.md) – add and manage highlights in a publication +* [Font families in the EPUB navigator](docs/Guides/Navigator/EPUB%20Fonts.md) – support custom font families with reflowable EPUB publications ### DRM diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 11ca0450ad..7edd8047fa 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -1,5 +1,5 @@ # XCODEGEN VERSION -2.44.1 +2.45.2 # SPEC { diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index c1e63b9d89..fd61374794 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -2140,8 +2140,8 @@ F389B1290B1CAA8E5F65573B /* Resources */ = { isa = PBXGroup; children = ( - 866AEA533E1F119928F17990 /* Localizable.strings */, 9BD31F314E7B3A61C55635E5 /* prod-license.lcpl */, + 866AEA533E1F119928F17990 /* Localizable.strings */, ); path = Resources; sourceTree = ""; @@ -2376,9 +2376,10 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1250; + TargetAttributes = { + }; }; buildConfigurationList = 5A872BCD95ECE5673BC89051 /* Build configuration list for PBXProject "Readium" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -2390,6 +2391,7 @@ mainGroup = 2C63ECC3CC1230CCA416F55F; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; + productRefGroup = AE0099F78A65150DDA19FF5A /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index bf93ae2243..b2d0b0db4a 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -24,7 +24,7 @@ Pod::Spec.new do |s| s.dependency 'ReadiumInternal', '~> 3.7.0' s.dependency 'Minizip', '~> 1.0.0' - s.dependency 'SwiftSoup', '~> 2.7.0' + s.dependency 'SwiftSoup', '~> 2.13.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' diff --git a/Support/CocoaPods/Specs.swift b/Support/CocoaPods/Specs.swift index 268fc0234c..fa8ab78f52 100644 --- a/Support/CocoaPods/Specs.swift +++ b/Support/CocoaPods/Specs.swift @@ -55,7 +55,7 @@ let modules: [ModuleSpec] = [ dependencies: [ .readium("ReadiumInternal"), .pod("Minizip", "~> 1.0.0"), - .pod("SwiftSoup", "~> 2.7.0"), + .pod("SwiftSoup", "~> 2.13.0"), .pod("ReadiumFuzi", "~> 4.0.0"), .pod("ReadiumZIPFoundation", "~> 3.0.1"), ] diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift index 6349c97bf6..acef594086 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift @@ -277,7 +277,7 @@ class HTMLResourceContentIteratorTest: XCTestCase { """ - let iter = iterator(nbspHtml, start: locator(selector: ":root > :nth-child(2) > :nth-child(2)")) + let iter = iterator(nbspHtml, start: locator(selector: ":root > :nth-child(1) > :nth-child(2)")) let expectedElement = TextContentElement( locator: locator( diff --git a/docs/Guides/Navigator/Decorations.md b/docs/Guides/Navigator/Decorations.md new file mode 100644 index 0000000000..57a24afaa3 --- /dev/null +++ b/docs/Guides/Navigator/Decorations.md @@ -0,0 +1,255 @@ +# Decorations + +The Decoration API lets you overlay visual elements on publication content – highlights, search result markers, TTS playback indicators, page-number labels in the margin, and more. For the common case of implementing user highlights, see the [Highlighting guide](Highlights.md). + +> [!NOTE] +> Only `EPUBNavigatorViewController` implements `DecorableNavigator` today. Always check if a navigator implements `DecorableNavigator` before enabling decoration-dependent features to future-proof your code. + +## Overview + +The Decoration API is built around a small set of types that work together. + +### Decoration + +A `Decoration` is a single UI element overlaid on publication content. It pairs a **location** (`Locator`) with a **style** (`Decoration.Style`) and carries a stable `id` used to track changes across updates. + +A single logical entity can map to multiple Decoration objects. For example, a user annotation might use one decoration for the highlight and a second for a margin icon at the same location. + +### Decoration Style + +A `Decoration.Style` describes the *abstract appearance* of a decoration — for example, a semi-transparent highlight or an underline — independently of the underlying media type or rendering engine. The toolkit ships two built-in styles (`highlight` and `underline`) and lets you define your own via `Decoration.Style.Id`. + +#### Decoration Template (EPUB) + +For EPUB, each `Decoration.Style.Id` maps to an `HTMLDecorationTemplate` that translates the abstract style into concrete HTML/CSS injected into the page. + +### Decoration Group + +Decorations are organised into **named groups**, one per logical app feature (e.g. `highlights`, `search`, `tts`, `page-list`, etc.). Each call to `apply(decorations:in:)` declares the complete desired state of one group; groups are fully independent. The navigator diffs the new list against the previous one internally and pushes only the necessary changes to the rendered content, so you can call `apply` on every state change without worrying about performance. + +## Guides + +### Creating a Decoration + +A `Decoration` pairs a location in the publication with a style to render. The `id` must be unique within the group and should match your model's primary key—this is what lets you look up the underlying record when the user taps the decoration later. + +```swift +let decoration = Decoration( + id: highlight.id, + locator: highlight.locator, + style: .highlight(tint: highlight.color) +) +``` + +### Applying Decorations to the Navigator + +Rather than telling the navigator about individual additions or removals, you declare the complete desired state of a group and let the navigator figure out what changed. Observe your models, map each one to a `Decoration`, then apply the full list: + +```swift +let decorations = highlights.map { highlight in + Decoration( + id: highlight.id, + locator: highlight.locator, + style: .highlight(tint: highlight.color) + ) +} + +navigator.apply(decorations: decorations, in: "highlights") +``` + +The navigator diffs the new list against the previous one and pushes only the actual changes to the rendered content. This means you can call `apply` freely on every state change — after an add, a color update, or a delete — without worrying about redundant work. + +To clear a group entirely, apply an empty array: + +```swift +navigator.apply(decorations: [], in: "highlights") +``` + +### Handling User Interactions on Decorations + +Register a callback with `observeDecorationInteractions(inGroup:onActivated:)` to be notified when the user taps a decoration. The event carries the decoration itself, its group name, and the tap location in navigator view coordinates: + +```swift +navigator.observeDecorationInteractions(inGroup: "highlights") { event in + +} +``` + +**`event.decoration.id`** matches the id you set when creating the Decoration, so you can retrieve the corresponding model from your database. + +**`event.rect`** gives the bounding box of the tapped decoration in the navigator view, useful for anchoring a popover. + +### Checking Whether a Navigator Supports a Decoration Style + +Not every navigator can render every style — underlining a sentence in an audiobook, for example, makes no sense. Before enabling a feature that depends on a specific style, verify that the navigator supports it: + +```swift +if navigator.supports(decorationStyle: .underline) { + // Offer underlining in the UI +} +``` + +For `EPUBNavigatorViewController`, this returns `true` for any style ID present in `Configuration.decorationTemplates`. + +### Creating a Custom Decoration Style + +The following example shows how to add a page-number label in the left margin for each entry in `publication.pageList` (declared print page markers). + +#### 1. Declare a custom `Decoration.Style.Id` + +```swift +extension Decoration.Style.Id { + static let pageList: Decoration.Style.Id = "page-list" +} +``` + +#### 2. Define a config struct + +The config carries the data your template needs. It must be `Hashable` so the diffing engine can detect changes. + +```swift +struct PageListConfig: Hashable { + /// Page number label from publication.pageList[].title + var label: String +} +``` + +#### 3. Write the `HTMLDecorationTemplate` + +```swift +extension HTMLDecorationTemplate { + static var pageList: HTMLDecorationTemplate { + let className = "app-page-number" + + return HTMLDecorationTemplate( + // One rectangle for the whole range + layout: .bounds, + // Span the full page so the label can float left + width: .page, + element: { decoration in + let config = decoration.style.config as? PageListConfig + + // var(--RS__backgroundColor) matches the Readium CSS theme background. + // Setting it inline prevents it being forced transparent by Readium CSS. + return """ +
+ + \(config?.label ?? "") + +
+ """ + }, + stylesheet: """ + .\(className) { + float: left; + margin-left: 4px; + padding: 0px 2px 0px 2px; + border: 1px solid; + border-radius: 10%; + box-shadow: rgba(50, 50, 93, 0.25) 0px 2px 5px -1px, rgba(0, 0, 0, 0.3) 0px 1px 3px -1px; + opacity: 0.8; + } + """ + ) + } +} +``` + +#### 4. Register it in `Configuration.decorationTemplates` + +```swift +var templates = HTMLDecorationTemplate.defaultTemplates() +templates[.pageList] = .pageList + +let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, + config: EPUBNavigatorViewController.Configuration( + decorationTemplates: templates + ) +) +``` + +#### 5. Build and apply decorations + +```swift +private func updatePageListDecorations() async { + guard let navigator = navigator as? DecorableNavigator else { return } + + var decorations: [Decoration] = [] + for (index, link) in publication.pageList.enumerated() { + guard + let title = link.title, + let locator = await publication.locate(link) + else { + continue + } + + decorations.append(Decoration( + id: "page-list-\(index)", + locator: locator, + style: Decoration.Style( + id: .pageList, + config: PageListConfig(label: title) + ) + )) + } + + navigator.apply(decorations: decorations, in: "page-list") +} +``` + +### Common Patterns + +#### Search Results + +Apply a temporary `"search"` group when the user performs a search. Use index-based IDs and `isActive` to highlight the currently selected result. Clear the group when the search is dismissed. + +```swift +let navigator: DecorableNavigator + +func applySearchDecorations(selectedIndex: Int?) { + let decorations = searchResults.enumerated().map { index, result in + Decoration( + id: "\(index)", + locator: result.locator, + style: .highlight(isActive: index == selectedIndex) + ) + } + navigator.apply(decorations: decorations, in: "search") +} + +// Show all results with none selected +applySearchDecorations(selectedIndex: nil) + +// When the user moves to a result, mark it as active +applySearchDecorations(selectedIndex: currentResultIndex) + +// Clear on dismiss +navigator.apply(decorations: [], in: "search") +``` + +#### TTS Playback + +Track the currently spoken sentence with a single-decoration `"tts"` group. Replace the decoration each time TTS advances, and clear it when TTS stops. + +```swift +let navigator: DecorableNavigator + +func ttsDidStartSpeaking(locator: Locator) { + let decoration = Decoration( + id: "tts", + locator: locator, + style: .underline(tint: .red) + ) + navigator.apply(decorations: [decoration], in: "tts") +} + +func ttsDidStop() { + navigator.apply(decorations: [], in: "tts") +} +``` + +## Further Reading + +- [Readium Decorator API proposal](https://readium.org/architecture/proposals/008-decorator-api.html) diff --git a/docs/Guides/Navigator/Highlights.md b/docs/Guides/Navigator/Highlights.md new file mode 100644 index 0000000000..154ccdfd33 --- /dev/null +++ b/docs/Guides/Navigator/Highlights.md @@ -0,0 +1,309 @@ +# Implementing Highlights + +Highlighting lets users mark up passages in a publication for later reference - a core feature of any reading app. In Readium, highlights are built on top of the **Decoration API**. If you want to understand that API in depth or build a custom decoration style, see the [Decorations guide](Decorations.md). + +**Readium is only responsible for *rendering* highlights over the publication content**. Persisting highlights to a database, and any UI around them (color pickers, annotation editors, highlight lists, etc.) are entirely the responsibility of your app. This guide assumes you already have a `Highlight` model and a repository to store and observe it. + +> [!NOTE] +> Only `EPUBNavigatorViewController` implements `DecorableNavigator` today. Always check if a navigator implements `DecorableNavigator` before enabling decoration-dependent features to future-proof your code. + +## Setting Up + +iOS shows a context menu when the user selects text in the navigator. You hook into this by declaring a custom `EditingAction` with a selector that will be fired when the user taps the menu item. + +```swift +let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, + config: EPUBNavigatorViewController.Configuration( + editingActions: EditingAction.defaultActions + [ + EditingAction(title: "Highlight", action: #selector(highlightSelection)) + ] + ) +) +``` + +## Creating a Highlight from a Text Selection + +The `action` selector must be implemented somewhere in the **responder chain** above the navigator — typically in its parent view controller. iOS routes editing actions up the responder chain, so the navigator passes them through without handling them itself. + +```swift +@objc func highlightSelection() { + guard let selection = navigator.currentSelection else { + return + } + + let highlight = Highlight( + bookId: bookId, + locator: selection.locator, + text: selection.locator.text.highlight, + color: .yellow + ) + + Task { + try await highlightRepository.add(highlight) + // If you use a reactive pattern (see below), the navigator + // updates automatically when the database changes. + } + + // dismisses the text selection handles immediately after saving; + // without it, the selection would linger on screen alongside the + // newly rendered highlight decoration. + navigator.clearSelection() +} +``` + +`navigator.currentSelection` returns a `Selection` value with: + +- `locator` — a `Locator` pointing at the selected range; `locator.text.highlight` contains the selected string. +- `frame` — the bounding rect of the selection in navigator view coordinates, useful for anchoring a popover. + +## Displaying Highlights + +Rather than calling `apply` manually after each add, delete, or color change, subscribe to your database and re-apply the complete list of decorations whenever it changes. This means there is a single code path for keeping the navigator in sync — no risk of forgetting to update the UI for one of the operations. + +The navigator diffs each new list against the previous one internally, so passing the full list every time is both safe and efficient — you never need to track individual changes yourself. + +```swift +func observeHighlightDecorations() { + guard let navigator = navigator as? DecorableNavigator else { return } + + // Register the tap callback once (see "Handling Taps" below) + navigator.observeDecorationInteractions(inGroup: "highlights") { [weak self] event in + self?.activateDecoration(event) + } + + // Re-apply on every database change. + highlightRepository.highlights(for: bookId) + .receive(on: DispatchQueue.main) + .sink { _ in } receiveValue: { [weak self] highlights in + guard let self else { return } + + let decorations = highlights.map { highlight in + Decoration( + // Use your database primary key as the highlight's `id` — this is what + // links the `Decoration` back to your model when the user later taps it. + id: highlight.id, + locator: highlight.locator, + style: .highlight(tint: highlight.color) + ) + } + + navigator.apply(decorations: decorations, in: "highlights") + } + .store(in: &subscriptions) +} +``` + +`receive(on: DispatchQueue.main)` is required because `apply(decorations:in:)` updates the UI and must run on the main thread, while database publishers typically deliver on a background thread. + +Call `observeHighlightDecorations()` once in `viewDidLoad`. + +## Handling Taps + +Use `observeDecorationInteractions(inGroup:onActivated:)` to react when the user taps a highlight. Your callback receives `OnDecorationActivatedEvent` objects. + +**`event.decoration.id`** matches the id you set when building the `Decoration`, so you can use it directly to retrieve the full record from your database. + +**`event.rect`** gives you the position of the tapped highlight in the navigator view, which you can use to anchor a popover precisely over it. + +```swift +private func activateDecoration(_ event: OnDecorationActivatedEvent) { + // Matches the id you used when building the Decoration. + let highlightId = event.decoration.id + + Task { @MainActor in + guard let highlight = try? await highlightRepository.highlight(forId: highlightId) else { + return + } + + presentHighlightMenu(for: highlight, anchoredTo: event.rect) + } +} + +private func presentHighlightMenu(for highlight: Highlight, anchoredTo rect: CGRect?) { + let alert = UIAlertController(title: "Highlight", message: nil, preferredStyle: .actionSheet) + + // Delete: remove from the database; the reactive stream clears the decoration automatically. + alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in + guard let self else { return } + Task { try await self.highlightRepository.remove(highlight.id) } + }) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + if let popover = alert.popoverPresentationController { + popover.sourceView = view + popover.sourceRect = rect ?? view.bounds + } + + present(alert, animated: true) +} +``` + +Because the navigator is wired to the reactive stream, updating or deleting a highlight in the database is automatically reflected in the navigator — no extra calls needed. + +## Navigating to a Highlight + +Use `navigator.go(to:)` to jump to a saved highlight's location: + +```swift +await navigator.go(to: highlight.locator) +``` + +## Complete Example + +The following self-contained `EPUBReaderViewController` wires up the full highlights workflow. `HighlightRepository` is left as a protocol so the example is storage-agnostic. + +```swift +import Combine +import ReadiumNavigator +import ReadiumShared +import UIKit + +// MARK: - Data model + +struct Highlight { + /// Database primary key (used as Decoration.id) + var id: String + var bookId: String + var locator: Locator + var color: UIColor +} + +// MARK: - Storage protocol (implement with GRDB, CoreData, etc.) + +protocol HighlightRepository { + func highlights(for bookId: String) -> AnyPublisher<[Highlight], Never> + func highlight(forId id: String) async throws -> Highlight? + func add(_ highlight: Highlight) async throws + func remove(_ id: String) async throws +} + +// MARK: - Reader view controller + +class EPUBReaderViewController: UIViewController { + + private let navigator: EPUBNavigatorViewController + private let highlightRepository: HighlightRepository + private let bookId: String + private var subscriptions = Set() + + private let highlightDecorationGroup = "highlights" + + init( + publication: Publication, + bookId: String, + lastLocation: Locator?, + highlightRepository: HighlightRepository + ) throws { + self.bookId = bookId + self.highlightRepository = highlightRepository + + navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastLocation, + config: EPUBNavigatorViewController.Configuration( + editingActions: EditingAction.defaultActions + [ + EditingAction(title: "Highlight", action: #selector(highlightSelection)) + ] + ) + ) + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + + // Embed the navigator + addChild(navigator) + navigator.view.frame = view.bounds + navigator.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(navigator.view) + navigator.didMove(toParent: self) + + // Wire up highlights + observeHighlightDecorations() + } + + // MARK: - Displaying highlights + + private func observeHighlightDecorations() { + guard let decorator = navigator as? DecorableNavigator else { return } + + decorator.observeDecorationInteractions(inGroup: highlightDecorationGroup) { [weak self] event in + self?.activateDecoration(event) + } + + highlightRepository.highlights(for: bookId) + .receive(on: DispatchQueue.main) + .sink { _ in } receiveValue: { [weak self] highlights in + guard let self else { return } + let decorations = highlights.map { h in + Decoration( + id: h.id, + locator: h.locator, + style: .highlight(tint: h.color) + ) + } + decorator.apply(decorations: decorations, in: self.highlightDecorationGroup) + } + .store(in: &subscriptions) + } + + // MARK: - Creating highlights + + @objc func highlightSelection() { + guard let selection = navigator.currentSelection else { return } + + let highlight = Highlight( + id: UUID().uuidString, + bookId: bookId, + locator: selection.locator, + color: .yellow + ) + + Task { + try await highlightRepository.add(highlight) + } + + navigator.clearSelection() + } + + // MARK: - Tapping existing highlights + + private func activateDecoration(_ event: OnDecorationActivatedEvent) { + let highlightId = event.decoration.id + + Task { + guard let highlight = try? await highlightRepository.highlight(forId: highlightId) else { return } + await MainActor.run { + presentHighlightMenu(for: highlight, anchoredTo: event.rect) + } + } + } + + private func presentHighlightMenu(for highlight: Highlight, anchoredTo rect: CGRect?) { + let alert = UIAlertController(title: "Highlight", message: nil, preferredStyle: .actionSheet) + + alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in + guard let self else { return } + Task { try await self.highlightRepository.remove(highlight.id) } + }) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + if let popover = alert.popoverPresentationController { + popover.sourceView = view + popover.sourceRect = rect ?? view.bounds + popover.permittedArrowDirections = .down + } + + present(alert, animated: true) + } +} +``` diff --git a/docs/Guides/Navigator/Navigator.md b/docs/Guides/Navigator/Navigator.md index c930c36ef4..5a9b818da8 100644 --- a/docs/Guides/Navigator/Navigator.md +++ b/docs/Guides/Navigator/Navigator.md @@ -53,7 +53,7 @@ Navigators enabling users to select parts of the content implement `SelectableNa A Decorable Navigator is able to render decorations over a publication, such as highlights or margin icons. -[See the corresponding proposal for more information](https://readium.org/architecture/proposals/008-decorator-api.html). +To implement user highlights in your app, see the [Highlights guide](Highlights.md). For a deeper look at the API or to build a custom decoration style, see the [Decorations guide](Decorations.md). ## Instantiating a Navigator @@ -102,9 +102,9 @@ You can observe the current position in the publication by implementing a `Navig ```swift navigator.delegate = MyNavigatorDelegate() -class MyNavigatorDelegate: NavigatorDelegate { +@MainActor class MyNavigatorDelegate: NavigatorDelegate { - override func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { if let position = locator.locations.position { print("At position \(position)") } @@ -124,7 +124,7 @@ class MyNavigatorDelegate: NavigatorDelegate { The `Locator` object may be serialized to JSON in your database, and deserialized to set the initial location when creating the navigator. ```swift -let lastReadLocation = Locator(jsonString: dabase.lastReadLocation()) +let lastReadLocation = try? Locator(jsonString: database.lastReadLocation()) let navigator = try EPUBNavigatorViewController( publication: publication, @@ -167,7 +167,7 @@ Bind it to your `Navigator` instance before adding your own input observers, so DirectionalNavigationAdapter().bind(to: navigator) // Toggle the navigation bar when the user taps outside the edge zones. -navigator.addObserver(.tap { [weak self] _ in +token = navigator.addObserver(.tap { [weak self] _ in self?.toggleNavigationBar() return true }) From f7d10d2bf5876408feae14d634416f69d1473fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 10 Mar 2026 16:44:30 +0100 Subject: [PATCH 55/55] 3.8.0 (#744) --- BuildTools/Sources/GeneratePodspecs/main.swift | 5 +++++ CHANGELOG.md | 5 ++++- README.md | 14 +++++++------- .../CocoaPods/ReadiumAdapterGCDWebServer.podspec | 7 ++++--- Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec | 9 +++++---- Support/CocoaPods/ReadiumInternal.podspec | 3 ++- Support/CocoaPods/ReadiumLCP.podspec | 7 ++++--- Support/CocoaPods/ReadiumNavigator.podspec | 9 +++++---- Support/CocoaPods/ReadiumOPDS.podspec | 7 ++++--- Support/CocoaPods/ReadiumShared.podspec | 7 ++++--- Support/CocoaPods/ReadiumStreamer.podspec | 7 ++++--- Support/CocoaPods/Specs.swift | 10 +++++++--- TestApp/Sources/Info.plist | 4 ++-- docs/Migration Guide.md | 4 +++- docs/NavigatorOverview.md | 2 ++ 15 files changed, 62 insertions(+), 38 deletions(-) diff --git a/BuildTools/Sources/GeneratePodspecs/main.swift b/BuildTools/Sources/GeneratePodspecs/main.swift index 25f91fe4e2..313a95e206 100644 --- a/BuildTools/Sources/GeneratePodspecs/main.swift +++ b/BuildTools/Sources/GeneratePodspecs/main.swift @@ -83,6 +83,11 @@ func render(_ m: ModuleSpec) -> String { lines.append(" s.xcconfig = { \(pairs) }") } + // Required for Swift `package` access level, which needs a -package-name compiler flag. + // SPM sets this automatically from Package.swift `name`; CocoaPods does not. + // All Readium modules share the same package name so that `package` access works across them. + lines.append(" s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name \(packageName)' }") + if !m.dependencies.isEmpty { lines.append("") for dep in m.dependencies { diff --git a/CHANGELOG.md b/CHANGELOG.md index 8480b8ede4..78d78c1ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. -## [Unreleased] + + +## [3.8.0] ### Added @@ -1151,3 +1153,4 @@ progression. Now if no reading progression is set, the `effectiveReadingProgress [3.5.0]: https://github.com/readium/swift-toolkit/compare/3.4.0...3.5.0 [3.6.0]: https://github.com/readium/swift-toolkit/compare/3.5.0...3.6.0 [3.7.0]: https://github.com/readium/swift-toolkit/compare/3.6.0...3.7.0 +[3.8.0]: https://github.com/readium/swift-toolkit/compare/3.7.0...3.8.0 diff --git a/README.md b/README.md index 7e26202ea5..7698779aff 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Guides are available to help you make the most of the toolkit. | Readium | iOS | Swift compiler | Xcode | |-----------|------|----------------|-------| | `develop` | 15.0 | 6.0 | 16.4 | -| 3.7.0 | 15.0 | 6.0 | 16.4 | +| 3.8.0 | 15.0 | 6.0 | 16.4 | | 3.0.0 | 13.4 | 5.10 | 15.4 | | 2.5.1 | 11.0 | 5.6.1 | 13.4 | | 2.5.0 | 10.0 | 5.6.1 | 13.4 | @@ -129,7 +129,7 @@ If you're stuck, find more information at [developer.apple.com](https://develope Add the following to your `Cartfile`: ``` -github "readium/swift-toolkit" ~> 3.7.0 +github "readium/swift-toolkit" ~> 3.8.0 ``` Then, [follow the usual Carthage steps](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) to add the Readium libraries to your project. @@ -159,11 +159,11 @@ Add the following `pod` statements to your `Podfile` for the Readium libraries y source 'https://github.com/readium/podspecs' source 'https://cdn.cocoapods.org/' -pod 'ReadiumShared', '~> 3.7.0' -pod 'ReadiumStreamer', '~> 3.7.0' -pod 'ReadiumNavigator', '~> 3.7.0' -pod 'ReadiumOPDS', '~> 3.7.0' -pod 'ReadiumLCP', '~> 3.7.0' +pod 'ReadiumShared', '~> 3.8.0' +pod 'ReadiumStreamer', '~> 3.8.0' +pod 'ReadiumNavigator', '~> 3.8.0' +pod 'ReadiumOPDS', '~> 3.8.0' +pod 'ReadiumLCP', '~> 3.8.0' ``` Take a look at [CocoaPods's documentation](https://guides.cocoapods.org/using/using-cocoapods.html) for more information. diff --git a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec index 89ae887023..4c73a9b6eb 100644 --- a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec +++ b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterGCDWebServer" - s.version = "3.7.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use GCDWebServer as an HTTP server in Readium" s.homepage = "http://readium.github.io" @@ -16,9 +16,10 @@ Pod::Spec.new do |s| s.platform = :ios s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.7.0' - s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' s.dependency 'ReadiumGCDWebServer', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec index f2b3456443..bce299ce92 100644 --- a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec +++ b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterLCPSQLite" - s.version = "3.7.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use SQLite.swift for the Readium LCP repositories" s.homepage = "http://readium.github.io" @@ -16,10 +16,11 @@ Pod::Spec.new do |s| s.platform = :ios s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.7.0' - s.dependency 'ReadiumShared', '~> 3.7.0' - s.dependency 'ReadiumLCP', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' + s.dependency 'ReadiumLCP', '~> 3.8.0' s.dependency 'SQLite.swift', '~> 0.15.0' end diff --git a/Support/CocoaPods/ReadiumInternal.podspec b/Support/CocoaPods/ReadiumInternal.podspec index 2149efa005..c74977169d 100644 --- a/Support/CocoaPods/ReadiumInternal.podspec +++ b/Support/CocoaPods/ReadiumInternal.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumInternal" - s.version = "3.7.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Private utilities used by the Readium modules" s.homepage = "http://readium.github.io" @@ -16,5 +16,6 @@ Pod::Spec.new do |s| s.platform = :ios s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } end diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index cb63b588bd..938ff45dcb 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumLCP" - s.version = "3.7.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Readium LCP" s.homepage = "http://readium.github.io" @@ -22,9 +22,10 @@ Pod::Spec.new do |s| s.platform = :ios s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.7.0' - s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' s.dependency 'CryptoSwift', '~> 1.8.0' diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index 86741e481a..50ad029c31 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumNavigator" - s.version = "3.7.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Readium Navigator" s.homepage = "http://readium.github.io" @@ -21,10 +21,11 @@ Pod::Spec.new do |s| s.swift_version = '5.10' s.platform = :ios s.ios.deployment_target = "15.0" + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.7.0' - s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' s.dependency 'DifferenceKit', '~> 1.0' - s.dependency 'SwiftSoup', '~> 2.7.0' + s.dependency 'SwiftSoup', '~> 2.11.0' end diff --git a/Support/CocoaPods/ReadiumOPDS.podspec b/Support/CocoaPods/ReadiumOPDS.podspec index d4141ae288..e28524b073 100644 --- a/Support/CocoaPods/ReadiumOPDS.podspec +++ b/Support/CocoaPods/ReadiumOPDS.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumOPDS" - s.version = "3.7.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Readium OPDS" s.homepage = "http://readium.github.io" @@ -16,9 +16,10 @@ Pod::Spec.new do |s| s.platform = :ios s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.7.0' - s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index b2d0b0db4a..fe3085f17d 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumShared" - s.version = "3.7.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Readium Shared" s.homepage = "http://readium.github.io" @@ -21,10 +21,11 @@ Pod::Spec.new do |s| s.frameworks = "CoreServices" s.libraries = 'xml2' s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' s.dependency 'Minizip', '~> 1.0.0' - s.dependency 'SwiftSoup', '~> 2.13.0' + s.dependency 'SwiftSoup', '~> 2.11.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index 57820d4349..f8d791512e 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumStreamer" - s.version = "3.7.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Readium Streamer" s.homepage = "http://readium.github.io" @@ -23,9 +23,10 @@ Pod::Spec.new do |s| s.ios.deployment_target = "15.0" s.libraries = 'z', 'xml2' s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.7.0' - s.dependency 'ReadiumShared', '~> 3.7.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' s.dependency 'CryptoSwift', '~> 1.8.0' diff --git a/Support/CocoaPods/Specs.swift b/Support/CocoaPods/Specs.swift index fa8ab78f52..d43a834d99 100644 --- a/Support/CocoaPods/Specs.swift +++ b/Support/CocoaPods/Specs.swift @@ -5,7 +5,7 @@ // /// Readium toolkit version — bump this when releasing a new version, then run `make podspecs`. -let version = "3.7.0" +let version = "3.8.0" /// Minimum iOS deployment target shared by all modules. let iosTarget = "15.0" @@ -13,6 +13,10 @@ let iosTarget = "15.0" /// Swift version requirement shared by all modules. let swiftVersion = "5.10" +/// Swift package name (from Package.swift). All modules share this so that `package` access +/// level works across module boundaries, matching the SPM build behaviour. +let packageName = "Readium" + // MARK: - Data model struct ModuleSpec { @@ -55,7 +59,7 @@ let modules: [ModuleSpec] = [ dependencies: [ .readium("ReadiumInternal"), .pod("Minizip", "~> 1.0.0"), - .pod("SwiftSoup", "~> 2.13.0"), + .pod("SwiftSoup", "~> 2.11.0"), .pod("ReadiumFuzi", "~> 4.0.0"), .pod("ReadiumZIPFoundation", "~> 3.0.1"), ] @@ -89,7 +93,7 @@ let modules: [ModuleSpec] = [ .readium("ReadiumInternal"), .readium("ReadiumShared"), .pod("DifferenceKit", "~> 1.0"), - .pod("SwiftSoup", "~> 2.7.0"), + .pod("SwiftSoup", "~> 2.11.0"), ] ), ModuleSpec( diff --git a/TestApp/Sources/Info.plist b/TestApp/Sources/Info.plist index 9613aa7e79..e841fa177d 100644 --- a/TestApp/Sources/Info.plist +++ b/TestApp/Sources/Info.plist @@ -252,9 +252,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 3.7.0 + 3.8.0 CFBundleVersion - 3.7.0 + 3.8.0 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index e31a7ab8c7..1e970f01be 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -2,7 +2,9 @@ All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file. -## Unreleased + + +## 3.8.0 ### Removing the HTTP Server from the EPUB Navigator diff --git a/docs/NavigatorOverview.md b/docs/NavigatorOverview.md index c46745c11e..249c61d4ae 100644 --- a/docs/NavigatorOverview.md +++ b/docs/NavigatorOverview.md @@ -14,4 +14,6 @@ Learn about the architecture, configuration, and usage of the Readium Navigator. - - - +- +- -