diff --git a/Documentation/Guides/Getting Started.md b/Documentation/Guides/Getting Started.md index 4007dc625..98b855375 100644 --- a/Documentation/Guides/Getting Started.md +++ b/Documentation/Guides/Getting Started.md @@ -16,9 +16,9 @@ The toolkit has been designed following these core tenets: ### Main packages -* `R2Shared` contains shared `Publication` models and utilities. -* `R2Streamer` parses publication files (e.g. an EPUB) into a `Publication` object. -* [`R2Navigator` renders the content of a publication](Navigator/Navigator.md). +* `ReadiumShared` contains shared `Publication` models and utilities. +* `ReadiumStreamer` parses publication files (e.g. an EPUB) into a `Publication` object. +* [`ReadiumNavigator` renders the content of a publication](Navigator/Navigator.md). ### Specialized packages @@ -30,7 +30,7 @@ The toolkit has been designed following these core tenets: * `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 (`R2Shared`) +## Overview of the shared models (`ReadiumShared`) The Readium toolkit provides models used as exchange types between packages. @@ -48,7 +48,6 @@ A `Publication` instance: #### Link - A [`Link` object](https://readium.org/webpub-manifest/#24-the-link-object) holds a pointer (URL) to a resource or service along with additional metadata, such as its media type or title. The `Publication` contains several `Link` collections, for example: @@ -70,68 +69,87 @@ A [`Locator` object](https://readium.org/architecture/models/locators/) represen ### Data models -#### Publication Asset +#### Asset -A `PublicationAsset` is an interface representing a single file or package holding the content of a `Publication`. A default implementation `FileAsset` grants access to a publication stored locally. +An `Asset` represents a single file or package and provides access to its content. There are two types of `Asset`: -#### Resource +* `ContainerAsset` for packages which contains several resources, such as a ZIP archive. +* `ResourceAsset` for accessing a single resource, such as a JSON or PDF file. -A `Resource` provides read access to a single resource of a publication, such as a file or an entry in an archive. +`Asset` instances are obtained through an `AssetRetriever`. -`Resource` instances are usually created by a `Fetcher`. The toolkit ships with various implementations supporting different data access protocols such as local files, HTTP, etc. +You can use the `asset.format` to identify the media type and capabilities of the asset. -#### Fetcher +```swift +if asset.format.conformsTo(.lcp) { + // The asset is protected with LCP. +} +if asset.format.conformsTo(.epub) { + // The asset represents an EPUB publication. +} +``` -A `Fetcher` provides read access to a collection of resources. `Fetcher` instances are created by a `PublicationAsset` to provide access to the content of a publication. +#### Resource -`Publication` objects internally use a `Fetcher` to expose their content. +A `Resource` provides read access to a single resource, such as a file or an entry in an archive. -## Opening a publication (`R2Streamer`) +`Resource` instances are usually created by a `ResourceFactory`. The toolkit ships with various implementations supporting different data access protocols such as local files or HTTP. -To retrieve a `Publication` object from a publication file like an EPUB or audiobook, begin by creating a `PublicationAsset` object used to read the file. Readium provides a `FileAsset` implementation for reading a publication stored on the local file system. +#### Container -```swift -let file = URL(fileURLWithPath: "path/to/book.epub") -let asset = FileAsset(file: file) -``` +A `Container` provides read access to a collection of resources. `Container` instances representing an archive are usually created by an `ArchiveOpener`. The toolkit ships with a `ZIPArchiveOpener` supporting local ZIP files. + +`Publication` objects internally use a `Container` to expose its content. + +## Opening a publication (`ReadiumStreamer`) -Then, use a `Streamer` instance to parse the asset and create a `Publication` object. +To retrieve a `Publication` object from a publication file like an EPUB or audiobook, you can use an `AssetRetriever` and `PublicationOpener`. ```swift -let streamer = Streamer() +// Instantiate the required components. +let httpClient = DefaultHTTPClient() +let assetRetriever = AssetRetriever( + httpClient: httpClient +) +let publicationOpener = PublicationOpener( + publicationParser: DefaultPublicationParser( + httpClient: httpClient, + assetRetriever: assetRetriever, + pdfFactory: DefaultPDFDocumentFactory() + ) +) + +let url: URL = URL(...) -streamer.open(asset: asset, allowUserInteraction: false) { result in - switch result { +// Retrieve an `Asset` to access the file content. +switch await assetRetriever.retrieve(url: url.anyURL.absoluteURL!) { +case .success(let asset): + // Open a `Publication` from the `Asset`. + switch await publicationOpener.open(asset: asset, allowUserInteraction: true, sender: view) { case .success(let publication): print("Opened \(publication.metadata.title)") + case .failure(let error): - alert(error.localizedDescription) - case .cancelled: - // The user cancelled the opening, for example by dismissing a password pop-up. - break + // Failed to access or parse the publication } + +case .failure(let error): + // Failed to retrieve the asset } ``` The `allowUserInteraction` parameter is useful when supporting a DRM like Readium LCP. It indicates if the toolkit can prompt the user for credentials when the publication is protected. +[See the dedicated user guide for more information](Open%20Publication.md). + ## Accessing the metadata of a publication After opening a publication, you may want to read its metadata to insert a new entity into your bookshelf database, for instance. The `publication.metadata` object contains everything you need, including `title`, `authors` and the `published` date. -You can retrieve the publication cover using `publication.cover`. Avoid calling this from the main thread to prevent blocking the user interface. +You can retrieve the publication cover using `await publication.cover()`. -## Rendering the publication on the screen (`R2Navigator`) +## Rendering the publication on the screen (`ReadiumNavigator`) 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. -```swift -let navigator = try EPUBNavigatorViewController( - publication: publication, - initialLocation: lastReadLocation, - httpServer: GCDHTTPServer.shared -) - -hostViewController.present(navigator, animated: true) -``` Please refer to the [Navigator guide](Navigator/Navigator.md) for more information. diff --git a/Documentation/Guides/Open Publication.md b/Documentation/Guides/Open Publication.md new file mode 100644 index 000000000..1e6914cd0 --- /dev/null +++ b/Documentation/Guides/Open Publication.md @@ -0,0 +1,92 @@ +# Opening a publication + +To open a publication with Readium, you need to instantiate a couple of components: an `AssetRetriever` and a `PublicationOpener`. + +## `AssetRetriever` + +The `AssetRetriever` grants access to the content of an asset located at a given URL, such as a publication package, manifest, or LCP license. + +### Constructing an `AssetRetriever` + +You can create an instance of `AssetRetriever` with: + +* An `HTTPClient` to enable the toolkit to perform HTTP requests and support the `http` and `https` URL schemes. You can use `DefaultHTTPClient` which provides callbacks for handling authentication when needed. + +```swift +let assetRetriever = AssetRetriever(httpClient: DefaultHTTPClient()) +``` + +### Retrieving an `Asset` + +With your fresh instance of `AssetRetriever`, you can open an `Asset` from any `AbsoluteURL`. + +```swift +// From a local file. +let url = FileURL(string: "file:///path/to/book.epub") +// or from an HTTP URL. +let url = HTTPURL(string: "https://domain/book.epub") + +switch await assetRetriever.retrieve(url: url) { + case .success(let asset): + ... + case .failure(let error): + // Failed to retrieve the asset. +} +``` + +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. + +```swift +let mediaType = asset.format.mediaType + +// Speed up the retrieval with a known media type. +let result = await assetRetriever.retrieve(url: url, mediaType: mediaType) +``` + +## `PublicationOpener` + +`PublicationOpener` builds a `Publication` object from an `Asset` using: + +* A `PublicationParser` to parse the asset structure and publication metadata. + * The `DefaultPublicationParser` handles all the formats supported by Readium out of the box. +* An optional list of `ContentProtection` to decrypt DRM-protected publications. + * If you support Readium LCP, you can get one from the `LCPService`. + +```swift +let publicationOpener = PublicationOpener( + parser: DefaultPublicationParser( + httpClient: httpClient, + assetRetriever: assetRetriever + ), + contentProtections: [ + lcpService.contentProtection(with: LCPDialogAuthentication()), + ] +) +``` + +### Opening a `Publication` + +Now that you have a `PublicationOpener` ready, you can use it to create a `Publication` from an `Asset` that was previously obtained using the `AssetRetriever`. + +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( + asset: asset, + allowUserInteraction: true, + sender: sender +) +``` + +## Supporting additional formats or URL schemes + +`DefaultPublicationParser` accepts additional parsers. You also have the option to use your own parser list by using `CompositePublicationParser` or create your own `PublicationParser` for a fully customized parsing resolution strategy. + +The `AssetRetriever` offers an additional constructor that provides greater extensibility options, using: + +* `ResourceFactory` which handles the URL schemes through which you can access content. +* `ArchiveOpener` which determines the types of archives (ZIP, RAR, etc.) that can be opened by the `AssetRetriever`. +* `FormatSniffer` which identifies the file formats that `AssetRetriever` can recognize. + +You can use either the default implementations or implement your own for each of these components using the composite pattern. The toolkit's `CompositeResourceFactory`, `CompositeArchiveOpener`, and `CompositeFormatSniffer` provide a simple resolution strategy. + diff --git a/Documentation/Guides/README.md b/Documentation/Guides/README.md index bf45e146c..547637b8c 100644 --- a/Documentation/Guides/README.md +++ b/Documentation/Guides/README.md @@ -1,6 +1,7 @@ # User guides * [Getting Started](Getting%20Started.md) +* [Opening a publication](Open%20Publication.md) * [Extracting the content of a publication](Content.md) * [Text-to-speech](TTS.md) * [Supporting Readium LCP](Readium%20LCP.md) diff --git a/Documentation/Migration Guide.md b/Documentation/Migration Guide.md index 878cac399..b8f801edb 100644 --- a/Documentation/Migration Guide.md +++ b/Documentation/Migration Guide.md @@ -4,11 +4,64 @@ All migration steps necessary in reading apps to upgrade to major versions of th ## Unreleased -### Async APIs +### Opening a `Publication` -Plenty of completion-based APIs were changed to use `async` functions instead. Follow the deprecation warnings to update your codebase. +The `Streamer` object has been deprecated in favor of components with smaller responsibilities: -### Readium LCP SQLite adapter +* `AssetRetriever` grants access to the content of an asset located at a given URL, such as a publication package, manifest, or LCP license +* `PublicationOpener` uses a publication parser and a set of content protections to create a `Publication` object from an `Asset`. + +[See the user guide for a detailed explanation on how to use these new APIs](Guides/Open%20Publication.md). + +### Typed URLs + +The toolkit now includes a new set of URL types (`RelativeURL`, `AbsoluteURL`, `FileURL`, `HTTPURL`, etc.). These new types ensure that you only pass URLs supported by our APIs. + +You can create an instance of such `URL` from its string representation: + +```swift +FileURL(string: "file:///path/to%20a%20file") +FileURL(path: "/path/to a file") +HTTPURL(string: "https://domain.com/file") +``` + +Or convert an existing Foundation `URL`: + +```swift +let url: URL +url.fileURL +url.httpURL +``` + +### Sniffing a `Format` + +`MediaType` no longer has static helpers for sniffing it from a file or URL. Instead, you can use an `AssetRetriever` to retrieve the format of a file. + +```swift +let assetRetriever = AssetRetriever(httpClient: DefaultHTTPClient()) + +switch await assetRetriever.sniffFormat(of: FileURL(string: ...)) { +case .success(let format): + print("Sniffed media type: \(format.mediaType)") +case .failure(let error): + // Failed to access the asset or recognize its format +} +``` + +The `MediaType` struct has been simplified. It now only holds the actual media type string. The name has been removed, and the file extension has been moved to `Format`. + +### Navigator + +All the navigator `go` APIs are now asynchronous and take an `options` argument instead of the `animated` boolean. + +```diff +-navigator.go(to: locator, animated: true, completion: { } ++await navigator.go(to: locator, options: NavigatorGoOptions(animated: true)) +``` + +### Readium LCP + +#### Readium LCP SQLite adapter The Readium LCP persistence layer was extracted to allow applications to provide their own implementations. The previous implementation is now part of a new package, `ReadiumAdapterLCPSQLite`, which you need to use to maintain the same behavior as before. @@ -38,6 +91,15 @@ let lcpService = LCPService( ) ``` +#### Introducing `LicenseDocumentSource` + +The LCP APIs now accept a `LicenseDocumentSource` enum instead of a URL to an LCPL file. This approach is more flexible, as it doesn't require the LCPL file to be stored on the file system. + +```diff +-lcpService.acquirePublication(from: url) { ... } ++await lcpService.acquirePublication(from: .file(FileURL(url: url))) +``` + ## 3.0.0-alpha.1 diff --git a/Sources/LCP/LCPLicense.swift b/Sources/LCP/LCPLicense.swift index b7e20b194..a4f646e50 100644 --- a/Sources/LCP/LCPLicense.swift +++ b/Sources/LCP/LCPLicense.swift @@ -52,4 +52,14 @@ public extension LCPLicense { func renewLoan(with delegate: LCPRenewDelegate) async -> Result { await renewLoan(with: delegate, prefersWebPage: false) } + + @available(*, unavailable, message: "Use the async variant.") + func renewLoan(with delegate: LCPRenewDelegate, prefersWebPage: Bool, completion: @escaping (CancellableResult) -> Void) { + fatalError() + } + + @available(*, unavailable, message: "Use the async variant.") + func returnPublication(completion: @escaping (LCPError?) -> Void) { + fatalError() + } } diff --git a/Sources/Shared/Publication/Asset/FileAsset.swift b/Sources/Shared/Publication/Asset/FileAsset.swift index 4cf090296..b21992424 100644 --- a/Sources/Shared/Publication/Asset/FileAsset.swift +++ b/Sources/Shared/Publication/Asset/FileAsset.swift @@ -8,4 +8,7 @@ import Foundation /// Represents a publication stored as a file on the local file system. @available(*, unavailable, message: "Use an `AssetRetriever` instead. See the migration guide.") -public final class FileAsset: PublicationAsset {} +public final class FileAsset: PublicationAsset { + public init(url: URL, mediaType: String? = nil) {} + public init(url: URL, mediaType: MediaType?) {} +} diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index 550cc8938..7fcc4c840 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -64,6 +64,11 @@ public struct MediaType: Hashable, Loggable, Sendable { fatalError() } + @available(*, unavailable, message: "File extension was moved to `Format`") + public var fileExtension: String { + fatalError() + } + /// Returns the UTI (Uniform Type Identifier) matching this media type, if any. public var uti: String? { UTI.findFrom(mediaTypes: [string], fileExtensions: [])?.string diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift index d28d2ecac..6487190ac 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift @@ -45,8 +45,7 @@ public struct FileURL: AbsoluteURL, Hashable, Sendable { } /// Returns new `FileURL` with symlinks resolved - // FIXME: Async - public func resolvingSymlinks() -> Self { + public func resolvingSymlinks() async -> Self { Self(url: url.resolvingSymlinksInPath())! } @@ -56,14 +55,12 @@ public struct FileURL: AbsoluteURL, Hashable, Sendable { } /// Returns whether the file exists on the file system. - // FIXME: Async - public func exists() throws -> Bool { + public func exists() async throws -> Bool { try url.checkResourceIsReachable() } /// Returns whether the file is a directory. - // FIXME: Async - public func isDirectory() throws -> Bool { + public func isDirectory() async throws -> Bool { try (url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false } diff --git a/Sources/Shared/Toolkit/ZIP/Minizip.swift b/Sources/Shared/Toolkit/ZIP/Minizip.swift index 2af29d371..c3a7201b9 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip.swift @@ -63,7 +63,7 @@ final class MinizipContainer: Container, Loggable { } static func make(file: FileURL) async -> Result { - guard (try? file.exists()) ?? false else { + guard await (try? file.exists()) ?? false else { return .failure(.reading(.access(.fileSystem(.fileNotFound(nil))))) } guard let zipFile = MinizipFile(url: file.url) else { diff --git a/Sources/Streamer/Parser/DefaultPublicationParser.swift b/Sources/Streamer/Parser/DefaultPublicationParser.swift index 9a9d8d80c..f95cea5a6 100644 --- a/Sources/Streamer/Parser/DefaultPublicationParser.swift +++ b/Sources/Streamer/Parser/DefaultPublicationParser.swift @@ -13,7 +13,7 @@ public final class DefaultPublicationParser: CompositePublicationParser { public init( httpClient: HTTPClient, assetRetriever: AssetRetriever, - pdfFactory: PDFDocumentFactory = DefaultPDFDocumentFactory(), + pdfFactory: PDFDocumentFactory, additionalParsers: [PublicationParser] = [] ) { super.init(additionalParsers + Array(ofNotNil: diff --git a/Sources/Streamer/Streamer.swift b/Sources/Streamer/Streamer.swift index 17fff153b..00dafa8c0 100644 --- a/Sources/Streamer/Streamer.swift +++ b/Sources/Streamer/Streamer.swift @@ -9,4 +9,12 @@ import ReadiumShared /// Opens a `Publication` using a list of parsers. @available(*, unavailable, renamed: "PublicationOpener", message: "Use a `PublicationOpener` instead") -public final class Streamer {} +public final class Streamer { + public init( + parsers: [PublicationParser] = [], + ignoreDefaultParsers: Bool = false, + contentProtections: [ContentProtection] = [], + httpClient: HTTPClient = DefaultHTTPClient(), + onCreatePublication: Publication.Builder.Transform? = nil + ) {} +} diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index f3ef6d4b1..5fec3bd7b 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -29,7 +29,8 @@ final class Readium { lazy var publicationOpener = PublicationOpener( parser: DefaultPublicationParser( httpClient: httpClient, - assetRetriever: assetRetriever + assetRetriever: assetRetriever, + pdfFactory: DefaultPDFDocumentFactory() ), contentProtections: contentProtections )