diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0d3b992..e2cab4157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -All notable changes to this project will be documented in this file. Take a look at [the migration guide](Documentation/Migration%20Guide.md) to upgrade between two major versions. +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. **Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution. @@ -21,6 +21,10 @@ All notable changes to this project will be documented in this file. Take a look * Support for streaming ZIP packages over HTTP. This lets you open a remote EPUB, audiobook, or any other ZIP-based publication without needing to download it first. +#### LCP + +* Support for streaming an LCP-protected publication from its License Document (LCPL). [Take a look at the LCP guide for more information](docs/Guides/Readium%20LCP.md#streaming-an-lcp-protected-package). + ### Deprecated * The `close()` and `Closeable` APIs are now deprecated. Resources are automatically released upon `deinit`, which aligns better with Swift. @@ -39,7 +43,7 @@ All notable changes to this project will be documented in this file. Take a look ## [3.0.0-beta.2] * The Readium Swift toolkit now requires a minimum of iOS 13.4. -* All the libraries are now available on a dedicated [Readium CocoaPods Specs repository](https://github.com/readium/podspecs). Take a look at [the migration guide](Documentation/Migration%20Guide.md) to migrate. +* All the libraries are now available on a dedicated [Readium CocoaPods Specs repository](https://github.com/readium/podspecs). Take a look at [the migration guide](docs/Migration%20Guide.md) to migrate. ### Added @@ -135,7 +139,7 @@ All notable changes to this project will be documented in this file. Take a look #### LCP -* The Readium LCP persistence layer was extracted to allow applications to provide their own implementations. Take a look at [the migration guide](Documentation/Migration%20Guide.md) for guidance. +* The Readium LCP persistence layer was extracted to allow applications to provide their own implementations. Take a look at [the migration guide](docs/Migration%20Guide.md) for guidance. ### Fixed @@ -158,7 +162,7 @@ All notable changes to this project will be documented in this file. Take a look #### Shared * `Link` and `Locator`'s `href` are normalized as valid URLs to improve interoperability with the Readium Web toolkits. - * **You MUST migrate your database if you were persisting HREFs and Locators**. Take a look at [the migration guide](Documentation/Migration%20Guide.md) for guidance. + * **You MUST migrate your database if you were persisting HREFs and Locators**. Take a look at [the migration guide](docs/Migration%20Guide.md) for guidance. * Links are not resolved to the `self` URL of a manifest anymore. However, you can still normalize the HREFs yourselves by calling `Manifest.normalizeHREFsToSelf()`. * `Publication.localizedTitle` is now optional, as we cannot guarantee a publication will always have a title. @@ -341,8 +345,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](Documentation/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](Documentation/Guides/Navigator%20Preferences.md) and [migration guide](Documentation/Migration%20Guide.md). +* [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). * New EPUB user preferences: * `fontWeight` - Base text font weight. * `textNormalization` - Normalize font style, weight and variants, which improves accessibility. @@ -369,11 +373,11 @@ All notable changes to this project will be documented in this file. Take a look #### Streamer -* `PublicationServer` is deprecated. See the [the migration guide](Documentation/Migration%20Guide.md#2.5.0) to migrate the HTTP server. +* `PublicationServer` is deprecated. See the [the migration guide](docs/Migration%20Guide.md#2.5.0) to migrate the HTTP server. #### Navigator -* The EPUB `UserSettings` component is deprecated and replaced by the new Preferences API. [Take a look at the user guide](Documentation/Guides/Navigator%20Preferences.md) and [migration guide](Documentation/Migration%20Guide.md). +* The EPUB `UserSettings` component is deprecated and replaced by the new Preferences API. [Take a look at the user guide](docs/Guides/Navigator%20Preferences.md) and [migration guide](docs/Migration%20Guide.md). ### Changed @@ -398,11 +402,11 @@ All notable changes to this project will be documented in this file. Take a look #### Shared * Support for the accessibility metadata in RWPM per [Schema.org Accessibility Properties for Discoverability Vocabulary](https://www.w3.org/2021/a11y-discov-vocab/latest/). -* [Extract the raw content (text, images, etc.) of a publication](Documentation/Guides/Content.md). +* [Extract the raw content (text, images, etc.) of a publication](docs/Guides/Content.md). #### Navigator -* [A brand new text-to-speech implementation](Documentation/Guides/TTS.md). +* [A brand new text-to-speech implementation](docs/Guides/TTS.md). #### Streamer diff --git a/Package.swift b/Package.swift index ba80fe3a8..73bac3e62 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: "2.0.0"), + .package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.0"), .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 74148b546..18cc6708d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ [Readium Mobile](https://github.com/readium/mobile) is a toolkit for ebooks, audiobooks and comics written in Swift & Kotlin. -:point_up: **Take a look at the [guide to get started](docs/Guides/Getting%20Started.md).** A [Test App](TestApp) demonstrates how to integrate the Readium Swift toolkit in your own reading app. +> [!TIP] +> **Take a look at the [guide to get started](docs/Guides/Getting%20Started.md).** A [Test App](TestApp) demonstrates how to integrate the Readium Swift toolkit in your own reading app. This toolkit is a modular project, which follows the [Readium Architecture](https://github.com/readium/architecture). diff --git a/Sources/Internal/Extensions/Result.swift b/Sources/Internal/Extensions/Result.swift index d02eb5ef4..669f352d0 100644 --- a/Sources/Internal/Extensions/Result.swift +++ b/Sources/Internal/Extensions/Result.swift @@ -11,6 +11,17 @@ public extension Result { try? get() } + func get(or def: Success) -> Success { + (try? get()) ?? def + } + + func `catch`(_ recover: (Failure) -> Self) -> Self { + if case let .failure(error) = self { + return recover(error) + } + return self + } + func eraseToAnyError() -> Result { mapError { $0 as Error } } diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index 11ecfe12e..ffa0c54f5 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -10,10 +10,12 @@ import ReadiumShared final class LCPContentProtection: ContentProtection, Loggable { private let service: LCPService private let authentication: LCPAuthenticating + private let assetRetriever: AssetRetriever - init(service: LCPService, authentication: LCPAuthenticating) { + init(service: LCPService, authentication: LCPAuthenticating, assetRetriever: AssetRetriever) { self.service = service self.authentication = authentication + self.assetRetriever = assetRetriever } func open( @@ -21,35 +23,118 @@ final class LCPContentProtection: ContentProtection, Loggable { credentials: String?, allowUserInteraction: Bool, sender: Any? + ) async -> Result { + switch asset { + case let .resource(resource): + return await openLicense( + using: resource, + credentials: credentials, + allowUserInteraction: allowUserInteraction, + sender: sender + ) + + case let .container(container): + return await openPublication( + in: container, + credentials: credentials, + allowUserInteraction: allowUserInteraction, + sender: sender + ) + } + } + + func openLicense( + using asset: ResourceAsset, + credentials: String?, + allowUserInteraction: Bool, + sender: Any? + ) async -> Result { + guard asset.format.conformsTo(.lcpLicense) else { + return .failure(.assetNotSupported(DebugError("The asset does not appear to be an LCP License"))) + } + + return await asset.resource.readAsLCPL() + .mapError { .reading($0) } + .asyncFlatMap { licenseDocument in + + await assetRetriever.retrieve(link: licenseDocument.publicationLink) + .flatMap { publicationAsset in + switch publicationAsset { + case .resource: + return .failure(.assetNotSupported(DebugError("Cannot open the LCP-protected publication as a Container"))) + case let .container(container): + return .success(container) + } + } + .asyncFlatMap { + await makeLCPAsset( + from: $0, + license: retrieveLicense( + in: .resource(asset), + credentials: credentials, + allowUserInteraction: allowUserInteraction, + sender: sender + ) + ) + } + } + } + + func openPublication( + in asset: ContainerAsset, + credentials: String?, + allowUserInteraction: Bool, + sender: Any? ) async -> Result { guard asset.format.conformsTo(.lcp) else { return .failure(.assetNotSupported(DebugError("The asset does not appear to be protected with LCP"))) } - guard - case var .container(asset) = asset, - asset.container.sourceURL?.scheme == .file - else { - return .failure(.assetNotSupported(DebugError("Only container asset of local files are currently supported with LCP"))) - } - return await parseEncryptionData(in: asset) + // FIXME: Alternative to storing the license in the file? +// guard asset.container.sourceURL?.scheme == .file else { +// return .failure(.assetNotSupported(DebugError("Only container asset of local files are currently supported with LCP"))) +// } + + return await makeLCPAsset( + from: asset, + license: retrieveLicense( + in: .container(asset), + credentials: credentials, + allowUserInteraction: allowUserInteraction, + sender: sender + ) + ) + } + + private func retrieveLicense( + in asset: Asset, + credentials: String?, + allowUserInteraction: Bool, + sender: Any? + ) async -> Result { + let authentication = credentials.map { LCPPassphraseAuthentication($0, fallback: self.authentication) } + ?? self.authentication + + return await service.retrieveLicense( + from: asset, + authentication: authentication, + allowUserInteraction: allowUserInteraction, + sender: sender + ) + } + + func makeLCPAsset( + from asset: ContainerAsset, + license: Result + ) async -> Result { + await parseEncryptionData(in: asset) .mapError { ContentProtectionOpenError.reading(.decoding($0)) } .asyncFlatMap { encryptionData in - let authentication = credentials.map { LCPPassphraseAuthentication($0, fallback: self.authentication) } - ?? self.authentication - - let license = await self.service.retrieveLicense( - from: .container(asset), - authentication: authentication, - allowUserInteraction: allowUserInteraction, - sender: sender - ) + var asset = asset - if let license = try? license.get() { - let decryptor = LCPDecryptor(license: license, encryptionData: encryptionData) - asset.container = asset.container - .map(transform: decryptor.decrypt(at:resource:)) - } + let decryptor = LCPDecryptor(license: license.getOrNil(), encryptionData: encryptionData) + asset.container = asset.container + .map(transform: decryptor.decrypt(at:resource:)) let cpAsset = ContentProtectionAsset( asset: .container(asset), @@ -74,12 +159,20 @@ private final class LCPContentProtectionService: ContentProtectionService { self.error = error } - convenience init(result: Result) { + convenience init(result: Result) { switch result { case let .success(license): self.init(license: license) + case let .failure(error): - self.init(error: error) + switch error { + case .missingPassphrase: + // We don't expose errors due to user cancellation. + self.init() + + default: + self.init(error: error) + } } } @@ -104,3 +197,68 @@ public extension Publication { findService(LCPContentProtectionService.self)?.license } } + +private extension AssetRetriever { + func retrieve(link: Link) async -> Result { + guard let url = link.url() else { + return .failure(.reading(.decoding("The LCP License Document does not contain a valid HTTP URL to the protected publication"))) + } + + return await retrieve( + url: url, + mediaType: link.mediaType + ) + .mapError { error in + switch error { + case .formatNotSupported, .schemeNotSupported: + return .assetNotSupported(error) + case let .reading(error): + return .reading(error) + } + } + } + + func retrieve(url: HTTPURL, mediaType: MediaType?) async -> Result { + if let format = mediaType?.lcpFormat { + return await retrieve(url: url, format: format) + } else { + return await retrieve(url: url, hints: FormatHints(mediaType: mediaType)) + } + } +} + +private extension MediaType { + /// To avoid sniffing the media type of the known protected package, + /// we rely only on the link media type. This is fine because we already + /// know that the files are protected with LCP and so we don't need to + /// refine the format. + var lcpFormat: Format? { + if matches(.epub) { + return Format( + specifications: .zip, .epub, .lcp, + mediaType: .epub, + fileExtension: "epub" + ) + } else if matches(.lcpProtectedPDF) { + return Format( + specifications: .zip, .rpf, .lcp, + mediaType: .lcpProtectedPDF, + fileExtension: "lcpdf" + ) + } else if matches(.lcpProtectedAudiobook) { + return Format( + specifications: .zip, .rpf, .lcp, + mediaType: .lcpProtectedAudiobook, + fileExtension: "lcpa" + ) + } else if matches(.divina) { + return Format( + specifications: .zip, .rpf, .lcp, + mediaType: .divina, + fileExtension: "divina" + ) + } else { + return nil + } + } +} diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index f846c0793..a28932631 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -19,6 +19,7 @@ import UIKit /// when presenting a dialog, for example. public final class LCPService: Loggable { private let licenses: LicensesService + private let assetRetriever: AssetRetriever @available(*, unavailable, message: "Provide a `licenseRepository` and `passphraseRepository`, following the migration guide") public init( @@ -67,6 +68,8 @@ public final class LCPService: Loggable { repository: passphraseRepository ) ) + + self.assetRetriever = assetRetriever } @available(*, unavailable, message: "Check the conformance of the file `Format` to the `lcp` specification instead.") @@ -108,7 +111,7 @@ public final class LCPService: Loggable { authentication: LCPAuthenticating, allowUserInteraction: Bool, sender: Any? - ) async -> Result { + ) async -> Result { await wrap { try await licenses.retrieve( from: asset, @@ -126,7 +129,7 @@ public final class LCPService: Loggable { /// LCP license. The default implementation `LCPDialogAuthentication` presents a dialog to the /// user to enter their passphrase. public func contentProtection(with authentication: LCPAuthenticating) -> ContentProtection { - LCPContentProtection(service: self, authentication: authentication) + LCPContentProtection(service: self, authentication: authentication, assetRetriever: assetRetriever) } private func wrap(_ block: () async throws -> Success) async -> Result { diff --git a/Sources/LCP/License/Container/ContainerLicenseContainer.swift b/Sources/LCP/License/Container/ContainerLicenseContainer.swift new file mode 100644 index 000000000..9e8771b2c --- /dev/null +++ b/Sources/LCP/License/Container/ContainerLicenseContainer.swift @@ -0,0 +1,71 @@ +// +// 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 ReadiumShared +import ReadiumZIPFoundation + +/// Access to a License Document stored in a ``Container``. +/// Meant to be subclassed to customize the pathInZIP property, +/// eg. ``EPUBLicenseContainer``. +class ContainerLicenseContainer: LicenseContainer { + private let asset: ContainerAsset + private let licensePath: RelativeURL + + init(asset: ContainerAsset, pathInContainer: RelativeURL) { + self.asset = asset + licensePath = pathInContainer + } + + func containsLicense() async throws -> Bool { + asset.container[licensePath] != nil + } + + func read() async throws -> Data { + do { + guard let resource = asset.container[licensePath] else { + throw LCPError.licenseContainer(.fileNotFound(licensePath.string)) + } + + return try await resource.read().get() + + } catch { + throw LCPError.licenseContainer(.readFailed(path: licensePath.string)) + } + } + + var isWritable: Bool { + asset.format.conformsTo(.zip) && asset.container.sourceURL?.fileURL != nil + } + + func write(_ license: LicenseDocument) async throws { + guard let file = asset.container.sourceURL?.fileURL else { + throw LCPError.licenseContainer(.writeFailed(path: licensePath.string)) + } + + let archive: Archive + do { + archive = try await Archive(url: file.url, accessMode: .update) + } catch { + throw LCPError.licenseContainer(.openFailed(error)) + } + + do { + // Removes the old License if it already exists in the archive, otherwise we get duplicated entries + if let oldLicense = try await archive.get(licensePath.string) { + try await archive.remove(oldLicense) + } + + // Stores the License into the ZIP file + let data = license.jsonData + try await archive.addEntry(with: licensePath.string, type: .file, uncompressedSize: Int64(data.count), provider: { position, size -> Data in + data[position ..< Int64(size)] + }) + } catch { + throw LCPError.licenseContainer(.writeFailed(path: licensePath.string)) + } + } +} diff --git a/Sources/LCP/License/Container/EPUBLicenseContainer.swift b/Sources/LCP/License/Container/EPUBLicenseContainer.swift deleted file mode 100644 index 742ec0aad..000000000 --- a/Sources/LCP/License/Container/EPUBLicenseContainer.swift +++ /dev/null @@ -1,15 +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 ReadiumShared - -/// Access a License Document stored in an EPUB archive, under META-INF/license.lcpl. -final class EPUBLicenseContainer: ZIPLicenseContainer { - init(epub: FileURL) { - super.init(zip: epub, pathInZIP: "META-INF/license.lcpl") - } -} diff --git a/Sources/LCP/License/Container/LicenseContainer.swift b/Sources/LCP/License/Container/LicenseContainer.swift index 46bfd7fe1..cffcada6c 100644 --- a/Sources/LCP/License/Container/LicenseContainer.swift +++ b/Sources/LCP/License/Container/LicenseContainer.swift @@ -7,7 +7,8 @@ import Foundation import ReadiumShared -/// Encapsulates the read/write access to the packaged License Document (eg. in an EPUB container, or a standalone LCPL file) +/// Encapsulates the read/write access to the packaged License Document (eg. in +/// an EPUB container, or a standalone LCPL file) protocol LicenseContainer { /// Returns whether this container currently contains a License Document. /// @@ -15,32 +16,25 @@ protocol LicenseContainer { func containsLicense() async throws -> Bool func read() async throws -> Data + + /// Indicates whether this container can update its license. + var isWritable: Bool { get } + func write(_ license: LicenseDocument) async throws } func makeLicenseContainer(for asset: Asset) throws -> LicenseContainer { switch asset { case let .resource(asset): - guard - asset.format.conformsTo(.lcpLicense), - let file = asset.resource.sourceURL?.fileURL - else { - throw LCPError.licenseContainer(ContainerError.openFailed(nil)) + guard asset.format.conformsTo(.lcpLicense) else { + throw LCPError.licenseContainer(ContainerError.openFailed(DebugError("Expected an LCP License Document"))) } - return LCPLLicenseContainer(lcpl: file) + return ResourceLicenseContainer(asset: asset) case let .container(asset): - guard - asset.format.conformsTo(.zip), - let file = asset.container.sourceURL?.fileURL - else { - throw LCPError.licenseContainer(ContainerError.openFailed(nil)) - } - - if asset.format.conformsTo(.epub) { - return EPUBLicenseContainer(epub: file) - } else { - return ReadiumLicenseContainer(path: file) - } + return ContainerLicenseContainer( + asset: asset, + pathInContainer: RelativeURL(path: asset.format.conformsTo(.epub) ? "META-INF/license.lcpl" : "license.lcpl")! + ) } } diff --git a/Sources/LCP/License/Container/ReadiumLicenseContainer.swift b/Sources/LCP/License/Container/ReadiumLicenseContainer.swift deleted file mode 100644 index 113d34a65..000000000 --- a/Sources/LCP/License/Container/ReadiumLicenseContainer.swift +++ /dev/null @@ -1,15 +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 ReadiumShared - -/// Access a License Document stored in a webpub, audiobook or LCPDF package. -final class ReadiumLicenseContainer: ZIPLicenseContainer { - init(path: FileURL) { - super.init(zip: path, pathInZIP: "license.lcpl") - } -} diff --git a/Sources/LCP/License/Container/LCPLLicenseContainer.swift b/Sources/LCP/License/Container/ResourceLicenseContainer.swift similarity index 50% rename from Sources/LCP/License/Container/LCPLLicenseContainer.swift rename to Sources/LCP/License/Container/ResourceLicenseContainer.swift index 8e4febf03..ed8ea9ef3 100644 --- a/Sources/LCP/License/Container/LCPLLicenseContainer.swift +++ b/Sources/LCP/License/Container/ResourceLicenseContainer.swift @@ -7,28 +7,38 @@ import Foundation import ReadiumShared -/// Access to a License Document packaged as a standalone LCPL file. -final class LCPLLicenseContainer: LicenseContainer { - private let lcpl: FileURL +/// Access to a License Document packaged as a standalone LCPL file in a +/// ``Resource`` asset. +final class ResourceLicenseContainer: LicenseContainer { + private let asset: ResourceAsset - init(lcpl: FileURL) { - self.lcpl = lcpl + init(asset: ResourceAsset) { + self.asset = asset } func containsLicense() async throws -> Bool { - true + asset.format.conformsTo(.lcpLicense) } func read() async throws -> Data { - guard let data = try? Data(contentsOf: lcpl.url) else { + do { + return try await asset.resource.read().get() + } catch { throw LCPError.licenseContainer(.readFailed(path: ".")) } - return data + } + + var isWritable: Bool { + asset.format.conformsTo(.lcpLicense) && asset.resource.sourceURL?.fileURL != nil } func write(_ license: LicenseDocument) async throws { + guard let file = asset.resource.sourceURL?.fileURL else { + throw LCPError.licenseContainer(.writeFailed(path: ".")) + } + do { - try license.jsonData.write(to: lcpl.url, options: .atomic) + try license.jsonData.write(to: file.url, options: .atomic) } catch { throw LCPError.licenseContainer(.writeFailed(path: ".")) } diff --git a/Sources/LCP/License/Container/ZIPLicenseContainer.swift b/Sources/LCP/License/Container/ZIPLicenseContainer.swift deleted file mode 100644 index dc4b56823..000000000 --- a/Sources/LCP/License/Container/ZIPLicenseContainer.swift +++ /dev/null @@ -1,79 +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 ReadiumShared -import ReadiumZIPFoundation - -/// Access to a License Document stored in a ZIP archive. -/// Meant to be subclassed to customize the pathInZIP property, eg. EPUBLicenseContainer. -class ZIPLicenseContainer: LicenseContainer { - private let zip: FileURL - private let pathInZIP: String - - init(zip: FileURL, pathInZIP: String) { - self.zip = zip - self.pathInZIP = pathInZIP - } - - func containsLicense() async throws -> Bool { - do { - let archive = try await Archive(url: zip.url, accessMode: .read) - return try await archive.get(pathInZIP) != nil - } catch { - throw LCPError.licenseContainer(.openFailed(error)) - } - } - - func read() async throws -> Data { - let archive: Archive - do { - archive = try await Archive(url: zip.url, accessMode: .read) - } catch { - throw LCPError.licenseContainer(.openFailed(error)) - } - - var data = Data() - - do { - guard let entry = try await archive.get(pathInZIP) else { - throw LCPError.licenseContainer(.fileNotFound(pathInZIP)) - } - - _ = try await archive.extract(entry) { part in - data.append(part) - } - } catch { - throw LCPError.licenseContainer(.readFailed(path: pathInZIP)) - } - - return data - } - - func write(_ license: LicenseDocument) async throws { - let archive: Archive - do { - archive = try await Archive(url: zip.url, accessMode: .update) - } catch { - throw LCPError.licenseContainer(.openFailed(error)) - } - - do { - // Removes the old License if it already exists in the archive, otherwise we get duplicated entries - if let oldLicense = try await archive.get(pathInZIP) { - try await archive.remove(oldLicense) - } - - // Stores the License into the ZIP file - let data = license.jsonData - try await archive.addEntry(with: pathInZIP, type: .file, uncompressedSize: Int64(data.count), provider: { position, size -> Data in - data[position ..< Int64(size)] - }) - } catch { - throw LCPError.licenseContainer(.writeFailed(path: pathInZIP)) - } - } -} diff --git a/Sources/LCP/License/Model/LicenseDocument.swift b/Sources/LCP/License/Model/LicenseDocument.swift index 7d67192e7..ca47d6ddc 100644 --- a/Sources/LCP/License/Model/LicenseDocument.swift +++ b/Sources/LCP/License/Model/LicenseDocument.swift @@ -89,6 +89,11 @@ public struct LicenseDocument { } } + /// Link to the protected publication. + public var publicationLink: Link { + link(for: .publication)! + } + /// Returns the first link containing the given rel. public func link(for rel: Rel, type: MediaType? = nil) -> Link? { links.firstWithRel(rel.rawValue, type: type) diff --git a/Sources/LCP/Toolkit/Streamable.swift b/Sources/LCP/Toolkit/Streamable.swift new file mode 100644 index 000000000..2f560f0f7 --- /dev/null +++ b/Sources/LCP/Toolkit/Streamable.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 ReadiumShared + +extension Streamable { + /// Reads the whole content as a LCP License Document. + func readAsLCPL() async -> ReadResult { + await read().flatMap { data in + do { + return try .success(LicenseDocument(data: data)) + } catch { + return .failure(.decoding("Not a valid LCP License Document", cause: error)) + } + } + } +} diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 2c0ed963e..e09bb0baf 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -136,6 +136,10 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo } deinit { + if let timeObserver = timeObserver { + player.removeTimeObserver(timeObserver) + } + playTask?.cancel() AudioSession.shared.end(for: self) } @@ -221,7 +225,14 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo /// Seeks to the given time in the current resource. public func seek(to time: Double) async { + let wasPlaying = (state == .playing) + pause() + await player.seek(to: CMTime(seconds: time, preferredTimescale: 1000)) + + if wasPlaying { + play() + } } /// Seeks relatively from the current time in the current resource. @@ -232,6 +243,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo private var rateObserver: NSKeyValueObservation? private var timeControlStatusObserver: NSKeyValueObservation? private var currentItemObserver: NSKeyValueObservation? + private var timeObserver: Any? private lazy var mediaLoader = PublicationMediaLoader(publication: publication) @@ -241,7 +253,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo player.automaticallyWaitsToMinimizeStalling = false player.volume = Float(settings.volume) - player.addPeriodicTimeObserver( + timeObserver = player.addPeriodicTimeObserver( forInterval: CMTime( seconds: config.playbackRefreshInterval, preferredTimescale: 1000 @@ -399,6 +411,9 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo public private(set) var currentLocation: Locator? public func go(to locator: Locator, options: NavigatorGoOptions) async -> Bool { + let wasPlaying = (state == .playing) + pause() + guard let newResourceIndex = publication.readingOrder.firstIndexWithHREF(locator.href) else { return false } @@ -424,6 +439,10 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo await delegate?.navigator(self, didJumpTo: locator) } + if wasPlaying { + play() + } + return true } catch { diff --git a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift index 6c6869518..5ae455bda 100644 --- a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift +++ b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift @@ -156,7 +156,12 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log } private func fillData(_ dataRequest: AVAssetResourceLoadingDataRequest, of request: AVAssetResourceLoadingRequest, using resource: Resource, link: Link) { - let range: Range = UInt64(dataRequest.currentOffset) ..< (UInt64(dataRequest.currentOffset) + UInt64(dataRequest.requestedLength)) + let range: Range? + if dataRequest.currentOffset == 0, dataRequest.requestsAllDataToEndOfResource { + range = nil + } else { + range = UInt64(dataRequest.currentOffset) ..< (UInt64(dataRequest.currentOffset) + UInt64(dataRequest.requestedLength)) + } let task = Task { let result = await resource.stream( diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift index 31f6ad2ce..57e66a0f3 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift @@ -23,15 +23,17 @@ public actor HTTPResource: Resource { await headResponse() .map { response in ResourceProperties { - $0.filename = response.filename ?? url.lastPathSegment - $0.mediaType = response.mediaType + if let response = response { + $0.filename = response.filename ?? url.lastPathSegment + $0.mediaType = response.mediaType + } } } } public func estimatedLength() async -> ReadResult { await headResponse().flatMap { - if let length = $0.contentLength { + if let length = $0?.contentLength { return .success(UInt64(length)) } else { return .success(nil) @@ -39,14 +41,22 @@ public actor HTTPResource: Resource { } } - private var _headResponse: ReadResult? + private var _headResponse: ReadResult? /// Cached HEAD response to get the expected content length and other /// metadata. - private func headResponse() async -> ReadResult { + private func headResponse() async -> ReadResult { if _headResponse == nil { _headResponse = await client.fetch(HTTPRequest(url: url, method: .head)) - .mapError { .access(.http($0)) } + .map { $0 as HTTPResponse? } + .flatMapError { error in + switch error { + case let .errorResponse(response) where response.status == .methodNotAllowed: + return .success(nil) + default: + return .failure(.access(.http(error))) + } + } } return _headResponse! } diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift index 798415009..86cd0cbfa 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift @@ -43,7 +43,7 @@ final class ZIPFoundationArchiveFactory { } else { // We use a large buffer to avoid making hundreds of small HTTP // range requests. - let bufferSize = 512.kB + let bufferSize = 6.MB var resource: Resource = resource.buffered(size: bufferSize) if let optionalLength = await resource.estimatedLength().getOrNil(), let length = optionalLength { @@ -97,15 +97,14 @@ enum ResourceDataSourceError: Error { } /// Bridges the ZIPFoundation's ``DataSource`` with our ``Resource``. -private final class ResourceDataSource: ReadiumZIPFoundation.DataSource { +private actor ResourceDataSource: ReadiumZIPFoundation.DataSource { private let resource: Resource - private var _position: UInt64 = 0 init(resource: Resource) { self.resource = resource } - func close() throws {} + let isWritable: Bool = false func length() async throws -> UInt64 { guard let length = try await resource.estimatedLength().get() else { @@ -114,21 +113,36 @@ private final class ResourceDataSource: ReadiumZIPFoundation.DataSource { return length } - func position() async throws -> UInt64 { - _position + func openRead() async throws -> any DataSourceTransaction { + Transaction(resource: resource) } - func seek(to position: UInt64) async throws { - _position = position - } + private actor Transaction: ReadiumZIPFoundation.DataSourceTransaction { + private var _position: UInt64 = 0 + private let resource: Resource + + init(resource: Resource) { + self.resource = resource + } - func read(length: Int) async throws -> Data { - guard length > 0 else { - return Data() + func close() async throws {} + + func position() async throws -> UInt64 { + _position + } + + func seek(to position: UInt64) async throws { + _position = position + } + + func read(length: Int) async throws -> Data { + guard length > 0 else { + return Data() + } + let range = _position ..< (_position + UInt64(length)) + let data = try await resource.read(range: range).get() + _position += UInt64(data.count) + return data } - let range = _position ..< (_position + UInt64(length)) - let data = try await resource.read(range: range).get() - _position += UInt64(data.count) - return data } } diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift index 83e4bc1d4..9d5db53e6 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift @@ -21,7 +21,7 @@ final class ZIPFoundationContainer: Container, Loggable { var entries = [RelativeURL: Entry]() - for try await entry in archive { + for entry in try await archive.entries() { guard entry.type == .file, let url = RelativeURL(path: entry.path)?.normalized, diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 3f1028685..2be082ff3 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -353,11 +353,9 @@ ../../Sources/LCP/LCPService.swift ../../Sources/LCP/License ../../Sources/LCP/License/Container -../../Sources/LCP/License/Container/EPUBLicenseContainer.swift -../../Sources/LCP/License/Container/LCPLLicenseContainer.swift +../../Sources/LCP/License/Container/ContainerLicenseContainer.swift ../../Sources/LCP/License/Container/LicenseContainer.swift -../../Sources/LCP/License/Container/ReadiumLicenseContainer.swift -../../Sources/LCP/License/Container/ZIPLicenseContainer.swift +../../Sources/LCP/License/Container/ResourceLicenseContainer.swift ../../Sources/LCP/License/LCPError+wrap.swift ../../Sources/LCP/License/License.swift ../../Sources/LCP/License/LicenseValidation.swift @@ -390,6 +388,7 @@ ../../Sources/LCP/Toolkit/Bundle.swift ../../Sources/LCP/Toolkit/DataCompression.swift ../../Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift +../../Sources/LCP/Toolkit/Streamable.swift ../../Sources/Navigator ../../Sources/Navigator/Audiobook ../../Sources/Navigator/Audiobook/AudioNavigator.swift @@ -442,9 +441,13 @@ ../../Sources/Navigator/EPUB/Assets/Static/scripts ../../Sources/Navigator/EPUB/Assets/Static/scripts/.gitignore ../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js +../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js.map ../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js +../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js.map ../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed.js +../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed.js.map ../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-reflowable.js +../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-reflowable.js.map ../../Sources/Navigator/EPUB/CSS ../../Sources/Navigator/EPUB/CSS/CSSLayout.swift ../../Sources/Navigator/EPUB/CSS/CSSProperties.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 665dd7045..bf02dfc89 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 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 */; }; 3ECB525CEB712CEC5EFCD26D /* WarningLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3510E7E84A5361BCECC90569 /* WarningLogger.swift */; }; @@ -177,7 +178,6 @@ 6F01765B4C03EC36C95D02E3 /* CGPDF.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6EB7CAF6D058380A2AB711A /* CGPDF.swift */; }; 6F042D80A0E07C285E006678 /* WKWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103E0171A3CDEFA1B1F1F180 /* WKWebView.swift */; }; 6FEE606C7126F68B5018CAD0 /* Rights.swift in Sources */ = {isa = PBXBuildFile; fileRef = D94EB44EC5A15FF631AE8B2E /* Rights.swift */; }; - 6FFC08925BF26902CF49B830 /* LCPLLicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EC9BDFB5AC6D5E7FC8F6A4C /* LCPLLicenseContainer.swift */; }; 7064F38E9C805E2203972057 /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC925E451D875E5F74748EDC /* Optional.swift */; }; 70853F23094C2BAFAD422AFD /* ReadiumInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42FD63C2720614E558522675 /* ReadiumInternal.framework */; }; 71AC16AB1FE4CD6C63C8A103 /* HTTPURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C2F9F4D29EBDE812891418F /* HTTPURL.swift */; }; @@ -199,7 +199,6 @@ 7E303F9D6DDE98BEF63E67F1 /* FileContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620465B4404C5FA3649D683 /* FileContainer.swift */; }; 7E33030C45010C776A131BD5 /* Manifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCA5481C26E0513D55F4DE48 /* Manifest.swift */; }; 7E45E10720EA6B4F18196316 /* Metadata+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC59A963F316359DF8B119AC /* Metadata+Presentation.swift */; }; - 7F297EC335D8934E50361D39 /* ReadiumLicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D191FF1BE0BA97581EB070 /* ReadiumLicenseContainer.swift */; }; 8029C2773AF704561B09BA99 /* DirectionalNavigationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F7D914B293DF0A912613D2 /* DirectionalNavigationAdapter.swift */; }; 8046E0E588860C8C5F67BF33 /* Publication+Deprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9627A9AFF7C08010248E1700 /* Publication+Deprecated.swift */; }; 80FACAC721EBA4A11764482C /* EPUBPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DA40519A11DDE69CDDBB1C /* EPUBPreferences.swift */; }; @@ -214,6 +213,7 @@ 8BA982B992E0370C7BF94DF6 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55D0EAD1ABB7B829A3891D3A /* UIView.swift */; }; 8BD3DB373A8785BE8E71845D /* EPUBFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0495085D1A7758A1197F666E /* EPUBFormatSniffer.swift */; }; 8CCDF77696A0F2C7BF3171CC /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600D8714B762FE37DE405C2E /* LocalizedString.swift */; }; + 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 */; }; 8F5B0B5B83BF7F1145556FF8 /* Properties+OPDS.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD79372361D085CA0500CF4 /* Properties+OPDS.swift */; }; @@ -259,6 +259,7 @@ A903542B8398018EB9C5A7F6 /* AudioFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE5CE54D209AEA4AE5B07618 /* AudioFormatSniffer.swift */; }; A9DFAA4F1D752E15B432FFAB /* AudioPublicationManifestAugmentor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */; }; AABE86D87AEF1253765D1A88 /* HREF.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CA9A244D941CB63515EDDE /* HREF.swift */; }; + AADE9BC2642DEAD9B2936FB6 /* ResourceLicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D5DCD95C7B908BB6CA77C8 /* ResourceLicenseContainer.swift */; }; AAF00F4BC4765B6755AB46A3 /* Properties+Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 294E01A2E6FF25539EBC1082 /* Properties+Archive.swift */; }; ACD1914D2D9BB7141148740F /* ReadiumShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97BC822B36D72EF548162129 /* ReadiumShared.framework */; }; AD0C25FD15876213BD8A3AAE /* ArchiveOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41EB258B894B86A0DA1D00D4 /* ArchiveOpener.swift */; }; @@ -327,7 +328,6 @@ D25058A427D47ABEA88A3F4B /* PublicationSpeechSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99DE4955327D8C2DE6F866D3 /* PublicationSpeechSynthesizer.swift */; }; D29E1DBC5BD1B82C996427C4 /* Closeable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02A9225D636D845BF24F6AC /* Closeable.swift */; }; D4BBC0AD7652265497B5CD1C /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10894CC9684584098A22D8FA /* URLExtensions.swift */; }; - D50FE2B82BB34E2881723BE9 /* ZIPLicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6453ABD6DF237362C6EECD2 /* ZIPLicenseContainer.swift */; }; D525E9E2AFA0ED63151A4FBC /* Assets in Resources */ = {isa = PBXBuildFile; fileRef = DBCE9786DD346E6BDB2E50FF /* Assets */; }; D589F337B244F5B58E85AEFB /* AudioSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC9D0ACCBA7B6E4473A95D5E /* AudioSettings.swift */; }; D5AF26F18E98CEF06AEC0329 /* SQLiteLCPLicenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17ACAC3E8F61DA108DCC9F51 /* SQLiteLCPLicenseRepository.swift */; }; @@ -342,7 +342,6 @@ D9499DACC5329F3774CBEDAC /* SwiftSoup.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE09289EB0FEA5FEC8506B1F /* SwiftSoup.xcframework */; }; D94A07E9627214888D37C6DF /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64FBE3CA5C1B0C73A22E86D /* Bundle.swift */; }; DB423F5860A1C47EF2E18113 /* PDFTapGestureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF324AD5E3E30687AC5262D /* PDFTapGestureController.swift */; }; - DC0487666F03A3FAFE49D0B9 /* EPUBLicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500E55D9CA753D6D6AA76D10 /* EPUBLicenseContainer.swift */; }; 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 */; }; @@ -495,7 +494,6 @@ 01A72947C91934D96A7EAA23 /* RootFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootFile.swift; sourceTree = ""; }; 01B24895126F2A744A8E9E61 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; 01CCE64AE9824DCF6D6413BC /* PerResourcePositionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerResourcePositionsService.swift; sourceTree = ""; }; - 01D191FF1BE0BA97581EB070 /* ReadiumLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumLicenseContainer.swift; sourceTree = ""; }; 01D342CCDB55C0E7089F905C /* LCPLicenseFormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPLicenseFormatSniffer.swift; sourceTree = ""; }; 031593240E2CCD681E652D7E /* ReadiumAdapterGCDWebServer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumAdapterGCDWebServer.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 03C234075C7F7573BA54B77D /* EPUBParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBParser.swift; sourceTree = ""; }; @@ -577,7 +575,6 @@ 3DA7FFAA3EA2B45961391DDF /* HTTPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPError.swift; sourceTree = ""; }; 3DF324AD5E3E30687AC5262D /* PDFTapGestureController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFTapGestureController.swift; sourceTree = ""; }; 3DFAC865449A1A225BF534DA /* OPDSAcquisition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSAcquisition.swift; sourceTree = ""; }; - 3EC9BDFB5AC6D5E7FC8F6A4C /* LCPLLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPLLicenseContainer.swift; sourceTree = ""; }; 3F95F3F20D758BE0E7005EA3 /* DifferenceKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = DifferenceKit.xcframework; path = ../../Carthage/Build/DifferenceKit.xcframework; sourceTree = ""; }; 3FD12CFF76C3F2946929CF93 /* Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; 4031FC7E7A15217731764EB2 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; @@ -607,7 +604,6 @@ 4BF3D04FB9D6A90EE77F1F02 /* ResourceProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceProperties.swift; sourceTree = ""; }; 4E564AE6D5137499C81FEBE2 /* TargetAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetAction.swift; sourceTree = ""; }; 50064FE9BBCEA4C00BA6BBEF /* ZIPFoundationArchiveOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZIPFoundationArchiveOpener.swift; sourceTree = ""; }; - 500E55D9CA753D6D6AA76D10 /* EPUBLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBLicenseContainer.swift; sourceTree = ""; }; 505BF8A630F7C7B96754E333 /* InMemoryPositionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryPositionsService.swift; sourceTree = ""; }; 508E0CD4F9F02CC851E6D1E1 /* Publication+EPUB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publication+EPUB.swift"; sourceTree = ""; }; 5124A0F95B52BA336E07C3D3 /* RelativeURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelativeURL.swift; sourceTree = ""; }; @@ -653,6 +649,7 @@ 7214B2366A4E024517FF8C76 /* HTTPRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequest.swift; sourceTree = ""; }; 72922E22040CEFB3B7BBCDAF /* LoggerStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerStub.swift; sourceTree = ""; }; 733C1DF0A4612D888376358B /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.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 = ""; }; @@ -692,6 +689,7 @@ 8DA31089FCAD8DFB9AC46E4E /* Tokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tokenizer.swift; sourceTree = ""; }; 8EA0008AF1B9B97962824D85 /* FallbackContentProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackContentProtection.swift; 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 = ""; }; 93BF3947EBA8736BF20F36FB /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; 9407E818636BEA4550E57F57 /* ReadiumNavigator.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumNavigator.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -724,7 +722,6 @@ A4F0C112656C4786F3861973 /* CoverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverService.swift; sourceTree = ""; }; A5A115134AA0B8F5254C8139 /* LCPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPError.swift; sourceTree = ""; }; A5BBF2FA3188DFFCF7B88A75 /* DefaultPublicationParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultPublicationParser.swift; sourceTree = ""; }; - A6453ABD6DF237362C6EECD2 /* ZIPLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZIPLicenseContainer.swift; sourceTree = ""; }; A666276329312F001163298C /* CompositeArchiveOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeArchiveOpener.swift; sourceTree = ""; }; A6AD227BF7C477BF13B0BB94 /* PDFDocumentHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFDocumentHolder.swift; sourceTree = ""; }; A75B535BBE6EE5898327DB52 /* HTMLInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLInjection.swift; sourceTree = ""; }; @@ -817,6 +814,7 @@ E1DAAE19E8372F6ECF772E0A /* MediaOverlayNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaOverlayNode.swift; sourceTree = ""; }; E1FB533E84CE563807BDB012 /* FormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatSniffer.swift; sourceTree = ""; }; 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 = ""; }; E5D7B566F794F356878AE8E0 /* PDFOutlineNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFOutlineNode.swift; sourceTree = ""; }; E5DF154DCC73CFBDB0F919DE /* AbsoluteURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsoluteURL.swift; sourceTree = ""; }; @@ -998,11 +996,9 @@ 099ACCDBBF9DE18DCDCAB123 /* Container */ = { isa = PBXGroup; children = ( - 500E55D9CA753D6D6AA76D10 /* EPUBLicenseContainer.swift */, - 3EC9BDFB5AC6D5E7FC8F6A4C /* LCPLLicenseContainer.swift */, + 9234A0351FDE626D8D242223 /* ContainerLicenseContainer.swift */, 15980B67505AAF10642B56C8 /* LicenseContainer.swift */, - 01D191FF1BE0BA97581EB070 /* ReadiumLicenseContainer.swift */, - A6453ABD6DF237362C6EECD2 /* ZIPLicenseContainer.swift */, + E2D5DCD95C7B908BB6CA77C8 /* ResourceLicenseContainer.swift */, ); path = Container; sourceTree = ""; @@ -1762,6 +1758,7 @@ F64FBE3CA5C1B0C73A22E86D /* Bundle.swift */, 1EBC685D4A0E07997088DD2D /* DataCompression.swift */, 9883F57707AC488197F4312E /* ReadiumLCPLocalizedString.swift */, + 739566E777BA37891BCECB95 /* Streamable.swift */, ); path = Toolkit; sourceTree = ""; @@ -2470,10 +2467,10 @@ files = ( D94A07E9627214888D37C6DF /* Bundle.swift in Sources */, 6D3BCAFF29D91DCA08809D71 /* CRLService.swift in Sources */, + 8CD0D28056D2BBADE170ABF6 /* ContainerLicenseContainer.swift in Sources */, 7CD20AED276833F9585B0E3B /* ContentKey.swift in Sources */, 81ADB258F083647221CED24F /* DataCompression.swift in Sources */, 294217B18570409AB1C317AD /* DeviceService.swift in Sources */, - DC0487666F03A3FAFE49D0B9 /* EPUBLicenseContainer.swift in Sources */, E8293787CB5E5CECE38A63B2 /* Encryption.swift in Sources */, 93214069D844E340CEA229AB /* EncryptionParser.swift in Sources */, 1BF9469B4574D30E5C9BB75E /* Event.swift in Sources */, @@ -2487,7 +2484,6 @@ B066F9DDCD00A8917478CB6C /* LCPDialogViewController.swift in Sources */, 25349166318EB00EE8A0765C /* LCPError+wrap.swift in Sources */, 98702AFB56F9C50F7246CDDA /* LCPError.swift in Sources */, - 6FFC08925BF26902CF49B830 /* LCPLLicenseContainer.swift in Sources */, C4F0A98562FDDB478F7DD0A9 /* LCPLicense.swift in Sources */, 9A463F872E1B05B64E026EBB /* LCPLicenseRepository.swift in Sources */, F90CF6CE1D4F5FA195E19D76 /* LCPPassphraseAuthentication.swift in Sources */, @@ -2505,13 +2501,13 @@ 2207C27B96F098AAF8B31F2C /* PassphrasesService.swift in Sources */, BAC8616BD37C22BC5541959A /* PotentialRights.swift in Sources */, 969961137E590BAEFBEB9CAB /* ReadiumLCPLocalizedString.swift in Sources */, - 7F297EC335D8934E50361D39 /* ReadiumLicenseContainer.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 */, - D50FE2B82BB34E2881723BE9 /* ZIPLicenseContainer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TestApp/README.md b/TestApp/README.md index 4970e3d36..720fe7530 100644 --- a/TestApp/README.md +++ b/TestApp/README.md @@ -2,8 +2,6 @@ This sample application demonstrates how to integrate the Readium Swift toolkit in your own reading app. Stable versions are [published on TestFlight](https://testflight.apple.com/join/lYEMEfBr). -:warning: The Readium toolkit itself supports down to iOS 11, but the Test App requires iOS 14 and Xcode 13.2. - ## Features * Supported publication formats: @@ -40,7 +38,8 @@ This project shows how to use Readium with several dependency managers: Swift Pa make spm ``` -:warning: Since the Xcode project is not committed to this repository, you need to run the `make ` command again after pulling any change from `r2-testapp-swift`. +> [!IMPORTANT] +> Since the Xcode project is not committed to this repository, you need to run the `make ` command again after pulling any change from the repository. ### Building with Readium LCP diff --git a/docs/Guides/Content.md b/docs/Guides/Content.md index 5dddb65af..ad66cbbb6 100644 --- a/docs/Guides/Content.md +++ b/docs/Guides/Content.md @@ -1,6 +1,7 @@ # Extracting the content of a publication -:warning: The described feature is still experimental and the implementation incomplete. +> [!NOTE] +> The described feature is still experimental and the implementation incomplete. Many high-level features require access to the raw content (text, media, etc.) of a publication, such as: diff --git a/docs/Guides/EPUB Fonts.md b/docs/Guides/EPUB Fonts.md index ae0032441..e61d78a66 100644 --- a/docs/Guides/EPUB Fonts.md +++ b/docs/Guides/EPUB Fonts.md @@ -2,7 +2,8 @@ Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](Navigator%20Preferences.md). -:warning: 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. +> [!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 diff --git a/docs/Guides/Getting Started.md b/docs/Guides/Getting Started.md index e6a3aaa69..e61d58a1d 100644 --- a/docs/Guides/Getting Started.md +++ b/docs/Guides/Getting Started.md @@ -2,7 +2,8 @@ The Readium Swift toolkit enables you to develop reading apps for iOS and iPadOS. It provides built-in support for multiple publication formats such as EPUB, PDF, audiobooks, and comics. -:warning: Readium offers only low-level tools. You are responsible for creating a user interface for reading and managing books, as well as a data layer to store the user's publications. The Test App is an example of such integration. +> [!NOTE] +> Readium offers only low-level tools. You are responsible for creating a user interface for reading and managing books, as well as a data layer to store the user's publications. The Test App is an example of such integration. ## Design principles diff --git a/docs/Guides/Navigator Preferences.md b/docs/Guides/Navigator Preferences.md index 0109aaca2..f971d0cae 100644 --- a/docs/Guides/Navigator Preferences.md +++ b/docs/Guides/Navigator Preferences.md @@ -311,7 +311,8 @@ In the following example, the `Picker` is built with `preference.supportedValues } ``` -:point_up: Contrarily to a `RangePreference`, there's no intrinsic `format(value:)` helper for an `EnumPreference`. Instead, you must provide your own localized strings for the possible values. +> [!NOTE] +> Contrarily to a `RangePreference`, there's no intrinsic `format(value:)` helper for an `EnumPreference`. Instead, you must provide your own localized strings for the possible values. ```swift pickerRow( @@ -363,7 +364,8 @@ let sharedPrefs = preferences.filterSharedPreferences() let combinedPrefs = publicationPrefs.merging(sharedPrefs) ``` -:warning: Some preferences are closely tied to a specific publication and should never be shared between multiple publications, such as the language. It is recommended that you store these preferences separately per book, which is what the suggested filters will do if you use them. +> [!TIP] +> Some preferences are closely tied to a specific publication and should never be shared between multiple publications, such as the language. It is recommended that you store these preferences separately per book, which is what the suggested filters will do if you use them. ## Appendix: Preference constraints diff --git a/docs/Guides/Navigator/EPUB Fonts.md b/docs/Guides/Navigator/EPUB Fonts.md index ae0032441..e61d78a66 100644 --- a/docs/Guides/Navigator/EPUB Fonts.md +++ b/docs/Guides/Navigator/EPUB Fonts.md @@ -2,7 +2,8 @@ Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](Navigator%20Preferences.md). -:warning: 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. +> [!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 diff --git a/docs/Guides/Navigator/Navigator.md b/docs/Guides/Navigator/Navigator.md index 736713535..827d485c3 100644 --- a/docs/Guides/Navigator/Navigator.md +++ b/docs/Guides/Navigator/Navigator.md @@ -2,7 +2,8 @@ You can use a Readium Navigator to present the publication to the user. The `Navigator` renders resources on the screen and offers APIs and user interactions for navigating the contents. -:warning: Navigators do not have user interfaces besides the view that displays the publication. Applications are responsible for providing a user interface with bookmark buttons, a progress bar, etc. +> [!IMPORTANT] +> Navigators do not have user interfaces besides the view that displays the publication. Applications are responsible for providing a user interface with bookmark buttons, a progress bar, etc. ## Default implementations @@ -71,7 +72,8 @@ let navigator = try EPUBNavigatorViewController( hostViewController.present(navigator, animated: true) ``` -:point_up: 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. +> [!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 @@ -156,7 +158,8 @@ if let locator = publication.locate(progression: 0.5) { ### Displaying the number of positions -:warning: 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. +> [!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. diff --git a/docs/Guides/Navigator/Preferences.md b/docs/Guides/Navigator/Preferences.md index 0109aaca2..f971d0cae 100644 --- a/docs/Guides/Navigator/Preferences.md +++ b/docs/Guides/Navigator/Preferences.md @@ -311,7 +311,8 @@ In the following example, the `Picker` is built with `preference.supportedValues } ``` -:point_up: Contrarily to a `RangePreference`, there's no intrinsic `format(value:)` helper for an `EnumPreference`. Instead, you must provide your own localized strings for the possible values. +> [!NOTE] +> Contrarily to a `RangePreference`, there's no intrinsic `format(value:)` helper for an `EnumPreference`. Instead, you must provide your own localized strings for the possible values. ```swift pickerRow( @@ -363,7 +364,8 @@ let sharedPrefs = preferences.filterSharedPreferences() let combinedPrefs = publicationPrefs.merging(sharedPrefs) ``` -:warning: Some preferences are closely tied to a specific publication and should never be shared between multiple publications, such as the language. It is recommended that you store these preferences separately per book, which is what the suggested filters will do if you use them. +> [!TIP] +> Some preferences are closely tied to a specific publication and should never be shared between multiple publications, such as the language. It is recommended that you store these preferences separately per book, which is what the suggested filters will do if you use them. ## Appendix: Preference constraints diff --git a/docs/Guides/Open Publication.md b/docs/Guides/Open Publication.md index 3546e10ad..3c6838455 100644 --- a/docs/Guides/Open Publication.md +++ b/docs/Guides/Open Publication.md @@ -34,7 +34,8 @@ switch await assetRetriever.retrieve(url: url) { } ``` -:point_up: Assets created with an HTTP URL are not downloaded; they will be streamed. If that is not your intention, you need to download the file first, for example using `HTTPClient.download()`. +> [!IMPORTANT] +> Assets created with an HTTP URL are not downloaded; they will be streamed. If that is not your intention, you need to download the file first, for example using `HTTPClient.download()`. The `AssetRetriever` will sniff the media type of the asset, which you can store in your bookshelf database to speed up the process next time you retrieve the `Asset`. This will improve performance, especially with HTTP URL schemes. @@ -58,10 +59,11 @@ let result = await assetRetriever.retrieve(url: url, mediaType: mediaType) let publicationOpener = PublicationOpener( parser: DefaultPublicationParser( httpClient: httpClient, - assetRetriever: assetRetriever + assetRetriever: assetRetriever, + pdfFactory: DefaultPDFDocumentFactory() ), contentProtections: [ - lcpService.contentProtection(with: LCPDialogAuthentication()), + lcpService.contentProtection(with: LCPDialogAuthentication()) ] ) ``` @@ -73,7 +75,7 @@ Now that you have a `PublicationOpener` ready, you can use it to create a `Publi The `allowUserInteraction` parameter is useful when supporting Readium LCP. When enabled and using a `LCPDialogAuthentication`, the toolkit will prompt the user if the passphrase is missing. ```swift -let result = await readium.publicationOpener.open( +let result = await publicationOpener.open( asset: asset, allowUserInteraction: true, sender: sender diff --git a/docs/Guides/Readium LCP.md b/docs/Guides/Readium LCP.md index 089339f7c..b46c04b4e 100644 --- a/docs/Guides/Readium LCP.md +++ b/docs/Guides/Readium LCP.md @@ -2,7 +2,8 @@ You can use the Readium Swift toolkit to download and read publications that are protected with the [Readium LCP](https://www.edrlab.org/readium-lcp/) DRM. -:point_up: To use LCP with the Readium toolkit, you must first obtain the `R2LCPClient` private library by contacting [EDRLab](https://www.edrlab.org/contact/). +> [!IMPORTANT] +> To use LCP with the Readium toolkit, you must first obtain the `R2LCPClient` private library by contacting [EDRLab](https://www.edrlab.org/contact/). ## Overview @@ -33,7 +34,8 @@ Readium LCP specifies new file formats. | [LCP for PDF package](https://readium.org/lcp-specs/notes/lcp-for-pdf.html) | `.lcpdf` | `application/pdf+lcp` | | [LCP for Audiobooks package](https://readium.org/lcp-specs/notes/lcp-for-audiobooks.html) | `.lcpa` | `application/audiobook+lcp` | -:point_up: EPUB files protected by LCP are supported without a special file extension or media type because EPUB accommodates any DRM scheme in its specification. +> [!NOTE] +> EPUB files protected by LCP are supported without a special file extension or media type because EPUB accommodates any DRM scheme in its specification. To support these formats in your application, you need to [register them in your `Info.plist`](https://developer.apple.com/documentation/uniformtypeidentifiers/defining_file_and_data_types_for_your_app) as imported types. @@ -150,7 +152,8 @@ Next, declare the imported types as [Document Types](https://help.apple.com/xcod ``` -:point_up: If EPUB is not included in your document types, now is a good time to add it. +> [!TIP] +> If EPUB is not included in your document types, now is a good time to add it. ## Initializing the `LCPService` @@ -214,14 +217,21 @@ After the download is completed, import the `publication.localURL` file into the ## Opening a publication protected with LCP -### Initializing the `Streamer` +### Initializing the `PublicationOpener` -A publication protected with LCP can be opened using the `Streamer` component, just like a non-protected publication. However, you must provide a [`ContentProtection`](https://readium.org/architecture/proposals/006-content-protection.html) implementation when initializing the `Streamer` to enable LCP. Luckily, `LCPService` has you covered. +A publication protected with LCP can be opened using the `PublicationOpener` component, just like a non-protected publication. However, you must provide a [`ContentProtection`](https://readium.org/architecture/proposals/006-content-protection.html) implementation when initializing the `PublicationOpener` to enable LCP. Luckily, `LCPService` has you covered. ```swift +let httpClient = DefaultHTTPClient() + let authentication = LCPDialogAuthentication() -let streamer = Streamer( +let publicationOpener = PublicationOpener( + parser: DefaultPublicationParser( + httpClient: httpClient, + assetRetriever: AssetRetriever(httpClient: httpClient), + pdfFactory: DefaultPDFDocumentFactory() + ), contentProtections: [ lcpService.contentProtection(with: authentication) ] @@ -230,35 +240,39 @@ let streamer = Streamer( An LCP package is secured with a *user passphrase* for decrypting the content. The `LCPAuthenticating` protocol used by `LCPService.contentProtection(with:)` provides the passphrase when needed. You can use the default `LCPDialogAuthentication` which displays a pop-up to enter the passphrase, or implement your own method for passphrase retrieval. -:point_up: The user will be prompted once per passphrase since `ReadiumLCP` stores known passphrases on the device. +> [!NOTE] +> The user will be prompted once per passphrase since `ReadiumLCP` stores known passphrases on the device. ### Opening the publication -You are now ready to open the publication file with your `Streamer` instance. +You are now ready to open the publication file with your `PublicationOpener` instance. ```swift -streamer.open( - asset: FileAsset(url: publicationURL), +// Retrieve an `Asset` to access the file content. +let url = FileURL(path: "/path/to/lcp-protected-book.epub", isDirectory: false) +let asset = try await assetRetriever.retrieve(url: url).get() + +// Open a `Publication` from the `Asset`. +let result = await publicationOpener.open( + asset: asset, allowUserInteraction: true, - sender: hostViewController, - completion: { result in - switch result { - case .success(let publication): - // Import or present the publication. - case .failure(let error): - // Present the error. - case .cancelled: - // The operation was cancelled. - } - } + sender: hostViewController ) + +switch result { +case .success(let publication): + // Import or present the publication. +case .failure(let error): + // Present the error. +} ``` The `allowUserInteraction` and `sender` arguments are forwarded to the `LCPAuthenticating` implementation when the passphrase is unknown. `LCPDialogAuthentication` shows a pop-up only if `allowUserInteraction` is `true`, using the `sender` as the pop-up's host `UIViewController`. 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. -:point_up: To check if a publication is protected with LCP before opening it, you can use `LCPService.isLCPProtected()`. +> [!TIP] +> To check if a publication is protected with LCP before opening it, you can use `LCPService.isLCPProtected()`. ### Using the opened `Publication` @@ -279,6 +293,40 @@ if publication.isRestricted { } ``` +## Streaming an LCP protected package + +If the server hosting the LCP protected package supports the [HTTP `HEAD` method](https://httpwg.org/specs/rfc9110.html#HEAD) and [HTTP Range requests](https://httpwg.org/specs/rfc7233.html), it is possible to stream directly an LCP protected publication from a License Document (`.lcpl`) file, without downloading the whole publication first. + +Simply open the License Document directly using the `PublicationOpener`. Make sure you provide an `HTTPClient` (or an `HTTPResourceFactory` for additional customization) to the `AssetRetriever`. + +```swift +// Instantiate the required components. +let httpClient = DefaultHTTPClient() +let assetRetriever = AssetRetriever(httpClient: httpClient) +let publicationOpener = PublicationOpener( + parser: DefaultPublicationParser( + httpClient: httpClient, + assetRetriever: assetRetriever + ), + contentProtections: [ + lcpService.contentProtection(with: LCPDialogAuthentication()), + ] +) + +// Retrieve an `Asset` to access the LCPL content. +let url = FileURL(path: "/path/to/license.lcpl", isDirectory: false) +let asset = try await assetRetriever.retrieve(url: url).get() + +// Open a `Publication` from the LCPL `Asset`. +let publication = try await publicationOpener.open( + asset: asset, + allowUserInteraction: true, + sender: hostViewController +).get() + +print("Opened \(publication.metadata.title)") +``` + ## Obtaining information on an LCP license An LCP License Document contains metadata such as its expiration date, the remaining number of characters to copy and the user name. You can access this information using an `LCPLicense` object. @@ -365,6 +413,4 @@ lcpLicense.renewLoan( The APIs may fail with an `LCPError`. These errors **must** be displayed to the user with a suitable message. -`LCPError` implements `LocalizedError`, enabling you to retrieve a user-friendly message. It's recommended to override the LCP localized strings in your app to translate them. These strings can be found at [Sources/LCP/Resources/en.lproj/Localizable.strings](https://github.com/readium/swift-toolkit/blob/main/Sources/LCP/Resources/en.lproj/Localizable.strings). - -:warning: In the next major update, `LCPError` will no longer be localized. Applications will need to provide their own localized error messages. If you are adding LCP to a new app, consider treating `LCPError` as non-localized from the start to ease future migration. +For an example, [take a look at the Test App](https://github.com/readium/swift-toolkit/blob/3.0.0/TestApp/Sources/App/Readium.swift#L221). \ No newline at end of file diff --git a/docs/Guides/TTS.md b/docs/Guides/TTS.md index a22c062bd..31505058e 100644 --- a/docs/Guides/TTS.md +++ b/docs/Guides/TTS.md @@ -1,6 +1,7 @@ # Text-to-speech -:warning: TTS is an experimental feature which is not yet implemented for all formats. +> [!NOTE] +> TTS is not yet implemented for all formats. Text-to-speech can be used to read aloud a publication using a synthetic voice. The Readium toolkit ships with a TTS implementation based on the native [Apple Speech Synthesis](https://developer.apple.com/documentation/avfoundation/speech_synthesis), but it is opened for extension if you want to use a different TTS engine. @@ -53,7 +54,8 @@ When pairing the `PublicationSpeechSynthesizer` with a `Navigator`, you can use ## Configuring the TTS -:warning: The way the synthesizer is configured is expected to change with the introduction of the new Settings API. Expect some breaking changes when updating. +> [!WARNING] +> The way the synthesizer is configured is expected to change with the introduction of the new Settings API. Expect some breaking changes when updating. The `PublicationSpeechSynthesizer` offers some options to configure the TTS engine. Note that the support of each configuration option depends on the TTS engine used. diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index c9966c6dc..0489e2c5a 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -15,7 +15,8 @@ If you use Carthage, add `Minizip.xcframework` back to your dependencies. No cha ## 3.0.0 -:warning: If you upgrade from an `alpha` or `beta` version of 3.0.0, please refer to the [3.0.0-beta.2 migration guide](https://github.com/readium/swift-toolkit/blob/3.0.0-beta.2/docs/Migration%20Guide.md) instead. +> [!IMPORTANT] +> If you upgrade from an `alpha` or `beta` version of 3.0.0, please refer to the [3.0.0-beta.2 migration guide](https://github.com/readium/swift-toolkit/blob/3.0.0-beta.2/docs/Migration%20Guide.md) instead. ### R2 prefix dropped @@ -71,7 +72,8 @@ If you use Carthage, remove `Minizip.xcframework` from your dependencies and add ### Migration of HREFs and Locators (bookmarks, annotations, etc.) - :warning: This requires a database migration in your application, if you were persisting `Locator` objects. + > [!CAUTION] + > This requires a database migration in your application, if you were persisting `Locator` objects. In Readium v2.x, a `Link` or `Locator`'s `href` could be either: @@ -257,11 +259,13 @@ A new Readium package was added to host the private internal utilities used by t pod 'ReadiumInternal', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.5.0/Support/CocoaPods/ReadiumInternal.podspec' ``` -:warning: It is not recommended to use any API from `ReadiumInternal` directly in your application. No compatibility guarantee is made between two versions. +> [!CAUTION] +> It is not recommended to use any API from `ReadiumInternal` directly in your application. No compatibility guarantee is made between two versions. ### Migrating the HTTP server -:warning: Migrating to the new Preferences API (see below) is required for the user settings to work with the new HTTP server. +> [!IMPORTANT] +> Migrating to the new Preferences API (see below) is required for the user settings to work with the new HTTP server. The Streamer's `PublicationServer` is now deprecated and you don't need to manage the HTTP server or register publications manually to it anymore.