diff --git a/.claude/skills/write-tests/SKILL.md b/.claude/skills/write-tests/SKILL.md index 933d387e5a..19ab131073 100644 --- a/.claude/skills/write-tests/SKILL.md +++ b/.claude/skills/write-tests/SKILL.md @@ -87,6 +87,48 @@ func roundTrip(format: Format) throws { ... } @Test func fetchMetadata() async throws { ... } ``` +### Arguments with several values + +When each case of a parameterized test holds more than one value, declare a dedicated struct nested in the suite instead of using a tuple. Named fields don't need a comment to explain their order, and default values keep the cases short. + +The struct is `Sendable`. Give it a `name` and conform it to `CustomTestStringConvertible`, so that the test report shows the name of each case. + +```swift +// ✅ Correct — a struct with named fields +struct LocatorPosition: Sendable, CustomTestStringConvertible { + let name: String + var durations: [TimeInterval?] = [600, 300, 900] + let locations: Locator.Locations + let expected: TimelinePosition + + var testDescription: String { name } +} + +@Test(arguments: [ + LocatorPosition( + name: "a progression with a known duration", + locations: .init(progression: 0.5), + expected: position(1, 150) + ), + LocatorPosition( + name: "a progression with an unknown duration", + durations: [600, nil, 900], + locations: .init(progression: 0.5), + expected: position(1, 0) + ), +]) +func locatorIsTurnedIntoAPosition(_ example: LocatorPosition) { ... } + +// ❌ Wrong — a tuple whose values are only told apart by their position +@Test(arguments: [ + ([600, 300, 900], Locator.Locations(progression: 0.5), position(1, 150)), + ([600, nil, 900], Locator.Locations(progression: 0.5), position(1, 0)), +] as [([TimeInterval?], Locator.Locations, TimelinePosition)]) +func locatorIsTurnedIntoAPosition(durations: [TimeInterval?], locations: Locator.Locations, expected: TimelinePosition) { ... } +``` + +A case holding a single value stays a plain argument, e.g. `@Test(arguments: [0, -1])`. + ### Display names A function name is enough for a simple test. When the behavior needs a sentence to be described, give display names to the nested suite and its tests, so that they read as one sentence in the test report. The suite names the subject and each test continues it, starting with a lowercase letter. @@ -218,7 +260,7 @@ fileprivate func makeLocator(href: String = "/ch.html", progression: Double? = n 1. Does every file start with `import Testing`? Zero `import XCTest`. 2. Happy path, boundary conditions, and error cases covered? -3. Similar tests collapsed into `@Test(arguments:)` parameterized tests? +3. Similar tests collapsed into `@Test(arguments:)` parameterized tests, with a struct when a case holds several values? 4. No `// MARK:` inside any suite type? 5. No `@Suite` without a trait or a display name reading as a sentence with its tests? 6. No `guard ... else { return }` — replaced with `!` or `try #require`? diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c0f0041b2..c9414ff446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to this project will be documented in this file. Take a look ### Added +#### Shared + +* New experimental `Publication.timeline()` API. A `Timeline` lays out the table of contents on the reading order, to answer questions such as "which chapter am I in?", "what's the next chapter?" or "how long is this chapter?". It is available for audiobooks, EPUB (limited support) and PDF. + #### Navigator * `AudioNavigator.stop()` stops the playback and ends the audio session. @@ -17,8 +21,9 @@ All notable changes to this project will be documented in this file. Take a look * Audio publications without a manifest, such as a standalone M4B or MP3 file or a container of audio files, now have a table of contents built from the chapters embedded in their audio files. * The metadata of the audio files of a ZIP or ZAB package is read too, when the package is stored on the device and its files are stored without compression, [see the user guide](docs/Guides/Navigator/Audiobook.md#metadata-of-audio-files). - * `AudioParser` has a new `containerEntriesMetadataPolicy` option, to read the audio files of a remote package or to open a package without reading them. + * `AudioParser.Configuration.containerEntriesMetadataPolicy` reads the audio files of a remote package, or opens a package without reading them. * Audiobooks without a table of contents now get one from the titles of their reading order resources. + * `AudioParser.Configuration.generatesTableOfContentsFromReadingOrder` and `ReadiumWebPubParser.Configuration.generatesAudiobookTableOfContentsFromReadingOrder` disable it. * New `AudioMetadataReader` to customize how `AudioParser` reads the metadata of each audio file. ### Changed diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index dd18b257ec..1cae22d81e 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -6,7 +6,7 @@ import AVFoundation import Foundation -import ReadiumShared +@_spi(ExperimentalSelectors) import ReadiumShared /// Status of a played media resource. public enum MediaPlaybackState: Sendable { diff --git a/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift b/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift index 8e8fd71310..8675d557b2 100644 --- a/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift +++ b/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift @@ -14,6 +14,7 @@ public extension Locator.Locations { /// wins. /// /// - https://www.w3.org/TR/media-frags/#naming-time + @_spi(ExperimentalSelectors) var temporal: TemporalSelector? { fragments .reversed() diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index f8e4f3b36c..f3cdbc36b4 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -232,8 +232,8 @@ public extension [Link] { /// Finds the index of the first link matching the given HREF. /// - /// Falls back to ignoring the query and fragment of `href`, to stay - /// consistent with `Manifest.linkWithHREF`. + /// Falls back to ignoring the fragment of `href`, then its query too, to + /// stay consistent with `Manifest.linkWithHREF`. func firstIndexWithHREF(_ href: T) -> Int? { let href = href.anyURL.normalized @@ -242,6 +242,7 @@ public extension [Link] { } return index(matchingHREF: href.string) + ?? index(matchingHREF: href.removingFragment().string) ?? index(matchingHREF: href.removingQuery().removingFragment().string) } diff --git a/Sources/Shared/Publication/Manifest.swift b/Sources/Shared/Publication/Manifest.swift index 1d669f27e9..01ea0a7ea2 100644 --- a/Sources/Shared/Publication/Manifest.swift +++ b/Sources/Shared/Publication/Manifest.swift @@ -152,6 +152,7 @@ public struct Manifest: Hashable, Sendable, JSONValueDecodable, JSONObjectEncoda let links = [readingOrder, resources, links] return deepFind(href: href, in: links) + ?? deepFind(href: href.removingFragment(), in: links) ?? deepFind(href: href.removingQuery().removingFragment(), in: links) } diff --git a/Sources/Shared/Publication/Services/Timeline/AudioTimelinePositionResolver.swift b/Sources/Shared/Publication/Services/Timeline/AudioTimelinePositionResolver.swift new file mode 100644 index 0000000000..7d3b552902 --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/AudioTimelinePositionResolver.swift @@ -0,0 +1,53 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +/// Resolves the locations of an audio publication into positions on its +/// timeline, from the durations of its reading order. +/// +/// A missing or non-positive duration is unknown. +package struct AudioTimelinePositionResolver: TimelinePositionResolver { + private let layout: TimelineLayout + + package init(readingOrder: [Link]) { + layout = TimelineLayout( + readingOrder: readingOrder, + lengths: readingOrder.map(\.duration), + offset: TimelineOffset.seconds + ) + } + + package var end: TimelinePosition? { + layout.end + } + + package func position(of link: Link) -> TimelinePosition? { + let url = link.url() + guard let index = layout.index(of: url) else { + return nil + } + return layout.position(at: index, offset: url.fragment?.temporalSelector?.start ?? 0) + } + + package func position(of locator: Locator) -> TimelinePosition? { + guard let index = layout.index(of: locator.href) else { + return nil + } + + let offset = locator.locations.temporal?.start + ?? locator.locations.progression.flatMap { progression in + layout.length(ofResourceAt: index).map { progression * $0 } + } + ?? 0 + + return layout.position(at: index, offset: offset) + } + + package func bounds(ofResource href: any URLConvertible) -> (start: TimelinePosition, end: TimelinePosition?)? { + layout.bounds(ofResource: href) + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/AudioTimelineService.swift b/Sources/Shared/Publication/Services/Timeline/AudioTimelineService.swift new file mode 100644 index 0000000000..156043b361 --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/AudioTimelineService.swift @@ -0,0 +1,52 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +/// Timeline service for audio publications, built from the table of contents +/// of the publication and the durations of its reading order. +@_spi(ExperimentalTimeline) +public final class AudioTimelineService: TimelineService { + /// - Parameter generatesFromReadingOrder: When the table of contents is + /// empty, generates one item per reading order resource. Otherwise, the + /// timeline is empty. + public static func makeFactory(generatesFromReadingOrder: Bool) -> TimelineServiceFactory { + { context in + AudioTimelineService( + publication: context.publication, + generatesFromReadingOrder: generatesFromReadingOrder + ) + } + } + + private let cache: AsyncThrowingMemoizer + + init(publication: Weak, generatesFromReadingOrder: Bool) { + cache = AsyncThrowingMemoizer { () async throws(ReadError) -> Timeline in + guard let publication = publication() else { + throw .unsupportedOperation(DebugError("The publication is no longer available")) + } + + var nodes = try await publication.tableOfContents().get().timelineNodes + if nodes.isEmpty, generatesFromReadingOrder { + nodes = publication.readingOrder.enumerated().map { index, link in + Timeline.Node( + title: link.title?.orNilIfBlank().map { .text($0) } ?? .number(index + 1), + link: link + ) + } + } + + return Timeline(nodes: nodes, resolver: AudioTimelinePositionResolver(readingOrder: publication.readingOrder)) + } + } + + /// Builds the timeline once. A failure is not cached, so the next call + /// tries again. + public func timeline() async throws(ReadError) -> Timeline { + try await cache() + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/PDFTimelinePositionResolver.swift b/Sources/Shared/Publication/Services/Timeline/PDFTimelinePositionResolver.swift new file mode 100644 index 0000000000..a2d4a01472 --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/PDFTimelinePositionResolver.swift @@ -0,0 +1,100 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +/// Resolves the locations of a PDF publication into positions on its +/// timeline, from the page count of each document in its reading order. +/// +/// A page from a link or a locator is clamped to the pages of its document, +/// when its page count is known. A missing or non-positive page count is +/// unknown. +package struct PDFTimelinePositionResolver: TimelinePositionResolver { + private let layout: TimelineLayout + + /// - Parameter pageCounts: Page count of each reading order resource, by + /// index. + package init(readingOrder: [Link], pageCounts: [Int?]) { + layout = TimelineLayout( + readingOrder: readingOrder, + lengths: pageCounts, + offset: TimelineOffset.page + ) + } + + package var end: TimelinePosition? { + layout.end + } + + package func position(of link: Link) -> TimelinePosition? { + let url = link.url() + guard let index = layout.index(of: url) else { + return nil + } + let page = url.fragment?.pdfSelector?.page ?? 1 + return layout.position(at: index, offset: clamped(page - 1, at: index)) + } + + /// The page is read from a `page=` fragment, as the navigator does, then + /// from the position in the publication, then from the progression in + /// the resource. + package func position(of locator: Locator) -> TimelinePosition? { + guard let index = layout.index(of: locator.href) else { + return nil + } + + let locations = locator.locations + let page = locations.page.flatMap { $0 >= 1 ? $0 - 1 : nil } + ?? pageFromPosition(locations.position, at: index) + ?? pageFromProgression(locations.progression, at: index) + ?? 0 + + return layout.position(at: index, offset: clamped(page, at: index)) + } + + package func bounds(ofResource href: any URLConvertible) -> (start: TimelinePosition, end: TimelinePosition?)? { + layout.bounds(ofResource: href) + } + + /// Page index in the resource at `index` of the given 1-based position in + /// the publication, or `nil` when it is outside the resource or the + /// preceding page counts are unknown. + private func pageFromPosition(_ position: Int?, at index: Int) -> Int? { + guard + let position, position >= 1, + let startOffset = layout.startOffset(ofResourceAt: index) + else { + return nil + } + let page = position - 1 - startOffset + guard page >= 0, layout.length(ofResourceAt: index).map({ page < $0 }) ?? true else { + return nil + } + return page + } + + /// Page index in the resource at `index` of the given progression in the + /// resource, or `nil` when its page count is unknown. + private func pageFromProgression(_ progression: Double?, at index: Int) -> Int? { + guard + let progression, + let pageCount = layout.length(ofResourceAt: index) + else { + return nil + } + let page = (min(max(0, progression), 1) * Double(pageCount)).rounded(.down) + return Int(page) + } + + /// Clamps `page` to the pages of the resource at `index`. + private func clamped(_ page: Int, at index: Int) -> Int { + let page = max(0, page) + guard let pageCount = layout.length(ofResourceAt: index) else { + return page + } + return min(page, pageCount - 1) + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/PDFTimelineService.swift b/Sources/Shared/Publication/Services/Timeline/PDFTimelineService.swift new file mode 100644 index 0000000000..ffbbaddab9 --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/PDFTimelineService.swift @@ -0,0 +1,45 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +/// Timeline service for PDF publications, built from the table of contents +/// of the publication and the page count of each document in its reading +/// order. +/// +/// The page counts are read from the positions of the publication, so that +/// they match the positions of the navigator. +@_spi(ExperimentalTimeline) +public final class PDFTimelineService: TimelineService { + public static func makeFactory() -> TimelineServiceFactory { + { context in + PDFTimelineService(publication: context.publication) + } + } + + private let cache: AsyncThrowingMemoizer + + init(publication: Weak) { + cache = AsyncThrowingMemoizer { () async throws(ReadError) -> Timeline in + guard let publication = publication() else { + throw .unsupportedOperation(DebugError("The publication is no longer available")) + } + + let nodes = try await publication.tableOfContents().get().timelineNodes + let pageCounts = try await publication.positionsByReadingOrder().get().map(\.count) + return Timeline( + nodes: nodes, + resolver: PDFTimelinePositionResolver(readingOrder: publication.readingOrder, pageCounts: pageCounts) + ) + } + } + + /// Builds the timeline once. A failure is not cached, so the next call + /// tries again. + public func timeline() async throws(ReadError) -> Timeline { + try await cache() + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/Timeline.swift b/Sources/Shared/Publication/Services/Timeline/Timeline.swift new file mode 100644 index 0000000000..aea2239ee3 --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/Timeline.swift @@ -0,0 +1,336 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// The table of contents of a publication laid out on its reading order, to +/// answer questions such as "which chapter am I in?", "what's the next +/// chapter?" or "how long is this chapter?". +/// +/// The reading order forms a continuous line from the start of the +/// publication to its end, made of the duration of each resource for an +/// audiobook. A ``TimelinePosition`` is a point on this line. +/// +/// A timeline is a tree of ``TimelineItem`` mirroring the table of contents. +/// Each item has a range on the line: +/// +/// - It starts where the link of its entry points, such as `t2.mp3#t=60`. +/// - It ends at the start of the next placed item after its subtree, or at +/// the end of the publication. A parent therefore contains its children, +/// and owns the gap before its first child. +/// +/// An item that cannot be placed keeps its place in the tree without a +/// range: its link points outside the reading order, or before the start of +/// the previous placed item. +/// +/// Without a table of contents, a timeline can be generated with one item per +/// reading order resource. +/// +/// To display the current chapter in a running header, locate the +/// navigator's location each time it changes, and update the header when +/// the `id` of the item changes: +/// +/// ```swift +/// func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { +/// let chapter = timeline.locate(locator, maxDepth: 1) +/// if chapter?.id != currentChapter?.id { +/// currentChapter = chapter +/// updateHeader() +/// } +/// } +/// ``` +@_spi(ExperimentalTimeline) +public struct Timeline: Sendable { + /// Source of a ``TimelineItem``. + public struct Node: Sendable { + public let title: TimelineItem.Title + public let link: Link + public let children: [Node] + + public init(title: TimelineItem.Title, link: Link, children: [Node] = []) { + self.title = title + self.link = link + self.children = children + } + } + + /// Top-level items. + public let items: [TimelineItem] + + private let resolver: any TimelinePositionResolver + + /// All the items, indexed by their ID. + private let allItems: [TimelineItem] + + /// ID of the parent of each item. + private let parentIDs: [Int?] + + /// Depth of each item, starting at 1 for the top level. + private let depths: [Int] + + /// IDs of the items with a range, in pre-order, which is also the order + /// of their starts. + private let rangedIDs: [Int] + + public init(nodes: [Node], resolver: any TimelinePositionResolver) { + // Flattens the nodes in pre-order. + var flatNodes: [Node] = [] + var parentIDs: [Int?] = [] + var depths: [Int] = [] + var childIDs: [[Int]] = [] + // ID following the subtree of each node. + var subtreeEnds: [Int] = [] + + @discardableResult + func flatten(_ node: Node, parentID: Int?, depth: Int) -> Int { + let nodeID = flatNodes.count + flatNodes.append(node) + parentIDs.append(parentID) + depths.append(depth) + childIDs.append([]) + subtreeEnds.append(nodeID) + for child in node.children { + childIDs[nodeID].append(flatten(child, parentID: nodeID, depth: depth + 1)) + } + subtreeEnds[nodeID] = flatNodes.count + return nodeID + } + + let topLevelIDs = nodes.map { flatten($0, parentID: nil, depth: 1) } + + // Places the nodes in pre-order. A node starting before the last + // placed one is left without a range. + var starts: [TimelinePosition?] = [] + var rangedIDs: [Int] = [] + for (nodeID, node) in flatNodes.enumerated() { + var start = resolver.position(of: node.link) + if let lastID = rangedIDs.last, let lastStart = starts[lastID], let position = start, position.isBefore(lastStart) { + start = nil + } + starts.append(start) + if start != nil { + rangedIDs.append(nodeID) + } + } + + // Start of the first ranged item from each ID. + var nextStarts = [TimelinePosition?](repeating: nil, count: flatNodes.count + 1) + for nodeID in flatNodes.indices.reversed() { + nextStarts[nodeID] = starts[nodeID] ?? nextStarts[nodeID + 1] + } + + // Builds the items from the leaves, as children follow their parent + // in pre-order. + var allItems = [TimelineItem?](repeating: nil, count: flatNodes.count) + for (nodeID, node) in flatNodes.enumerated().reversed() { + allItems[nodeID] = TimelineItem( + id: nodeID, + title: node.title, + link: node.link, + start: starts[nodeID], + end: starts[nodeID].flatMap { start in + // An item can start past the known end of the + // publication. + nextStarts[subtreeEnds[nodeID]] ?? resolver.end.map { $0.isBefore(start) ? start : $0 } + }, + children: childIDs[nodeID].map { allItems[$0]! } + ) + } + + let builtItems = allItems.map { $0! } + self.allItems = builtItems + items = topLevelIDs.map { builtItems[$0] } + self.resolver = resolver + self.parentIDs = parentIDs + self.depths = depths + self.rangedIDs = rangedIDs + } + + /// Returns the deepest item containing `locator`, or `nil` when it is + /// outside the reading order or before the first item. + /// + /// - Parameter maxDepth: Returns an ancestor of the deepest item, no + /// deeper than this depth. The top level is 1. + public func locate(_ locator: Locator, maxDepth: Int? = nil) -> TimelineItem? { + guard + maxDepth.map({ $0 >= 1 }) ?? true, + let position = resolver.position(of: locator) + else { + return nil + } + + // Index of the first ranged item starting after the position. + var low = 0 + var high = rangedIDs.count + while low < high { + let middle = (low + high) / 2 + if !position.isBefore(allItems[rangedIDs[middle]].start!) { + low = middle + 1 + } else { + high = middle + } + } + guard low > 0 else { + return nil + } + + return labelID(of: rangedIDs[low - 1], maxDepth: maxDepth).map { allItems[$0] } + } + + /// Returns the first item with a range in pre-order, or `nil` when no + /// placed item is within `maxDepth`. + /// + /// - Parameter maxDepth: Skips the items deeper than this depth. The top + /// level is 1. + public func first(maxDepth: Int? = nil) -> TimelineItem? { + rangedIDs + .first { isWithin($0, maxDepth: maxDepth) } + .map { allItems[$0] } + } + + /// Returns the item before `item` in pre-order, skipping the items + /// without a range or sharing its start. + /// + /// - Parameter maxDepth: Skips the items deeper than this depth. The top + /// level is 1. + public func previous(before item: TimelineItem, maxDepth: Int? = nil) -> TimelineItem? { + guard contains(item) else { + return nil + } + return rangedIDs.last { candidateID in + candidateID < item.id + && isWithin(candidateID, maxDepth: maxDepth) + && item.start.map { allItems[candidateID].start!.isBefore($0) } ?? true + } + .map { allItems[$0] } + } + + /// Returns the item after `item` in pre-order, skipping the items without + /// a range or sharing its start. + /// + /// - Parameter maxDepth: Skips the items deeper than this depth. The top + /// level is 1. + public func next(after item: TimelineItem, maxDepth: Int? = nil) -> TimelineItem? { + guard contains(item) else { + return nil + } + return rangedIDs.first { candidateID in + candidateID > item.id + && isWithin(candidateID, maxDepth: maxDepth) + && item.start.map { $0.isBefore(allItems[candidateID].start!) } ?? true + } + .map { allItems[$0] } + } + + /// Returns the ancestors of `item`, from the top level down to its + /// parent. Empty for a top-level item. + public func ancestors(of item: TimelineItem) -> [TimelineItem] { + guard contains(item) else { + return [] + } + var ancestors: [TimelineItem] = [] + var parentID = parentIDs[item.id] + while let id = parentID { + ancestors.insert(allItems[id], at: 0) + parentID = parentIDs[id] + } + return ancestors + } + + /// Returns consecutive segments covering the timeline, each labelled with + /// the deepest item playing in it. + /// + /// The item of a segment is the one returned by ``locate(_:maxDepth:)`` + /// in it. + /// + /// - Parameter maxDepth: Labels the segments with items no deeper than + /// this depth. The top level is 1. + public func segments(maxDepth: Int? = nil) -> [TimelineSegment] { + var segments: [TimelineSegment] = [] + + // Each ranged item starts an interval up to the next one, labelled + // like `locate` would in it. + for (index, rangedID) in rangedIDs.enumerated() { + guard let labelID = labelID(of: rangedID, maxDepth: maxDepth) else { + continue + } + let item = allItems[labelID] + let start = allItems[rangedID].start! + var end = item.end + if index + 1 < rangedIDs.count, let next = allItems[rangedIDs[index + 1]].start, end.map({ next.isBefore($0) }) ?? true { + end = next + } + guard end.map({ start.isBefore($0) }) ?? true else { + continue + } + + // The items of a subtree are consecutive in pre-order, so the + // intervals of a label are adjacent. + if let last = segments.last, last.item.id == labelID { + segments[segments.count - 1] = TimelineSegment(item: item, start: last.start, end: end) + } else { + segments.append(TimelineSegment(item: item, start: start, end: end)) + } + } + + return segments + } + + /// Returns the segments of ``segments(maxDepth:)`` in the reading order + /// resource at `href`, with positions clipped to the resource. + /// + /// Empty when `href` is not in the reading order. + public func segments(in href: any URLConvertible, maxDepth: Int? = nil) -> [TimelineSegment] { + guard let (resourceStart, resourceEnd) = resolver.bounds(ofResource: href) else { + return [] + } + let index = resourceStart.readingOrderIndex + + return segments(maxDepth: maxDepth).compactMap { segment in + guard + segment.start.readingOrderIndex <= index, + segment.end.map({ resourceStart.isBefore($0) }) ?? true + else { + return nil + } + + let start = segment.start.isBefore(resourceStart) ? resourceStart : segment.start + // A segment can end past the known end of its resource. + let end = segment.end + .flatMap { $0.readingOrderIndex == index ? $0 : nil } + .map { end in resourceEnd.map { $0.isBefore(end) ? $0 : end } ?? end } + ?? resourceEnd + guard end.map({ start.isBefore($0) }) ?? true else { + return nil + } + return TimelineSegment(item: segment.item, start: start, end: end) + } + } + + /// Returns whether `item` belongs to this timeline. + private func contains(_ item: TimelineItem) -> Bool { + allItems.indices.contains(item.id) && allItems[item.id] == item + } + + /// Returns the ID of the item labelling the positions from the start of + /// the ranged item `itemID`: itself, or its closest ranged ancestor + /// within `maxDepth`. + private func labelID(of itemID: Int, maxDepth: Int?) -> Int? { + var labelID: Int? = itemID + while let currentID = labelID, !isRanged(currentID) || !isWithin(currentID, maxDepth: maxDepth) { + labelID = parentIDs[currentID] + } + return labelID + } + + private func isRanged(_ itemID: Int) -> Bool { + allItems[itemID].start != nil + } + + private func isWithin(_ itemID: Int, maxDepth: Int?) -> Bool { + maxDepth.map { depths[itemID] <= $0 } ?? true + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/TimelineItem.swift b/Sources/Shared/Publication/Services/Timeline/TimelineItem.swift new file mode 100644 index 0000000000..207bd83ac4 --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/TimelineItem.swift @@ -0,0 +1,74 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +/// An entry of a ``Timeline``, such as a chapter. +/// +/// As the ``id`` is a pre-order index, the same entry keeps its `id` in a +/// timeline rebuilt from the same table of contents. +@_spi(ExperimentalTimeline) +public struct TimelineItem: Identifiable, Hashable, Sendable { + /// Title of an item. + public enum Title: Hashable, Sendable { + /// A title to display. + case text(String) + + /// No title. Holds the 1-based number of the item's resource in the + /// reading order, to display a title such as "Track 3". + /// + /// It counts all the resources, not only the untitled ones. + case number(Int) + } + + /// Index of the item in the pre-order traversal of its timeline, unique + /// within the timeline. + public let id: Int + + /// Title of the item. + public let title: Title + + /// Link of the table of contents entry, or of the reading order resource + /// for a generated item. + /// + /// Use it to jump to the item with `Navigator.go(to:)`. + public let link: Link + + /// Start of the item, or `nil` when it could not be placed on the + /// timeline. + /// + /// For example, when it references a resource outside the reading order, + /// or starts before the previous item (going back). + public let start: TimelinePosition? + + /// Exclusive end of the item. + /// + /// `nil` when the item has no ``start``, or when it ends at the end of a + /// publication whose last resource length is unknown. + public let end: TimelinePosition? + + /// Items of the nested table of contents entries, in order. + /// + /// Their ranges are inside the range of this item, which owns the gap + /// before its first placed child. + public let children: [TimelineItem] + + package init( + id: Int, + title: Title, + link: Link, + start: TimelinePosition?, + end: TimelinePosition?, + children: [TimelineItem] + ) { + self.id = id + self.title = title + self.link = link + self.start = start + self.end = end + self.children = children + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/TimelineLayout.swift b/Sources/Shared/Publication/Services/Timeline/TimelineLayout.swift new file mode 100644 index 0000000000..0f686c77eb --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/TimelineLayout.swift @@ -0,0 +1,142 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// A length of a resource on a timeline, such as a duration or a page count. +package protocol TimelineLength: AdditiveArithmetic, Comparable, Sendable { + var doubleValue: Double { get } +} + +extension Int: TimelineLength { + package var doubleValue: Double { + Double(self) + } +} + +extension Double: TimelineLength { + package var doubleValue: Double { + self + } +} + +/// Lays out the resources of a reading order one after the other on a +/// timeline, from their lengths. +/// +/// A missing or non-positive length is unknown. +package struct TimelineLayout: Sendable { + private let readingOrder: [Link] + + /// Length of each reading order resource. + private let lengths: [Length?] + + /// Offset of each reading order resource from the start of the + /// publication, `nil` when a preceding length is unknown. + private let startOffsets: [Length?] + + /// Length of the publication, `nil` when a length is unknown. + private let totalLength: Length? + + /// Turns a length into an offset of the timeline. + private let makeOffset: @Sendable (Length) -> TimelineOffset + + /// - Parameters: + /// - lengths: Length of each reading order resource, by index. + /// - offset: Turns a length into an offset of the timeline, such as + /// `TimelineOffset.seconds`. + package init( + readingOrder: [Link], + lengths: [Length?], + offset: @escaping @Sendable (Length) -> TimelineOffset + ) { + self.readingOrder = readingOrder + makeOffset = offset + self.lengths = readingOrder.indices.map { index in + lengths.getOrNil(index).flatMap { $0 }.flatMap { $0 > .zero ? $0 : nil } + } + + var startOffsets: [Length?] = [] + var total: Length? = .zero + for length in self.lengths { + startOffsets.append(total) + total = total.flatMap { total in length.map { total + $0 } } + } + self.startOffsets = startOffsets + totalLength = total + } + + /// Length of the resource at `index`, `nil` when it is unknown. + package func length(ofResourceAt index: Int) -> Length? { + lengths[index] + } + + /// Offset of the resource at `index` from the start of the publication, + /// `nil` when a preceding length is unknown. + package func startOffset(ofResourceAt index: Int) -> Length? { + startOffsets[index] + } + + /// Index of the reading order resource at `href`, ignoring its fragment. + package func index(of href: any URLConvertible) -> Int? { + readingOrder.firstIndexWithHREF(href) + } + + /// End of the publication, or `nil` when the reading order is empty or + /// the length of its last resource is unknown. + package var end: TimelinePosition? { + guard !readingOrder.isEmpty else { + return nil + } + return end(ofResourceAt: readingOrder.count - 1) + } + + /// Start and end of the reading order resource at `href`, ignoring its + /// fragment, or `nil` when it is not in the reading order. The end is + /// `nil` when the length of the resource is unknown. + package func bounds(ofResource href: any URLConvertible) -> (start: TimelinePosition, end: TimelinePosition?)? { + guard let index = index(of: href) else { + return nil + } + return (position(at: index, offset: .zero), end(ofResourceAt: index)) + } + + /// Position at `offset` in the resource at `index`. + package func position(at index: Int, offset: Length) -> TimelinePosition { + let totalOffset = startOffsets[index].map { $0 + offset } + + return TimelinePosition( + readingOrderIndex: index, + offset: makeOffset(offset), + totalOffset: totalOffset.map(makeOffset), + progression: lengths[index].map { offset.doubleValue / $0.doubleValue }, + totalProgression: totalOffset.flatMap { totalOffset in + totalLength.map { totalOffset.doubleValue / $0.doubleValue } + } + ) + } + + /// End of the reading order resource at `index`, or `nil` when its + /// length is unknown. + private func end(ofResourceAt index: Int) -> TimelinePosition? { + guard let length = lengths[index] else { + return nil + } + let end = position(at: index, offset: length) + + // The end of the publication is at 100%, even when the length of a + // preceding resource is unknown. + guard index < readingOrder.count - 1 else { + return TimelinePosition( + readingOrderIndex: index, + offset: end.offset, + totalOffset: end.totalOffset, + progression: end.progression, + totalProgression: 1 + ) + } + return end + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/TimelinePosition.swift b/Sources/Shared/Publication/Services/Timeline/TimelinePosition.swift new file mode 100644 index 0000000000..1807ea2fca --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/TimelinePosition.swift @@ -0,0 +1,79 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// A position on a ``Timeline``. +/// +/// A position is located by its reading order index and its offset. The other +/// properties are derived from these two. +@_spi(ExperimentalTimeline) +public struct TimelinePosition: Hashable, Sendable { + /// Index of the resource in the reading order. + public let readingOrderIndex: Int + + /// Offset in the resource. + public let offset: TimelineOffset + + /// Offset from the start of the publication, or `nil` when the length of + /// a preceding resource is unknown. + public let totalOffset: TimelineOffset? + + /// Progression in the resource, or `nil` when its length is unknown. + /// + /// Between 0 and 1, unless the position is past the known end of the + /// resource. + public let progression: Double? + + /// Progression in the publication, or `nil` when the length of a + /// resource is unknown. + /// + /// Between 0 and 1, unless the position is past the known end of the + /// publication. + public let totalProgression: Double? + + public init( + readingOrderIndex: Int, + offset: TimelineOffset, + totalOffset: TimelineOffset? = nil, + progression: Double? = nil, + totalProgression: Double? = nil + ) { + self.readingOrderIndex = readingOrderIndex + self.offset = offset + self.totalOffset = totalOffset + self.progression = progression + self.totalProgression = totalProgression + } + + /// Returns whether this position comes before `other` on the timeline, + /// ordering by reading order index, then by offset. + func isBefore(_ other: TimelinePosition) -> Bool { + (readingOrderIndex, offset) < (other.readingOrderIndex, other.offset) + } +} + +/// An offset in a resource, or from the start of a publication. +/// +/// A ``TimelinePositionResolver`` uses a single case for all the offsets of a +/// resource. Comparing offsets of different cases is meaningless: they are +/// ordered by case. +@_spi(ExperimentalTimeline) +public enum TimelineOffset: Comparable, Hashable, Sendable { + /// A time offset in an audio or video resource, in seconds. + case seconds(TimeInterval) + + /// A progression in a scrollable resource such as an HTML document, + /// between 0 and 1. + case scroll(Double) + + /// Index of a page in a paginated resource such as a PDF document or an + /// image, starting at 0. + /// + /// As an offset, it counts the pages before it: a resource of N pages + /// ends at `.page(N)`. + case page(Int) +} diff --git a/Sources/Shared/Publication/Services/Timeline/TimelinePositionResolver.swift b/Sources/Shared/Publication/Services/Timeline/TimelinePositionResolver.swift new file mode 100644 index 0000000000..13f46319a2 --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/TimelinePositionResolver.swift @@ -0,0 +1,30 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +/// Resolves locations of a publication into positions on its ``Timeline``, +/// following the rules of its format. +/// +/// The same resolution is used to build a timeline and to query it. +@_spi(ExperimentalTimeline) +public protocol TimelinePositionResolver: Sendable { + /// End of the publication, or `nil` when it is unknown. + var end: TimelinePosition? { get } + + /// Position of the given link, from its HREF and fragment, or `nil` when + /// it is outside the reading order. + func position(of link: Link) -> TimelinePosition? + + /// Position of the given locator, or `nil` when it is outside the reading + /// order. + func position(of locator: Locator) -> TimelinePosition? + + /// Start and end of the reading order resource at `href`, ignoring its + /// fragment, or `nil` when it is not in the reading order. The end is + /// `nil` when it is unknown. + func bounds(ofResource href: any URLConvertible) -> (start: TimelinePosition, end: TimelinePosition?)? +} diff --git a/Sources/Shared/Publication/Services/Timeline/TimelineSegment.swift b/Sources/Shared/Publication/Services/Timeline/TimelineSegment.swift new file mode 100644 index 0000000000..b571bb500f --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/TimelineSegment.swift @@ -0,0 +1,25 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// A range of a ``Timeline`` labelled with the ``TimelineItem`` playing in it. +@_spi(ExperimentalTimeline) +public struct TimelineSegment: Hashable, Sendable { + public let item: TimelineItem + + public let start: TimelinePosition + + /// Exclusive end of the segment, or `nil` when it ends at the end of a + /// resource whose length is unknown. + public let end: TimelinePosition? + + package init(item: TimelineItem, start: TimelinePosition, end: TimelinePosition?) { + self.item = item + self.start = start + self.end = end + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/TimelineService.swift b/Sources/Shared/Publication/Services/Timeline/TimelineService.swift new file mode 100644 index 0000000000..8d6b960c93 --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/TimelineService.swift @@ -0,0 +1,66 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +@_spi(ExperimentalTimeline) +public typealias TimelineServiceFactory = @Sendable (PublicationServiceContext) -> TimelineService? + +/// Provides the ``Timeline`` of a publication: its table of contents laid out +/// on the reading order, where each entry becomes an item with a start and an +/// end. +/// +/// Use it to find the chapter containing the current location, move to the +/// previous or next chapter, or get the boundaries and duration of each +/// chapter. +@_spi(ExperimentalTimeline) +public protocol TimelineService: PublicationService { + /// Returns the timeline of the publication. + func timeline() async throws(ReadError) -> Timeline +} + +// MARK: Publication Helpers + +public extension Publication { + /// Returns the timeline of the publication, or `nil` when the publication + /// has no timeline service. + @_spi(ExperimentalTimeline) + func timeline() async throws(ReadError) -> Timeline? { + guard let service = findService(TimelineService.self) else { + return nil + } + return try await service.timeline() + } +} + +// MARK: PublicationServicesBuilder Helpers + +public extension PublicationServicesBuilder { + @_spi(ExperimentalTimeline) + mutating func setTimelineServiceFactory(_ factory: TimelineServiceFactory?) { + if let factory = factory { + set(TimelineService.self, factory) + } else { + remove(TimelineService.self) + } + } +} + +// MARK: Table of Contents Helpers + +extension [Link] { + /// Nodes of the timeline of this table of contents. An untitled entry is + /// replaced by its children. + var timelineNodes: [Timeline.Node] { + flatMap { link -> [Timeline.Node] in + let children = link.children.timelineNodes + guard let title = link.title?.orNilIfBlank() else { + return children + } + return [Timeline.Node(title: .text(title), link: link, children: children)] + } + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/WebTimelinePositionResolver.swift b/Sources/Shared/Publication/Services/Timeline/WebTimelinePositionResolver.swift new file mode 100644 index 0000000000..14eb5f9fdc --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/WebTimelinePositionResolver.swift @@ -0,0 +1,81 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +/// Resolves the locations of a web publication, such as an EPUB, into +/// positions on its timeline. +/// +/// The first table of contents entry pointing to each reading order resource +/// is placed at the start of the resource. A locator resolves to its resource +/// and progression only, so a fixed-layout resource is like a reflowable one +/// that is never scrolled. Each resource ends at a progression of 1. +package struct WebTimelinePositionResolver: TimelinePositionResolver { + private let readingOrder: [Link] + + /// First entry pointing to each reading order resource, by index. + /// + /// Entries are matched by value, so a duplicate of a first entry is placed + /// too, at the same start. + private let firstEntries: [Int: Link] + + /// - Parameter nodes: Nodes of the timeline, whose first entry pointing to + /// each resource is placed. + package init(readingOrder: [Link], nodes: [Timeline.Node]) { + self.readingOrder = readingOrder + + var firstEntries: [Int: Link] = [:] + func record(_ nodes: [Timeline.Node]) { + for node in nodes { + if let index = readingOrder.firstIndexWithHREF(node.link.url()), firstEntries[index] == nil { + firstEntries[index] = node.link + } + record(node.children) + } + } + record(nodes) + self.firstEntries = firstEntries + } + + package var end: TimelinePosition? { + guard !readingOrder.isEmpty else { + return nil + } + return end(ofResourceAt: readingOrder.count - 1) + } + + package func position(of link: Link) -> TimelinePosition? { + guard + let index = readingOrder.firstIndexWithHREF(link.url()), + firstEntries[index] == link + else { + return nil + } + return start(ofResourceAt: index) + } + + package func position(of locator: Locator) -> TimelinePosition? { + guard let index = readingOrder.firstIndexWithHREF(locator.href) else { + return nil + } + return TimelinePosition( + readingOrderIndex: index, + offset: .scroll(locator.locations.progression ?? 0) + ) + } + + package func bounds(ofResource href: any URLConvertible) -> (start: TimelinePosition, end: TimelinePosition?)? { + readingOrder.firstIndexWithHREF(href).map { (start(ofResourceAt: $0), end(ofResourceAt: $0)) } + } + + private func start(ofResourceAt index: Int) -> TimelinePosition { + TimelinePosition(readingOrderIndex: index, offset: .scroll(0)) + } + + private func end(ofResourceAt index: Int) -> TimelinePosition { + TimelinePosition(readingOrderIndex: index, offset: .scroll(1)) + } +} diff --git a/Sources/Shared/Publication/Services/Timeline/WebTimelineService.swift b/Sources/Shared/Publication/Services/Timeline/WebTimelineService.swift new file mode 100644 index 0000000000..f4ed61c681 --- /dev/null +++ b/Sources/Shared/Publication/Services/Timeline/WebTimelineService.swift @@ -0,0 +1,45 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +/// Timeline service for web publications, such as EPUB, built from the table +/// of contents of the publication. +/// +/// Only the first entry pointing to each reading order resource is placed, at +/// the start of the resource. The other entries keep their place in the tree +/// without a range. A chapter split across several resources is therefore +/// located in all of them. +@_spi(ExperimentalTimeline) +public final class WebTimelineService: TimelineService { + public static func makeFactory() -> TimelineServiceFactory { + { context in + WebTimelineService(publication: context.publication) + } + } + + private let cache: AsyncThrowingMemoizer + + init(publication: Weak) { + cache = AsyncThrowingMemoizer { () async throws(ReadError) -> Timeline in + guard let publication = publication() else { + throw .unsupportedOperation(DebugError("The publication is no longer available")) + } + + let nodes = try await publication.tableOfContents().get().timelineNodes + return Timeline( + nodes: nodes, + resolver: WebTimelinePositionResolver(readingOrder: publication.readingOrder, nodes: nodes) + ) + } + } + + /// Builds the timeline once. A failure is not cached, so the next call + /// tries again. + public func timeline() async throws(ReadError) -> Timeline { + try await cache() + } +} diff --git a/Sources/Shared/Toolkit/AsyncThrowingMemoizer.swift b/Sources/Shared/Toolkit/AsyncThrowingMemoizer.swift new file mode 100644 index 0000000000..24a044c653 --- /dev/null +++ b/Sources/Shared/Toolkit/AsyncThrowingMemoizer.swift @@ -0,0 +1,51 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +/// An actor that caches the first successful result of an asynchronous +/// computation. +/// +/// Unlike ``AsyncMemoizer``, a failure is not cached: the next call computes +/// again. The calls made while a computation is running share its result. +/// +/// Example: +/// ```swift +/// let memoizer = AsyncThrowingMemoizer { () async throws(ReadError) -> Data in +/// try await resource.read().get() +/// } +/// +/// let data = try await memoizer() +/// ``` +package actor AsyncThrowingMemoizer { + private let compute: @Sendable () async throws(Failure) -> Success + private var task: Task, Never>? + + package init(_ compute: @escaping @Sendable () async throws(Failure) -> Success) { + self.compute = compute + } + + /// Returns the cached success, or computes it if not yet available. + package func callAsFunction() async throws(Failure) -> Success { + let task = task ?? Task { [compute] in + do throws(Failure) { + return try await .success(compute()) + } catch { + return .failure(error) + } + } + self.task = task + + let result = await task.value + // Another call may have started a new computation in the meantime. + if case .failure = result, self.task == task { + self.task = nil + } + return try result.get() + } + + deinit { + task?.cancel() + } +} diff --git a/Sources/Shared/Toolkit/Selectors/PDFSelector.swift b/Sources/Shared/Toolkit/Selectors/PDFSelector.swift new file mode 100644 index 0000000000..ecc21a8c01 --- /dev/null +++ b/Sources/Shared/Toolkit/Selectors/PDFSelector.swift @@ -0,0 +1,118 @@ +// +// Copyright 2026 Readium Foundation. All 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 + +// MARK: - PDF Selector + +/// Identifies a page or an area in a page within a PDF document. +/// +/// - https://www.rfc-editor.org/rfc/rfc8118 +@_spi(ExperimentalSelectors) +public struct PDFSelector: Hashable, Sendable { + /// 1-based page number within the PDF resource. + public var page: Int + + /// View rectangle in the page. + public var rect: Rect? + + /// A rectangle within a PDF page. + /// + /// Coordinate values are expressed in the default user space coordinate + /// system of the document: 1/72 of an inch measured down and to the right + /// from the upper left corner of the (current) page ([ISOPDF2] 8.3.2.3 + /// "User Space"). + public struct Rect: Hashable, Sendable { + public var left: Double + public var top: Double + public var width: Double + public var height: Double + + public init( + left: Double, + top: Double, + width: Double, + height: Double + ) { + self.left = left + self.top = top + self.width = width + self.height = height + } + } + + /// Returns `true` when this selector points to the first page in the PDF + /// document. + var isAtStart: Bool { + page == 1 && rect == nil + } + + public init(page: Int, rect: Rect? = nil) { + self.page = page + self.rect = rect + } +} + +// MARK: - Fragment + +@_spi(ExperimentalSelectors) +public extension PDFSelector { + /// Creates a ``PDFSelector`` from a URL fragment following RFC 8118. + /// + /// Fragment format: `page=N[&viewrect=left,top,width,height]` + /// + /// - https://www.rfc-editor.org/rfc/rfc8118 + init?(fragment: URLFragment) { + var pageValue: Int? + var rectValue: Rect? + + for parameter in fragment.parameters { + let value = parameter.value + + switch parameter.name { + case "page": + guard let n = Int(value), n >= 1 else { return nil } + pageValue = n + + case "viewrect": + let coords = value.components(separatedBy: ",") + guard coords.count == 4, + let left = Double(coords[0]), + let top = Double(coords[1]), + let width = Double(coords[2]), + let height = Double(coords[3]) + else { return nil } + rectValue = Rect(left: left, top: top, width: width, height: height) + + default: + break + } + } + + guard let page = pageValue else { return nil } + self.init(page: page, rect: rectValue) + } + + /// Returns a URL fragment representation of this selector following RFC + /// 8118. + /// + /// - https://www.rfc-editor.org/rfc/rfc8118 + var fragment: URLFragment { + var raw = "page=\(page)" + if let r = rect { + raw += "&viewrect=\(r.left),\(r.top),\(r.width),\(r.height)" + } + return URLFragment(rawValue: raw)! + } +} + +@_spi(ExperimentalSelectors) +public extension URLFragment { + /// Parses the fragment as a ``PDFSelector`` following RFC 8118. + var pdfSelector: PDFSelector? { + PDFSelector(fragment: self) + } +} diff --git a/Sources/Shared/Toolkit/Selectors/TemporalSelector.swift b/Sources/Shared/Toolkit/Selectors/TemporalSelector.swift index 775520b9d9..58524ac878 100644 --- a/Sources/Shared/Toolkit/Selectors/TemporalSelector.swift +++ b/Sources/Shared/Toolkit/Selectors/TemporalSelector.swift @@ -14,6 +14,7 @@ import Foundation /// dimension. /// /// - https://www.w3.org/TR/media-frags/#naming-time +@_spi(ExperimentalSelectors) public enum TemporalSelector: Hashable, Sendable { /// A single point in time within the media stream. case position(TemporalPosition) @@ -31,6 +32,7 @@ public enum TemporalSelector: Hashable, Sendable { } /// A single point in time within a media rendition. +@_spi(ExperimentalSelectors) public struct TemporalPosition: Hashable, Sendable { /// Time offset from the start of the media rendition, in seconds. public let time: TimeInterval @@ -45,6 +47,7 @@ public struct TemporalPosition: Hashable, Sendable { } /// A time range within a media rendition. +@_spi(ExperimentalSelectors) public struct TemporalClip: Hashable, Sendable { /// Start of the clip, in seconds from the beginning of the media rendition. public let start: TimeInterval @@ -80,6 +83,7 @@ private extension TimeInterval { // MARK: - Fragment +@_spi(ExperimentalSelectors) public extension TemporalSelector { /// Creates a ``TemporalSelector`` from a URL fragment following the W3C /// Media Fragments URI specification (temporal dimension). @@ -277,6 +281,7 @@ private extension Substring { } } +@_spi(ExperimentalSelectors) public extension URLFragment { /// Parses the fragment as a ``TemporalSelector`` following the W3C Media /// Fragments URI specification. diff --git a/Sources/Streamer/Parser/Audio/AudioManifestBuilder.swift b/Sources/Streamer/Parser/Audio/AudioManifestBuilder.swift index 113325aeed..1451448584 100644 --- a/Sources/Streamer/Parser/Audio/AudioManifestBuilder.swift +++ b/Sources/Streamer/Parser/Audio/AudioManifestBuilder.swift @@ -5,13 +5,17 @@ // import Foundation -import ReadiumShared +@_spi(ExperimentalSelectors) import ReadiumShared /// Builds the manifest of an audio publication from the metadata of its audio /// files. /// /// https://github.com/readium/architecture/blob/master/streamer/parser/audio-metadata.md#aggregation struct AudioManifestBuilder { + /// Whether the titled files of the reading order make the table of + /// contents when no file has a titled chapter. + var generatesTableOfContentsFromReadingOrder: Bool = true + /// An audio file of the publication. struct Entry { let url: AnyURL @@ -92,7 +96,8 @@ struct AudioManifestBuilder { /// Returns the concatenation of the chapters of each file. /// /// When no file has a titled chapter, the table of contents is made of - /// the titled files of the reading order instead. + /// the titled files of the reading order instead, unless + /// `generatesTableOfContentsFromReadingOrder` is false. /// /// https://github.com/readium/architecture/blob/master/streamer/parser/audio-metadata.md#table-of-contents private func makeTableOfContents(entries: [Entry], readingOrder: [Link], warnings: WarningLogger?) -> [Link] { @@ -101,7 +106,7 @@ struct AudioManifestBuilder { } guard chapters.contains(where: { !$0.isEmpty }) else { - return readingOrder.tableOfContentsFromTitles + return generatesTableOfContentsFromReadingOrder ? readingOrder.tableOfContentsFromTitles : [] } return zip(entries, chapters).flatMap { entry, chapters -> [Link] in diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index 0d2597f1cd..3dbc88e65f 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumShared +@_spi(ExperimentalTimeline) import ReadiumShared /// Parses an audiobook Publication from an unstructured archive format /// containing audio files, such as ZAB (Zipped Audio Book) or a simple ZIP. @@ -39,34 +39,54 @@ public final class AudioParser: PublicationParser, Loggable { case always } + public struct Configuration: Sendable { + /// Indicates when the audio entries of a package are opened to read + /// their metadata. A standalone audio file is always read. + public var containerEntriesMetadataPolicy: ContainerEntriesMetadataPolicy + + /// Number of audio entries of a package read at the same time. + /// + /// A larger value opens a package faster and uses more memory: while + /// it reads a file, the default reader holds about twice the size of + /// its metadata, cover included. A value below 1 reads the files one + /// at a time. + public var maxConcurrentMetadataReads: Int + + /// When no file has a chapter, makes the table of contents from the + /// titles of the files, if at least two of them are titled. + /// + /// When disabled, the experimental timeline of such a publication is + /// empty, instead of having one item per file. + public var generatesTableOfContentsFromReadingOrder: Bool + + public init( + containerEntriesMetadataPolicy: ContainerEntriesMetadataPolicy = .localOnly, + maxConcurrentMetadataReads: Int = 3, + generatesTableOfContentsFromReadingOrder: Bool = true + ) { + self.containerEntriesMetadataPolicy = containerEntriesMetadataPolicy + self.maxConcurrentMetadataReads = maxConcurrentMetadataReads + self.generatesTableOfContentsFromReadingOrder = generatesTableOfContentsFromReadingOrder + } + } + private let assetRetriever: AssetRetriever private let metadataReader: AudioMetadataReader - private let containerEntriesMetadataPolicy: ContainerEntriesMetadataPolicy - private let maxConcurrentMetadataReads: Int + private let config: Configuration /// - Parameters: /// - assetRetriever: Sniffs the format of the files of a package. /// - metadataReader: Reads the metadata embedded in each audio file. /// The default one does not read the compressed entries of an /// archive, so these files have no metadata in the publication. - /// - containerEntriesMetadataPolicy: Indicates when the audio entries of - /// a package are opened to read their metadata. A standalone audio file - /// is always read. - /// - maxConcurrentMetadataReads: Number of audio entries of a package - /// read at the same time. A larger value opens a package faster and - /// uses more memory: while it reads a file, the default reader holds - /// about twice the size of its metadata, cover included. A value - /// below 1 reads the files one at a time. public init( assetRetriever: AssetRetriever, metadataReader: AudioMetadataReader = DefaultAudioMetadataReader(), - containerEntriesMetadataPolicy: ContainerEntriesMetadataPolicy = .localOnly, - maxConcurrentMetadataReads: Int = 3 + config: Configuration = .init() ) { self.assetRetriever = assetRetriever self.metadataReader = metadataReader - self.containerEntriesMetadataPolicy = containerEntriesMetadataPolicy - self.maxConcurrentMetadataReads = max(1, maxConcurrentMetadataReads) + self.config = config } private let audioSpecifications: Set = [ @@ -132,14 +152,14 @@ public final class AudioParser: PublicationParser, Loggable { /// Returns whether the policy lets the audio files of `container` be /// read. private func readsMetadata(of container: Container) -> Bool { - switch containerEntriesMetadataPolicy { + switch config.containerEntriesMetadataPolicy { case .never: return false case .always: return true case .localOnly: guard container.isStoredOnDevice else { - log(.debug, "The audio entries of the package are not read as it is not stored on the device, so the publication will have no metadata, table of contents or cover. Create the `AudioParser` with `containerEntriesMetadataPolicy: .always` to read them.") + log(.debug, "The audio entries of the package are not read as it is not stored on the device, so the publication will have no metadata, table of contents or cover. Set `AudioParser.Configuration.containerEntriesMetadataPolicy` to `.always` to read them.") return false } return true @@ -213,7 +233,9 @@ public final class AudioParser: PublicationParser, Loggable { } } - let manifest = AudioManifestBuilder().build( + let manifest = AudioManifestBuilder( + generatesTableOfContentsFromReadingOrder: config.generatesTableOfContentsFromReadingOrder + ).build( entries: zip(readingOrder, metadata).map { file, metadata in AudioManifestBuilder.Entry(url: file.0, format: file.1, metadata: metadata) }, @@ -228,7 +250,12 @@ public final class AudioParser: PublicationParser, Loggable { container: container, servicesBuilder: .init( cover: cover.map { EmbeddedCoverService.makeFactory(data: $0.data, mediaType: $0.mediaType) }, - locator: AudioLocatorService.makeFactory() + locator: AudioLocatorService.makeFactory(), + setup: { [config] in + $0.setTimelineServiceFactory(AudioTimelineService.makeFactory( + generatesFromReadingOrder: config.generatesTableOfContentsFromReadingOrder + )) + } ) )) } @@ -245,9 +272,10 @@ public final class AudioParser: PublicationParser, Loggable { warnings: WarningLogger? ) async -> ReadResult<[AudioMetadata?]> { var metadata: [AudioMetadata?] = [] + let batchSize = max(1, config.maxConcurrentMetadataReads) - for start in stride(from: 0, to: files.count, by: maxConcurrentMetadataReads) { - let batch = files[start ..< min(start + maxConcurrentMetadataReads, files.count)] + for start in stride(from: 0, to: files.count, by: batchSize) { + let batch = files[start ..< min(start + batchSize, files.count)] let includesCover = !metadata.contains { $0?.cover != nil } switch await readMetadata(ofBatch: batch, in: container, includesCover: includesCover) { diff --git a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift index 69867a5b64..e1481b52c6 100644 --- a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift +++ b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumShared +@_spi(ExperimentalSelectors) import ReadiumShared /// Locator service for audio publications. final class AudioLocatorService: LocatorService { diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index ee55e36860..e270341f4a 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumShared +@_spi(ExperimentalTimeline) import ReadiumShared /// Errors thrown during the parsing of the EPUB /// @@ -72,7 +72,10 @@ public final class EPUBParser: PublicationParser { ), guidedNavigation: SMILGuidedNavigationService.makeFactory(), positions: EPUBPositionsService.makeFactory(reflowableStrategy: reflowablePositionsStrategy), - search: ContentSearchService.makeFactory() + search: ContentSearchService.makeFactory(), + setup: { + $0.setTimelineServiceFactory(WebTimelineService.makeFactory()) + } ) )) } catch { diff --git a/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift b/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift index 84b05d7d07..309429969f 100644 --- a/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift +++ b/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift @@ -6,7 +6,7 @@ import Foundation import ReadiumFuzi -import ReadiumShared +@_spi(ExperimentalSelectors) import ReadiumShared /// Parses EPUB 3 SMIL Media Overlay documents and values into Readium models /// (e.g. ``GuidedNavigationDocument``). diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index 0bd05c628d..cfa1ab9463 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -6,7 +6,7 @@ import CoreGraphics import Foundation -import ReadiumShared +@_spi(ExperimentalTimeline) import ReadiumShared public final class PDFParser: PublicationParser, Loggable { private let pdfFactory: PDFDocumentFactory @@ -69,6 +69,7 @@ public final class PDFParser: PublicationParser, Loggable { cached: (href: href, document: document) ) ) + $0.setTimelineServiceFactory(PDFTimelineService.makeFactory()) } ) ) diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index dfb0959bd2..0b023d71bd 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumShared +@_spi(ExperimentalTimeline) import ReadiumShared public enum ReadiumWebPubParserError: Error, Sendable { case parseFailure(url: URL, Error?) @@ -19,20 +19,52 @@ public final class ReadiumWebPubParser: PublicationParser, Loggable { case invalidManifest } + public struct Configuration: Sendable { + /// Strategy used to calculate the number of positions in a reflowable + /// resource of a web publication conforming to the EPUB profile. + public var epubReflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy + + /// When an audiobook has no table of contents, makes one from the + /// titles of its reading order resources, if at least two of them are + /// titled. + /// + /// When disabled, the experimental timeline of such an audiobook is + /// empty, instead of having one item per reading order resource. + public var generatesAudiobookTableOfContentsFromReadingOrder: Bool + + public init( + epubReflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy = .recommended, + generatesAudiobookTableOfContentsFromReadingOrder: Bool = true + ) { + self.epubReflowablePositionsStrategy = epubReflowablePositionsStrategy + self.generatesAudiobookTableOfContentsFromReadingOrder = generatesAudiobookTableOfContentsFromReadingOrder + } + } + private let pdfFactory: PDFDocumentFactory? private let httpClient: HTTPClient - private let epubReflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy + private let config: Configuration /// - Parameters: /// - pdfFactory: Factory used to open PDF documents, if available. /// - httpClient: The HTTP client used to fetch remote resources. - /// - epubReflowablePositionsStrategy: Strategy used to calculate - /// the number of positions in a reflowable resource of a web publication - /// conforming to the EPUB profile. - public init(pdfFactory: PDFDocumentFactory?, httpClient: HTTPClient, epubReflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy = .recommended) { + public init( + pdfFactory: PDFDocumentFactory?, + httpClient: HTTPClient, + config: Configuration = .init() + ) { self.pdfFactory = pdfFactory self.httpClient = httpClient - self.epubReflowablePositionsStrategy = epubReflowablePositionsStrategy + self.config = config + } + + @available(*, unavailable, message: "Set `epubReflowablePositionsStrategy` in the `config` instead.") + public convenience init( + pdfFactory: PDFDocumentFactory?, + httpClient: HTTPClient, + epubReflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy + ) { + fatalError() } public func parse( @@ -116,7 +148,7 @@ public final class ReadiumWebPubParser: PublicationParser, Loggable { // An audiobook often has no table of contents while its // reading order links are titled. Use them instead, so that // apps can offer a navigation between the resources. - if manifest.conforms(to: .audiobook), manifest.tableOfContents.isEmpty { + if config.generatesAudiobookTableOfContentsFromReadingOrder, manifest.conforms(to: .audiobook), manifest.tableOfContents.isEmpty { manifest.tableOfContents = manifest.readingOrder.tableOfContentsFromTitles } @@ -125,18 +157,22 @@ public final class ReadiumWebPubParser: PublicationParser, Loggable { container: container, servicesBuilder: PublicationServicesBuilder(setup: { if manifest.conforms(to: .epub) { - $0.setPositionsServiceFactory(EPUBPositionsService.makeFactory(reflowableStrategy: epubReflowablePositionsStrategy)) + $0.setPositionsServiceFactory(EPUBPositionsService.makeFactory(reflowableStrategy: config.epubReflowablePositionsStrategy)) } else if manifest.conforms(to: .divina) { $0.setPositionsServiceFactory(PerResourcePositionsService.makeFactory(fallbackMediaType: MediaType("image/*")!)) } else if manifest.conforms(to: .audiobook) { $0.setLocatorServiceFactory(AudioLocatorService.makeFactory()) + $0.setTimelineServiceFactory(AudioTimelineService.makeFactory( + generatesFromReadingOrder: config.generatesAudiobookTableOfContentsFromReadingOrder + )) } else if manifest.conforms(to: .pdf), format.conformsTo(.lcp), let pdfFactory { $0.setPDFDocumentServiceFactory(DefaultPDFDocumentService.makeFactory(factory: pdfFactory)) $0.setTableOfContentsServiceFactory(LCPDFTableOfContentsService.makeFactory()) $0.setPositionsServiceFactory(LCPDFPositionsService.makeFactory()) + $0.setTimelineServiceFactory(PDFTimelineService.makeFactory()) $0.setContentServiceFactory(DefaultContentService.makeFactory( resourceContentIteratorFactories: [ PDFResourceContentIterator.Factory(), @@ -147,6 +183,10 @@ public final class ReadiumWebPubParser: PublicationParser, Loggable { // FIXME: WebPositionsService from Kotlin? + if manifest.conforms(to: .epub) || manifest.readingOrder.allAreHTML { + $0.setTimelineServiceFactory(WebTimelineService.makeFactory()) + } + if manifest.readingOrder.allAreHTML { $0.setSearchServiceFactory(ContentSearchService.makeFactory()) $0.setContentServiceFactory(DefaultContentService.makeFactory( diff --git a/Tests/NavigatorTests/Audio/AudioNavigatorTests.swift b/Tests/NavigatorTests/Audio/AudioNavigatorTests.swift index 5477687a1d..791bddaa3f 100644 --- a/Tests/NavigatorTests/Audio/AudioNavigatorTests.swift +++ b/Tests/NavigatorTests/Audio/AudioNavigatorTests.swift @@ -6,7 +6,7 @@ import Foundation @testable import ReadiumNavigator -import ReadiumShared +@_spi(ExperimentalSelectors) import ReadiumShared import Testing import TestSupport diff --git a/Tests/SharedTests/Extensions.swift b/Tests/SharedTests/Extensions.swift index dbd736acc3..e7f057b11b 100644 --- a/Tests/SharedTests/Extensions.swift +++ b/Tests/SharedTests/Extensions.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumShared +@_spi(ExperimentalSelectors) import ReadiumShared extension Locator { init(href: String, mediaType: MediaType, title: String? = nil, locations: Locations = .init(), text: Text = .init()) { diff --git a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift index a2e8791507..aa528db012 100644 --- a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift @@ -4,7 +4,7 @@ // available in the top-level LICENSE file of the project. // -@testable import ReadiumShared +@_spi(ExperimentalSelectors) @testable import ReadiumShared import Testing struct LocatorLocationsAudioTests { diff --git a/Tests/SharedTests/Publication/LinkArrayTests.swift b/Tests/SharedTests/Publication/LinkArrayTests.swift index b8486d2239..8089b17711 100644 --- a/Tests/SharedTests/Publication/LinkArrayTests.swift +++ b/Tests/SharedTests/Publication/LinkArrayTests.swift @@ -105,6 +105,13 @@ class LinkArrayTests: XCTestCase { XCTAssertEqual(try links.firstIndexWithHREF(XCTUnwrap(AnyURL(string: "l2?query=1#fragment"))), 1) } + /// Ignoring only the fragment takes precedence over ignoring the query too. + func testFirstIndexWithHREFIgnoringFragmentKeepsTheQuery() throws { + let links = [Link(href: "l1"), Link(href: "l1?query=1")] + + XCTAssertEqual(try links.firstIndexWithHREF(XCTUnwrap(AnyURL(string: "l1?query=1#fragment"))), 1) + } + /// Finds the first `Link` with a `type` matching the given `mediaType`. func testFirstWithMediaType() { let links = [ diff --git a/Tests/SharedTests/Publication/PublicationTests.swift b/Tests/SharedTests/Publication/PublicationTests.swift index 2f82d050eb..f0e83692a0 100644 --- a/Tests/SharedTests/Publication/PublicationTests.swift +++ b/Tests/SharedTests/Publication/PublicationTests.swift @@ -174,6 +174,14 @@ struct PublicationTests { #expect(publication.linkWithHREF(AnyURL(string: "l1#a")!)?.href == "l1#a") #expect(publication.linkWithHREF(AnyURL(string: "l2#b")!)?.href == "l2") } + + @Test func ignoresAnchorOfHREFWithQuery() { + let publication = makePublication(links: [ + Link(href: "l1?q=a"), + ]) + + #expect(publication.linkWithHREF(AnyURL(string: "l1?q=a#b")!)?.href == "l1?q=a") + } } struct LinkWithRel { diff --git a/Tests/SharedTests/Publication/Services/Timeline/AudioTimelinePositionResolverTests.swift b/Tests/SharedTests/Publication/Services/Timeline/AudioTimelinePositionResolverTests.swift new file mode 100644 index 0000000000..f99e3f7d4c --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Timeline/AudioTimelinePositionResolverTests.swift @@ -0,0 +1,116 @@ +// +// Copyright 2026 Readium Foundation. All 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 +@_spi(ExperimentalTimeline) @testable import ReadiumShared +import Testing + +/// Reading order: tracks `t1` of 600 s, `t2` of 300 s and `t3` of 900 s, +/// unless a test gives other durations. +enum AudioTimelinePositionResolverTests { + struct Links { + struct LinkOffset: Sendable, CustomTestStringConvertible { + let href: String + var readingOrderIndex = 1 + let offset: TimelineOffset + + var testDescription: String { + href + } + } + + @Test(arguments: [ + LinkOffset(href: "t2#t=60", offset: .seconds(60)), + LinkOffset(href: "t2", offset: .seconds(0)), + LinkOffset(href: "t2#t=10,20", offset: .seconds(10)), + // Past the known duration of the track, the offset is not clamped. + LinkOffset(href: "t3#t=1000", readingOrderIndex: 2, offset: .seconds(1000)), + ]) + func linkIsTurnedIntoAnOffset(_ example: LinkOffset) throws { + let position = try #require(resolver().position(of: Link(href: example.href))) + + #expect(position.readingOrderIndex == example.readingOrderIndex) + #expect(position.offset == example.offset) + } + + @Test func linkOutsideTheReadingOrderHasNoPosition() { + #expect(resolver().position(of: Link(href: "outside.mp3#t=60")) == nil) + } + } + + /// All the locators are in `t2`. + struct Locators { + struct LocatorOffset: Sendable, CustomTestStringConvertible { + let name: String + var durations: [TimeInterval?] = [600, 300, 900] + let locations: Locator.Locations + let offset: TimelineOffset + + var testDescription: String { + name + } + } + + @Test(arguments: [ + LocatorOffset( + name: "a `t=` fragment wins over the progression", + locations: .init(fragments: ["t=60"], progression: 0.5), + offset: .seconds(60) + ), + LocatorOffset( + name: "a `t=` fragment with no duration known", + durations: [nil, nil, nil], + locations: .init(fragments: ["t=60"]), + offset: .seconds(60) + ), + LocatorOffset( + name: "a progression with a known duration", + locations: .init(progression: 0.5), + offset: .seconds(150) + ), + LocatorOffset( + name: "a progression is ignored when the duration is unknown", + durations: [600, nil, 900], + locations: .init(progression: 0.5), + offset: .seconds(0) + ), + LocatorOffset( + name: "a progression is ignored when the duration is 0", + durations: [600, 0, 900], + locations: .init(progression: 0.5), + offset: .seconds(0) + ), + LocatorOffset( + name: "no location is the start of the track", + locations: .init(), + offset: .seconds(0) + ), + ]) + func locatorIsTurnedIntoAnOffset(_ example: LocatorOffset) throws { + let locator = Locator(href: AnyURL(string: "t2")!, mediaType: .mp3, locations: example.locations) + let position = try #require(resolver(example.durations).position(of: locator)) + + #expect(position.readingOrderIndex == 1) + #expect(position.offset == example.offset) + } + + @Test func locatorOutsideTheReadingOrderHasNoPosition() { + let locator = Locator(href: AnyURL(string: "outside.mp3")!, mediaType: .mp3, locations: .init(fragments: ["t=60"])) + + #expect(resolver().position(of: locator) == nil) + } + } +} + +// MARK: - Helpers + +private func resolver(_ durations: [TimeInterval?] = [600, 300, 900]) -> AudioTimelinePositionResolver { + AudioTimelinePositionResolver( + readingOrder: durations.enumerated().map { index, duration in + Link(href: "t\(index + 1)", mediaType: .mp3, duration: duration) + } + ) +} diff --git a/Tests/SharedTests/Publication/Services/Timeline/AudioTimelineServiceTests.swift b/Tests/SharedTests/Publication/Services/Timeline/AudioTimelineServiceTests.swift new file mode 100644 index 0000000000..c77327ac89 --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Timeline/AudioTimelineServiceTests.swift @@ -0,0 +1,208 @@ +// +// Copyright 2026 Readium Foundation. All 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 +@_spi(ExperimentalTimeline) @testable import ReadiumShared +import Testing +@_spi(ExperimentalTimeline) import TestSupport + +enum AudioTimelineServiceTests { + struct TableOfContents { + @Test func itemsMirrorTheTableOfContents() async throws { + let toc = [ + Link(href: "t1.mp3", title: "Intro"), + Link(href: "t2.mp3#t=10", title: "Chapter", children: [ + Link(href: "t2.mp3#t=20", title: "Section"), + ]), + ] + + let timeline = try await timeline(toc: toc) + + // `t3.mp3` gets no generated item. + #expect(timeline.outline == [ + "0 Intro → t1.mp3", + "1 Chapter → t2.mp3#t=10", + " 2 Section → t2.mp3#t=20", + ]) + #expect(timeline.items.map(\.link) == toc) + } + + struct TableOfContentsOutline: Sendable, CustomTestStringConvertible { + let name: String + let toc: [Link] + var generatesFromReadingOrder = true + let expected: [String] + + var testDescription: String { + name + } + } + + @Test(arguments: [ + TableOfContentsOutline( + name: "untitled entries are replaced by their children", + toc: [ + Link(href: "t1.mp3", children: [ + Link(href: "t1.mp3#t=5", title: "One"), + Link(href: "t2.mp3", title: " ", children: [Link(href: "t2.mp3#t=5", title: "Two")]), + ]), + Link(href: "t3.mp3", title: "Three"), + ], + expected: ["0 One → t1.mp3#t=5", "1 Two → t2.mp3#t=5", "2 Three → t3.mp3"] + ), + TableOfContentsOutline( + name: "an empty table of contents generates one item per track", + toc: [], + expected: ["0 One → t1.mp3", "1 #2 → t2.mp3", "2 #3 → t3.mp3"] + ), + TableOfContentsOutline( + name: "only untitled, childless entries count as an empty table of contents", + toc: [Link(href: "t1.mp3"), Link(href: "t2.mp3", title: " ")], + expected: ["0 One → t1.mp3", "1 #2 → t2.mp3", "2 #3 → t3.mp3"] + ), + TableOfContentsOutline( + name: "an empty table of contents gives an empty timeline with the option off", + toc: [], + generatesFromReadingOrder: false, + expected: [] + ), + ]) + func untitledOrMissingEntries(_ example: TableOfContentsOutline) async throws { + let timeline = try await timeline(toc: example.toc, generatesFromReadingOrder: example.generatesFromReadingOrder) + + #expect(timeline.outline == example.expected) + } + + @Test func generatedItemsLinkToTheStartOfEachTrack() async throws { + let timeline = try await timeline(toc: []) + + #expect(timeline.items.map(\.link) == readingOrder) + #expect(timeline.items.map { $0.start?.readingOrderIndex } == [0, 1, 2]) + #expect(timeline.items.map { $0.start?.offset } == [.seconds(0), .seconds(0), .seconds(0)]) + } + + @Test func customServiceIsUsed() async throws { + let timeline = try await timeline( + toc: [Link(href: "t1.mp3", title: "Manifest")], + tocService: { _ in StubTableOfContentsService { _ in .success([Link(href: "t2.mp3", title: "Custom")]) } } + ) + + #expect(timeline.outline == ["0 Custom → t2.mp3"]) + } + } + + struct Caching { + @Test func failureIsThrownAndNotCached() async throws { + let service = StubTableOfContentsService { call in + call == 1 ? .failure(.decoding("Broken")) : .success([Link(href: "t1.mp3", title: "Intro")]) + } + let publication = makePublication(tocService: { _ in service }) + + let error = await #expect(throws: ReadError.self) { + _ = try await publication.timeline() + } + if case .decoding? = error {} else { + Issue.record("Expected a decoding error, got \(String(describing: error))") + } + #expect(try await publication.timeline()?.outline == ["0 Intro → t1.mp3"]) + withExtendedLifetime(publication) {} + } + + @Test func successIsCached() async throws { + let service = StubTableOfContentsService { _ in .success([Link(href: "t1.mp3", title: "Intro")]) } + let publication = makePublication(tocService: { _ in service }) + + let first = try await publication.timeline()?.items + let second = try await publication.timeline()?.items + + #expect(first?.isEmpty == false) + #expect(first == second) + #expect(service.callCount == 1) + withExtendedLifetime(publication) {} + } + } + + struct PublicationHelper { + @Test func returnsNilWithoutAService() async throws { + let publication = Publication(manifest: Manifest(metadata: Metadata(title: ""), readingOrder: readingOrder)) + + #expect(try await publication.timeline() == nil) + } + } + + struct Lifetime { + @Test func failsOnceThePublicationIsDeallocated() async { + let service = AudioTimelineService(publication: Weak(), generatesFromReadingOrder: true) + + let error = await #expect(throws: ReadError.self) { + _ = try await service.timeline() + } + guard case .unsupportedOperation? = error else { + Issue.record("Expected an unsupportedOperation error, got \(String(describing: error))") + return + } + } + } +} + +// MARK: - Helpers + +/// Three tracks of 600 s, only the first one titled. +private let readingOrder = [ + Link(href: "t1.mp3", mediaType: .mp3, title: "One", duration: 600), + Link(href: "t2.mp3", mediaType: .mp3, duration: 600), + Link(href: "t3.mp3", mediaType: .mp3, title: " ", duration: 600), +] + +private func makePublication( + toc: [Link] = [], + generatesFromReadingOrder: Bool = true, + tocService: TableOfContentsServiceFactory? = nil +) -> Publication { + Publication( + manifest: Manifest(metadata: Metadata(title: ""), readingOrder: readingOrder, tableOfContents: toc), + servicesBuilder: PublicationServicesBuilder(setup: { + $0.setTimelineServiceFactory(AudioTimelineService.makeFactory(generatesFromReadingOrder: generatesFromReadingOrder)) + if let tocService { + $0.setTableOfContentsServiceFactory(tocService) + } + }) + ) +} + +private func timeline( + toc: [Link], + generatesFromReadingOrder: Bool = true, + tocService: TableOfContentsServiceFactory? = nil +) async throws -> Timeline { + let publication = makePublication(toc: toc, generatesFromReadingOrder: generatesFromReadingOrder, tocService: tocService) + let timeline = try #require(await publication.timeline()) + withExtendedLifetime(publication) {} + return timeline +} + +/// Answers with `toc`, which takes the number of the call, starting at 1. +private final class StubTableOfContentsService: TableOfContentsService, @unchecked Sendable { + private let toc: @Sendable (_ call: Int) -> ReadResult<[Link]> + private let lock = NSLock() + private var calls = 0 + + init(_ toc: @escaping @Sendable (_ call: Int) -> ReadResult<[Link]>) { + self.toc = toc + } + + var callCount: Int { + lock.withLock { calls } + } + + func tableOfContents() async -> ReadResult<[Link]> { + let call = lock.withLock { + calls += 1 + return calls + } + return toc(call) + } +} diff --git a/Tests/SharedTests/Publication/Services/Timeline/PDFTimelinePositionResolverTests.swift b/Tests/SharedTests/Publication/Services/Timeline/PDFTimelinePositionResolverTests.swift new file mode 100644 index 0000000000..985d0b2934 --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Timeline/PDFTimelinePositionResolverTests.swift @@ -0,0 +1,164 @@ +// +// Copyright 2026 Readium Foundation. All 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 +@_spi(ExperimentalTimeline) @testable import ReadiumShared +import Testing + +/// Reading order: `a.pdf` of 10 pages and `b.pdf` of 5 pages, unless a test +/// gives other page counts. +enum PDFTimelinePositionResolverTests { + struct Links { + struct LinkPage: Sendable, CustomTestStringConvertible { + let href: String + var pageCounts: [Int?] = [10, 5] + var readingOrderIndex = 1 + let offset: TimelineOffset + + var testDescription: String { + "\(href) with \(pageCounts)" + } + } + + @Test(arguments: [ + LinkPage(href: "a.pdf#page=3", readingOrderIndex: 0, offset: .page(2)), + LinkPage(href: "b.pdf", offset: .page(0)), + LinkPage(href: "b.pdf#page=0", offset: .page(0)), + LinkPage(href: "b.pdf#page=9", offset: .page(4)), + LinkPage(href: "b.pdf#page=9", pageCounts: [10, nil], offset: .page(8)), + ]) + func linkIsTurnedIntoAPage(_ example: LinkPage) throws { + let position = try #require(resolver(example.pageCounts).position(of: Link(href: example.href))) + + #expect(position.readingOrderIndex == example.readingOrderIndex) + #expect(position.offset == example.offset) + } + + @Test func linkOutsideTheReadingOrderHasNoPosition() { + #expect(resolver([10, 5]).position(of: Link(href: "outside.pdf#page=1")) == nil) + } + } + + /// All the locators are in `b.pdf`, at positions 11 to 15. + struct Locators { + struct LocatorPage: Sendable, CustomTestStringConvertible { + let name: String + var pageCounts: [Int?] = [10, 5] + let locations: Locator.Locations + let offset: TimelineOffset + + var testDescription: String { + name + } + } + + @Test(arguments: [ + LocatorPage( + name: "a `page=` fragment wins over the position and the progression", + locations: .init(fragments: ["page=3"], progression: 0.8, position: 12), + offset: .page(2) + ), + LocatorPage( + name: "a `page=` fragment past the last page is clamped", + locations: .init(fragments: ["page=9"]), + offset: .page(4) + ), + LocatorPage( + name: "an invalid `page=` fragment is ignored", + locations: .init(fragments: ["page=0"], position: 13), + offset: .page(2) + ), + LocatorPage( + name: "the first position of the resource wins over the progression", + locations: .init(progression: 0.8, position: 11), + offset: .page(0) + ), + LocatorPage( + name: "the last position of the resource", + locations: .init(position: 15), + offset: .page(4) + ), + LocatorPage( + name: "a position before the resource is ignored", + locations: .init(progression: 0.5, position: 10), + offset: .page(2) + ), + LocatorPage( + name: "a position after the resource is ignored", + locations: .init(progression: 0.5, position: 16), + offset: .page(2) + ), + LocatorPage( + name: "a position is ignored when a preceding page count is unknown", + pageCounts: [nil, 5], + locations: .init(progression: 0.8, position: 2), + offset: .page(4) + ), + LocatorPage( + name: "a position is not clamped when the page count of the resource is unknown", + pageCounts: [10, nil], + locations: .init(position: 20), + offset: .page(9) + ), + LocatorPage( + name: "a progression is rounded down", + locations: .init(progression: 0.5), + offset: .page(2) + ), + LocatorPage( + name: "a progression of 1 is the last page", + locations: .init(progression: 1), + offset: .page(4) + ), + LocatorPage( + name: "a progression past 1 is the last page", + locations: .init(progression: 1e300), + offset: .page(4) + ), + LocatorPage( + name: "a negative progression is the first page", + locations: .init(progression: -1), + offset: .page(0) + ), + LocatorPage( + name: "a progression is ignored when the page count is unknown", + pageCounts: [10, nil], + locations: .init(progression: 0.5), + offset: .page(0) + ), + LocatorPage( + name: "no location is the first page", + locations: .init(), + offset: .page(0) + ), + ]) + func locatorIsTurnedIntoAPage(_ example: LocatorPage) throws { + let locator = Locator(href: AnyURL(string: "b.pdf")!, mediaType: .pdf, locations: example.locations) + let position = try #require(resolver(example.pageCounts).position(of: locator)) + + #expect(position.readingOrderIndex == 1) + #expect(position.offset == example.offset) + } + + @Test func locatorOutsideTheReadingOrderHasNoPosition() { + let locator = Locator(href: AnyURL(string: "outside.pdf")!, mediaType: .pdf, locations: .init(fragments: ["page=1"])) + + #expect(resolver([10, 5]).position(of: locator) == nil) + } + } +} + +// MARK: - Helpers + +private func resolver(_ pageCounts: [Int?]) -> PDFTimelinePositionResolver { + PDFTimelinePositionResolver( + readingOrder: [ + Link(href: "a.pdf", mediaType: .pdf), + Link(href: "b.pdf", mediaType: .pdf), + ], + pageCounts: pageCounts + ) +} diff --git a/Tests/SharedTests/Publication/Services/Timeline/PDFTimelineServiceTests.swift b/Tests/SharedTests/Publication/Services/Timeline/PDFTimelineServiceTests.swift new file mode 100644 index 0000000000..73dcdf2cdf --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Timeline/PDFTimelineServiceTests.swift @@ -0,0 +1,140 @@ +// +// Copyright 2026 Readium Foundation. All 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 +@_spi(ExperimentalTimeline) @testable import ReadiumShared +import Testing +@_spi(ExperimentalTimeline) import TestSupport + +/// Reading order: `a.pdf` of 10 pages and `b.pdf` of 5 pages. +/// +/// - Part 1 → `a.pdf` +/// - Chapter 1 → `a.pdf#page=3` +/// - Chapter 2 → `b.pdf#page=2` +struct PDFTimelineServiceTests { + @Test func itemsArePlacedOnThePagesOfThePositions() async throws { + let timeline = try await timeline(positions: { _ in + InMemoryPositionsService(positionsByReadingOrder: positions([10, 5])) + }) + + #expect(timeline.outline == [ + "0 Part 1 → a.pdf", + " 1 Chapter 1 → a.pdf#page=3", + "2 Chapter 2 → b.pdf#page=2", + ]) + let segments = timeline.segments() + #expect(segments.map(\.start.readingOrderIndex) == [0, 0, 1]) + #expect(segments.map(\.start.offset) == [.page(0), .page(2), .page(1)]) + #expect(segments.map { $0.end?.readingOrderIndex } == [0, 1, 1]) + #expect(segments.map { $0.end?.offset } == [.page(2), .page(1), .page(5)]) + #expect(timeline.items[1].end?.totalProgression == 1) + } + + /// Without positions, the pages are still placed, but the end of the + /// publication is unknown. + @Test func pageCountsAreUnknownWithoutPositions() async throws { + let timeline = try await timeline(positions: nil) + + #expect(timeline.items.map { $0.start?.readingOrderIndex } == [0, 1]) + #expect(timeline.items.map { $0.start?.offset } == [.page(0), .page(1)]) + #expect(timeline.items[1].end == nil) + } + + /// Unlike for an audiobook, no items are generated from the reading order. + @Test func emptyTableOfContentsGivesAnEmptyTimeline() async throws { + let timeline = try await timeline(toc: [], positions: nil) + + #expect(timeline.items.isEmpty) + } + + @Test func positionsFailureIsThrownAndNotCached() async throws { + let service = FailingOncePositionsService(positions: positions([10, 5])) + let publication = makePublication(positions: { _ in service }) + + let error = await #expect(throws: ReadError.self) { + _ = try await publication.timeline() + } + guard case .decoding? = error else { + Issue.record("Expected a decoding error, got \(String(describing: error))") + return + } + #expect(try await publication.timeline()?.items[1].end?.offset == .page(5)) + withExtendedLifetime(publication) {} + } + + @Test func failsOnceThePublicationIsDeallocated() async { + let service = PDFTimelineService(publication: Weak()) + + let error = await #expect(throws: ReadError.self) { + _ = try await service.timeline() + } + guard case .unsupportedOperation? = error else { + Issue.record("Expected an unsupportedOperation error, got \(String(describing: error))") + return + } + } +} + +// MARK: - Helpers + +private func makePublication(toc: [Link]? = nil, positions: PositionsServiceFactory?) -> Publication { + Publication( + manifest: Manifest( + metadata: Metadata(title: ""), + readingOrder: [ + Link(href: "a.pdf", mediaType: .pdf), + Link(href: "b.pdf", mediaType: .pdf), + ], + tableOfContents: toc ?? [ + Link(href: "a.pdf", title: "Part 1", children: [ + Link(href: "a.pdf#page=3", title: "Chapter 1"), + ]), + Link(href: "b.pdf#page=2", title: "Chapter 2"), + ] + ), + servicesBuilder: PublicationServicesBuilder(setup: { + $0.setTimelineServiceFactory(PDFTimelineService.makeFactory()) + if let positions { + $0.setPositionsServiceFactory(positions) + } + }) + ) +} + +private func timeline(toc: [Link]? = nil, positions: PositionsServiceFactory?) async throws -> Timeline { + let publication = makePublication(toc: toc, positions: positions) + let timeline = try #require(await publication.timeline()) + withExtendedLifetime(publication) {} + return timeline +} + +/// One position per page of each resource. +private func positions(_ pageCounts: [Int]) -> [[Locator]] { + pageCounts.enumerated().map { index, pageCount in + (0 ..< pageCount).map { _ in + Locator(href: AnyURL(string: index == 0 ? "a.pdf" : "b.pdf")!, mediaType: .pdf) + } + } +} + +/// Fails on the first call only. +private final class FailingOncePositionsService: PositionsService, @unchecked Sendable { + private let positions: [[Locator]] + private let lock = NSLock() + private var calls = 0 + + init(positions: [[Locator]]) { + self.positions = positions + } + + func positionsByReadingOrder() async -> ReadResult<[[Locator]]> { + let call = lock.withLock { + calls += 1 + return calls + } + return call == 1 ? .failure(.decoding("Broken")) : .success(positions) + } +} diff --git a/Tests/SharedTests/Publication/Services/Timeline/TimelineLayoutTests.swift b/Tests/SharedTests/Publication/Services/Timeline/TimelineLayoutTests.swift new file mode 100644 index 0000000000..cd00ccd8d5 --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Timeline/TimelineLayoutTests.swift @@ -0,0 +1,136 @@ +// +// Copyright 2026 Readium Foundation. All 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 +@_spi(ExperimentalTimeline) @testable import ReadiumShared +import Testing + +/// Reading order: `a`, `b` and `c`, of 10, 5 and 20 pages unless a test gives +/// other lengths. +enum TimelineLayoutTests { + struct Lengths { + @Test func nonPositiveAndMissingLengthsAreUnknown() { + let layout = layout([0, -5]) + + #expect([0, 1, 2].map { layout.length(ofResourceAt: $0) } == [nil, nil, nil]) + } + + @Test func startOffsetIsUnknownAfterAnUnknownLength() { + let layout = layout([10, nil, 20]) + + #expect([0, 1, 2].map { layout.startOffset(ofResourceAt: $0) } == [0, 10, nil]) + } + } + + struct Index { + @Test func indexIgnoresTheFragment() { + let layout = layout() + + #expect(layout.index(of: AnyURL(string: "b#page=2")!) == 1) + #expect(layout.index(of: AnyURL(string: "outside")!) == nil) + } + } + + struct Positions { + /// Position at page 2 of `b` with the given lengths. + struct PositionLengths: Sendable, CustomTestStringConvertible { + let name: String + let lengths: [Int?] + let expected: TimelinePosition + + var testDescription: String { + name + } + } + + @Test(arguments: [ + PositionLengths( + name: "all the lengths are known", + lengths: [10, 5, 20], + expected: position(1, 2, totalOffset: 12, progression: 0.4, totalProgression: 12.0 / 35) + ), + PositionLengths( + name: "the length of the resource is unknown", + lengths: [10, nil, 20], + expected: position(1, 2, totalOffset: 12, progression: nil, totalProgression: nil) + ), + PositionLengths( + name: "a preceding length is unknown", + lengths: [nil, 5, 20], + expected: position(1, 2, totalOffset: nil, progression: 0.4, totalProgression: nil) + ), + PositionLengths( + name: "a later length is unknown", + lengths: [10, 5, nil], + expected: position(1, 2, totalOffset: 12, progression: 0.4, totalProgression: nil) + ), + ]) + func positionDependsOnTheLengths(_ example: PositionLengths) { + #expect(layout(example.lengths).position(at: 1, offset: 2) == example.expected) + } + } + + struct Bounds { + @Test func resourceBounds() { + let layout = layout([10, 5, nil]) + + #expect(layout.bounds(ofResource: AnyURL(string: "a")!)?.start == position(0, 0, totalOffset: 0, progression: 0, totalProgression: nil)) + #expect(layout.bounds(ofResource: AnyURL(string: "b")!)?.end == position(1, 5, totalOffset: 15, progression: 1, totalProgression: nil)) + #expect(layout.bounds(ofResource: AnyURL(string: "c")!)?.end == nil) + #expect(layout.bounds(ofResource: AnyURL(string: "outside")!) == nil) + } + + struct PublicationEnd: Sendable, CustomTestStringConvertible { + let lengths: [Int?] + let expected: TimelinePosition? + + var testDescription: String { + "\(lengths)" + } + } + + /// The end of the publication is at 100%, even when a preceding length + /// is unknown. + @Test(arguments: [ + PublicationEnd(lengths: [10, 5, 20], expected: position(2, 20, totalOffset: 35, progression: 1, totalProgression: 1)), + PublicationEnd(lengths: [nil, 5, 20], expected: position(2, 20, totalOffset: nil, progression: 1, totalProgression: 1)), + PublicationEnd(lengths: [10, 5, nil], expected: nil), + ]) + func publicationEnd(_ example: PublicationEnd) { + #expect(layout(example.lengths).end == example.expected) + } + + @Test func emptyReadingOrderHasNoEnd() { + #expect(TimelineLayout(readingOrder: [], lengths: [], offset: TimelineOffset.page).end == nil) + } + } +} + +// MARK: - Helpers + +private func layout(_ lengths: [Int?] = [10, 5, 20]) -> TimelineLayout { + TimelineLayout( + readingOrder: [Link(href: "a"), Link(href: "b"), Link(href: "c")], + lengths: lengths, + offset: TimelineOffset.page + ) +} + +private func position( + _ index: Int, + _ page: Int, + totalOffset: Int?, + progression: Double?, + totalProgression: Double? +) -> TimelinePosition { + TimelinePosition( + readingOrderIndex: index, + offset: .page(page), + totalOffset: totalOffset.map { .page($0) }, + progression: progression, + totalProgression: totalProgression + ) +} diff --git a/Tests/SharedTests/Publication/Services/Timeline/TimelineTests.swift b/Tests/SharedTests/Publication/Services/Timeline/TimelineTests.swift new file mode 100644 index 0000000000..22a20b4607 --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Timeline/TimelineTests.swift @@ -0,0 +1,614 @@ +// +// Copyright 2026 Readium Foundation. All 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 +@_spi(ExperimentalTimeline) @testable import ReadiumShared +import Testing +@_spi(ExperimentalTimeline) import TestSupport + +/// Three fixtures are used. +/// +/// **Main**: tracks `t1` 600 s, `t2` 300 s and `t3` 900 s. +/// +/// - Part 1 → `t1` +/// - Chapter 1 → `t1` +/// - Chapter 2 → `t1#t=300` +/// - Part 2 → `t2#t=60` +/// - Chapter 3 → `t2#t=120` +/// - Chapter 4 → `t3` +/// +/// **Sparse**: tracks `t1` 600 s, `t2` 300 s, `t3` 900 s and `t4` 100 s. The +/// TOC starts late, has rangeless entries, and doesn't reference `t4`. +/// +/// - A → `t2#t=10` +/// - R → `outside.mp3` (not in the reading order) +/// - C → `t3` +/// - X → `t1` (goes back) +/// - B → `t3#t=200` +/// +/// **Nested**: tracks `t1` 600 s and `t2` 300 s. A placed item is nested +/// under a rangeless one. +/// +/// - Part 1 → `t1` +/// - Chapter 1 → `t1` +/// - Chapter 2 → `outside.mp3` (not in the reading order) +/// - Section → `t1#t=300` +/// - Part 2 → `t2` +enum TimelineTests { + struct Building { + @Test func idsArePreOrderIndexesAndItemsKeepTheNodes() { + #expect(main.timeline.outline == [ + "0 Part 1 → t1", + " 1 Chapter 1 → t1", + " 2 Chapter 2 → t1#t=300", + "3 Part 2 → t2#t=60", + " 4 Chapter 3 → t2#t=120", + " 5 Chapter 4 → t3", + ]) + #expect(main.timeline.preOrderItems.map(\.link) == main.nodes.preOrder.map(\.link)) + } + + @Test func emptyNodesGiveAnEmptyTimeline() { + let timeline = TimelineFixture(durations: [600, 300, 900], nodes: []).timeline + + #expect(timeline.items.isEmpty) + #expect(timeline.locate(Locator(href: "t1", mediaType: .mp3)) == nil) + #expect(timeline.first() == nil) + #expect(timeline.segments() == []) + #expect(timeline.segments(in: url("t1")) == []) + } + + @Test func sparseItemsKeepTheirPlaceWithoutARange() { + #expect(sparse.timeline.outline == [ + "0 A → t2#t=10", + "1 R → outside.mp3", + " 2 C → t3", + "3 X → t1", + "4 B → t3#t=200", + ]) + #expect(sparse.timeline.preOrderItems.map(\.start) == [ + sparse.at(1, 10), + // Outside the reading order. + nil, + // Placed under a rangeless parent. + sparse.at(2, 0), + // Goes back. + nil, + // Compared with the last placed start (C), not with X. + sparse.at(2, 200), + ]) + } + + @Test func childStartingBeforeItsParentIsRangeless() { + let fixture = TimelineFixture(durations: [600, 300], nodes: [ + node("Parent", "t2", [node("Child", "t1")]), + ]) + + #expect(fixture["Parent"].start != nil) + #expect(fixture["Child"].start == nil) + #expect(fixture["Child"].end == nil) + } + + @Test func itemsAreAllPlaced() { + #expect(main.timeline.preOrderItems.map(\.start) == [ + // Part 1 and Chapter 1 share a start. + main.at(0, 0), + main.at(0, 0), + main.at(0, 300), + main.at(1, 60), + main.at(1, 120), + main.at(2, 0), + ]) + } + } + + struct Ends { + @Test func itemEndsAtTheNextRangedItemAfterItsSubtree() { + let publicationEnd = position(2, 900, totalOffset: 1800, progression: 1, totalProgression: 1) + + #expect(main.timeline.preOrderItems.map(\.end) == [ + // Part 1 ends where Chapter 2 does. + main.at(1, 60), + // Chapter 1 ends at Chapter 2's start. + main.at(0, 300), + // Chapter 2 ends in the next track. + main.at(1, 60), + publicationEnd, + main.at(2, 0), + publicationEnd, + ]) + } + + @Test func endsSkipRangelessItems() { + #expect(sparse.timeline.preOrderItems.map(\.end) == [ + sparse.at(2, 0), + nil, + sparse.at(2, 200), + nil, + sparse.at(3, 100), + ]) + } + + @Test func firstOfTwoSiblingsSharingAStartHasAnEmptyRange() { + let fixture = TimelineFixture(durations: [600], nodes: [node("One", "t1"), node("Two", "t1")]) + + #expect(fixture["One"].start == fixture.at(0, 0)) + #expect(fixture["One"].end == fixture.at(0, 0)) + } + + struct PublicationEnd: Sendable, CustomTestStringConvertible { + let name: String + let durations: [TimeInterval?] + let expected: TimelinePosition? + + var testDescription: String { + name + } + } + + @Test(arguments: [ + PublicationEnd( + name: "the last track duration is unknown", + durations: [600, 300, nil], + expected: nil + ), + PublicationEnd( + name: "an earlier duration is unknown but the last one is known", + durations: [nil, 300, 900], + expected: position(2, 900, totalOffset: nil, progression: 1, totalProgression: 1) + ), + ]) + func endOfThePublication(_ example: PublicationEnd) { + let fixture = TimelineFixture(durations: example.durations, nodes: [node("One", "t1"), node("Two", "t3")]) + + #expect(fixture["Two"].end == example.expected) + #expect(fixture.timeline.segments().last?.end == example.expected) + #expect(fixture.timeline.segments(in: url("t3")).last?.end == example.expected) + } + + /// `Link.duration` can be shorter than the actual track. + @Test func startPastTheKnownDurationOfATrack() { + let fixture = TimelineFixture(durations: [600, 300], nodes: [ + node("A", "t1"), + node("B", "t1#t=700"), + node("C", "t2"), + node("D", "t2#t=400"), + ]) + + // D starts past the end of the publication. + #expect(fixture["D"].end == fixture.at(1, 400)) + #expect(fixture.timeline.segments() == [ + fixture.segment("A", (0, 0), (0, 700)), + fixture.segment("B", (0, 700), (1, 0)), + fixture.segment("C", (1, 0), (1, 400)), + ]) + // Clipped to the end of each track. + #expect(fixture.timeline.segments(in: url("t1")) == [ + fixture.segment("A", (0, 0), (0, 600)), + ]) + #expect(fixture.timeline.segments(in: url("t2")) == [ + fixture.segment("C", (1, 0), (1, 300)), + ]) + } + } + + struct Equality { + struct OffsetPair: Sendable, CustomTestStringConvertible { + let name: String + let low: TimelineOffset + let high: TimelineOffset + + var testDescription: String { + name + } + } + + @Test(arguments: [ + OffsetPair(name: "seconds", low: .seconds(10), high: .seconds(20)), + OffsetPair(name: "scroll", low: .scroll(0.1), high: .scroll(0.2)), + OffsetPair(name: "page", low: .page(1), high: .page(2)), + ]) + func positionsAreOrderedByIndexThenByOffset(_ offsets: OffsetPair) { + func position(_ index: Int, _ offset: TimelineOffset) -> TimelinePosition { + TimelinePosition(readingOrderIndex: index, offset: offset) + } + + #expect(position(0, offsets.high).isBefore(position(1, offsets.low))) + #expect(position(1, offsets.low).isBefore(position(1, offsets.high))) + #expect(position(1, offsets.high).isBefore(position(2, offsets.low))) + #expect(!position(1, offsets.low).isBefore(position(1, offsets.low))) + } + + @Test func itemsOfTimelinesBuiltFromTheSameNodesAreEqual() { + let other = makeMain() + + #expect(main["Chapter 1"] == other["Chapter 1"]) + #expect(main["Chapter 1"].hashValue == other["Chapter 1"].hashValue) + #expect(main["Chapter 1"] != main["Chapter 2"]) + } + + /// C has the id of Chapter 2 in Main. + @Test func queriesIgnoreTheItemsOfAnotherTimeline() { + #expect(main.timeline.previous(before: sparse["C"]) == nil) + #expect(main.timeline.next(after: sparse["C"]) == nil) + #expect(main.timeline.ancestors(of: sparse["C"]) == []) + } + } + + struct Locate { + @Test func returnsTheDeepestItemContainingTheLocator() { + #expect(main.locate("t1", 450) == main["Chapter 2"]) + // Part 2's gap. + #expect(main.locate("t2", 90) == main["Part 2"]) + // A start shared by Part 1 and Chapter 1. + #expect(main.locate("t1", 0) == main["Chapter 1"]) + } + + @Test func stopsAtTheMaxDepth() { + #expect(main.locate("t2", 200, maxDepth: 1) == main["Part 2"]) + #expect(main.locate("t2", 200, maxDepth: 5) == main["Chapter 3"]) + // R is rangeless. + #expect(sparse.locate("t3", 100, maxDepth: 1) == nil) + } + + @Test func coverageOfASparseTimeline() { + #expect(sparse.locate("t1", 300) == nil) + // Before A. + #expect(sparse.locate("t2", 5) == nil) + #expect(sparse.locate("outside.mp3") == nil) + // Not referenced by the TOC. + #expect(sparse.locate("t4", 50) == sparse["B"]) + // Past the known end of the last track. + #expect(sparse.locate("t4", 150) == sparse["B"]) + } + } + + struct First { + @Test func isTheFirstRangedItemInPreOrder() { + // The outermost of Part 1 and Chapter 1, which share the first + // start. + #expect(main.timeline.first() == main["Part 1"]) + #expect(first.timeline.first() == first["C"]) + } + + @Test func stopsAtTheMaxDepth() { + #expect(first.timeline.first(maxDepth: 1) == first["X"]) + } + + @Test func isNilWhenNoItemIsPlaced() { + let timeline = TimelineFixture(durations: [600], nodes: [node("R", "outside.mp3")]).timeline + + #expect(timeline.first() == nil) + } + + /// The first placed item is under a rangeless parent. + private let first = TimelineFixture(durations: [600, 300], nodes: [ + node("R", "outside.mp3", [node("C", "t1")]), + node("X", "t2"), + ]) + } + + struct PreviousAndNext { + @Test func followThePreOrder() { + let order = ["Chapter 1", "Chapter 2", "Part 2", "Chapter 3", "Chapter 4"].map { main[$0] } + for (item, next) in zip(order, order.dropFirst()) { + #expect(main.timeline.next(after: item) == next) + #expect(main.timeline.previous(before: next) == item) + } + #expect(main.timeline.previous(before: main["Part 1"]) == nil) + #expect(main.timeline.next(after: main["Chapter 4"]) == nil) + } + + @Test func skipItemsWithTheSameStart() { + #expect(main.timeline.next(after: main["Part 1"]) == main["Chapter 2"]) + #expect(main.timeline.previous(before: main["Chapter 1"]) == nil) + } + + @Test func rangelessItemsUseTheirPlaceInThePreOrder() { + #expect(sparse.timeline.next(after: sparse["A"]) == sparse["C"]) + #expect(sparse.timeline.next(after: sparse["R"]) == sparse["C"]) + #expect(sparse.timeline.previous(before: sparse["X"]) == sparse["C"]) + #expect(sparse.timeline.next(after: sparse["X"]) == sparse["B"]) + } + + @Test func stopAtTheMaxDepth() { + #expect(main.timeline.next(after: main["Chapter 2"], maxDepth: 1) == main["Part 2"]) + // The containing part, as it compares with the start of the given + // item. + #expect(main.timeline.previous(before: main["Chapter 3"], maxDepth: 1) == main["Part 2"]) + } + + @Test(arguments: [0, -1]) + func maxDepthBelowOneReturnsNothing(maxDepth: Int) { + #expect(main.locate("t1", 450, maxDepth: maxDepth) == nil) + #expect(main.timeline.first(maxDepth: maxDepth) == nil) + #expect(main.timeline.previous(before: main["Chapter 2"], maxDepth: maxDepth) == nil) + #expect(main.timeline.next(after: main["Chapter 2"], maxDepth: maxDepth) == nil) + #expect(main.timeline.segments(maxDepth: maxDepth) == []) + #expect(main.timeline.segments(in: url("t1"), maxDepth: maxDepth) == []) + } + } + + struct Ancestors { + @Test func goFromTheTopLevelToTheParent() { + #expect(main.timeline.ancestors(of: main["Part 1"]) == []) + #expect(main.timeline.ancestors(of: main["Chapter 3"]) == [main["Part 2"]]) + #expect(sparse.timeline.ancestors(of: sparse["C"]) == [sparse["R"]]) + } + } + + struct Segments { + @Test func coverTheTimelineWithTheDeepestItems() { + #expect(main.timeline.segments() == [ + // Part 1's segment is empty. + main.segment("Chapter 1", (0, 0), (0, 300)), + main.segment("Chapter 2", (0, 300), (1, 60)), + main.segment("Part 2", (1, 60), (1, 120)), + main.segment("Chapter 3", (1, 120), (2, 0)), + main.segment("Chapter 4", (2, 0), (2, 900)), + ]) + } + + @Test func stopAtTheMaxDepth() { + #expect(main.timeline.segments(maxDepth: 1) == [ + main.segment("Part 1", (0, 0), (1, 60)), + main.segment("Part 2", (1, 60), (2, 900)), + ]) + } + + @Test func skipTheGapsOfASparseTimeline() { + #expect(sparse.timeline.segments() == [ + sparse.segment("A", (1, 10), (2, 0)), + sparse.segment("C", (2, 0), (2, 200)), + sparse.segment("B", (2, 200), (3, 100)), + ]) + // `[t3 0, t3 200)` has no segment, as `locate` returns nil there. + #expect(sparse.timeline.segments(maxDepth: 1) == [ + sparse.segment("A", (1, 10), (2, 0)), + sparse.segment("B", (2, 200), (3, 100)), + ]) + } + + @Test func ancestorKeepsTheRangeOfADeeperItemUnderARangelessOne() { + #expect(nested.timeline.segments() == [ + nested.segment("Chapter 1", (0, 0), (0, 300)), + nested.segment("Section", (0, 300), (1, 0)), + nested.segment("Part 2", (1, 0), (1, 300)), + ]) + // `locate` returns Part 1 in `[t1 300, t2 0)`. + #expect(nested.timeline.segments(maxDepth: 2) == [ + nested.segment("Chapter 1", (0, 0), (0, 300)), + nested.segment("Part 1", (0, 300), (1, 0)), + nested.segment("Part 2", (1, 0), (1, 300)), + ]) + #expect(nested.timeline.segments(maxDepth: 1) == [ + nested.segment("Part 1", (0, 0), (1, 0)), + nested.segment("Part 2", (1, 0), (1, 300)), + ]) + } + + struct FixtureDepth: Sendable, CustomTestStringConvertible { + let name: String + let fixture: TimelineFixture + let maxDepth: Int? + + var testDescription: String { + name + } + } + + /// `locate` returns the item of a segment at its start and 1 ms before + /// its end. + @Test(arguments: [ + FixtureDepth(name: "main", fixture: main, maxDepth: nil), + FixtureDepth(name: "main at depth 1", fixture: main, maxDepth: 1), + FixtureDepth(name: "sparse", fixture: sparse, maxDepth: nil), + FixtureDepth(name: "sparse at depth 1", fixture: sparse, maxDepth: 1), + FixtureDepth(name: "nested", fixture: nested, maxDepth: nil), + FixtureDepth(name: "nested at depth 2", fixture: nested, maxDepth: 2), + ]) + func labelsMatchLocate(_ example: FixtureDepth) { + let fixture = example.fixture + for segment in fixture.timeline.segments(maxDepth: example.maxDepth) { + #expect(fixture.locate(segment.start, maxDepth: example.maxDepth) == segment.item) + #expect(fixture.locate(segment.end!, by: -0.001, maxDepth: example.maxDepth) == segment.item) + } + } + } + + struct SegmentsInResource { + @Test func areClippedToTheResource() { + #expect(main.timeline.segments(in: url("t1")) == [ + main.segment("Chapter 1", (0, 0), (0, 300)), + main.segment("Chapter 2", (0, 300), (0, 600)), + ]) + #expect(main.timeline.segments(in: url("t2")) == [ + main.segment("Chapter 2", (1, 0), (1, 60)), + main.segment("Part 2", (1, 60), (1, 120)), + main.segment("Chapter 3", (1, 120), (1, 300)), + ]) + // No empty Chapter 3. + #expect(main.timeline.segments(in: url("t3")) == [ + main.segment("Chapter 4", (2, 0), (2, 900)), + ]) + } + + @Test func ofASparseTimeline() { + #expect(sparse.timeline.segments(in: url("t1")) == []) + #expect(sparse.timeline.segments(in: url("t2")) == [ + sparse.segment("A", (1, 10), (1, 300)), + ]) + } + + @Test func endIsNilWhenTheTrackDurationIsUnknown() { + let fixture = TimelineFixture(durations: [600, nil, 900], nodes: makeMain().nodes) + + #expect(fixture.timeline.segments(in: url("t2")).last?.item == fixture["Chapter 3"]) + #expect(fixture.timeline.segments(in: url("t2")).last?.end == nil) + } + + @Test func hrefIsResolvedInTheReadingOrder() { + #expect(main.timeline.segments(in: url("t2#t=60")) == main.timeline.segments(in: url("t2"))) + #expect(main.timeline.segments(in: url("outside.mp3")) == []) + } + + @Test func stopAtTheMaxDepth() { + #expect(main.timeline.segments(in: url("t2"), maxDepth: 1) == [ + main.segment("Part 1", (1, 0), (1, 60)), + main.segment("Part 2", (1, 60), (1, 300)), + ]) + } + } +} + +// MARK: - Helpers + +private let main = makeMain() +private let sparse = makeSparse() +private let nested = makeNested() + +private func makeMain() -> TimelineFixture { + TimelineFixture(durations: [600, 300, 900], nodes: [ + node("Part 1", "t1", [ + node("Chapter 1", "t1"), + node("Chapter 2", "t1#t=300"), + ]), + node("Part 2", "t2#t=60", [ + node("Chapter 3", "t2#t=120"), + node("Chapter 4", "t3"), + ]), + ]) +} + +private func makeSparse() -> TimelineFixture { + TimelineFixture(durations: [600, 300, 900, 100], nodes: [ + node("A", "t2#t=10"), + node("R", "outside.mp3", [node("C", "t3")]), + node("X", "t1"), + node("B", "t3#t=200"), + ]) +} + +private func makeNested() -> TimelineFixture { + TimelineFixture(durations: [600, 300], nodes: [ + node("Part 1", "t1", [ + node("Chapter 1", "t1"), + node("Chapter 2", "outside.mp3", [node("Section", "t1#t=300")]), + ]), + node("Part 2", "t2"), + ]) +} + +private func node(_ title: String, _ href: String, _ children: [Timeline.Node] = []) -> Timeline.Node { + Timeline.Node(title: .text(title), link: Link(href: href), children: children) +} + +private func url(_ string: String) -> AnyURL { + AnyURL(string: string)! +} + +private func position( + _ index: Int, + _ offset: TimeInterval, + totalOffset: TimeInterval?, + progression: Double?, + totalProgression: Double? +) -> TimelinePosition { + TimelinePosition( + readingOrderIndex: index, + offset: .seconds(offset), + totalOffset: totalOffset.map { .seconds($0) }, + progression: progression, + totalProgression: totalProgression + ) +} + +/// A timeline built from tracks named `t1`, `t2` and so on. +struct TimelineFixture: Sendable { + let durations: [TimeInterval?] + let readingOrder: [Link] + let nodes: [Timeline.Node] + let timeline: Timeline + + init(durations: [TimeInterval?], nodes: [Timeline.Node]) { + self.durations = durations + readingOrder = durations.enumerated().map { index, duration in + Link(href: "t\(index + 1)", mediaType: .mp3, duration: duration) + } + self.nodes = nodes + timeline = Timeline(nodes: nodes, resolver: AudioTimelinePositionResolver(readingOrder: readingOrder)) + } + + /// Returns the item with the given title. + subscript(title: String) -> TimelineItem { + timeline.preOrderItems.first { $0.title == .text(title) }! + } + + /// Returns the position at `offset` in the track at `index`, when all the + /// durations are known. + func at(_ index: Int, _ offset: TimeInterval) -> TimelinePosition { + let durations = durations.map { $0! } + let totalOffset = durations[.. TimelineSegment { + TimelineSegment(item: self[title], start: at(start.0, start.1), end: at(end.0, end.1)) + } + + func locate(_ href: String, _ offset: TimeInterval? = nil, maxDepth: Int? = nil) -> TimelineItem? { + let fragments = offset.map { ["t=\($0)"] } ?? [] + return timeline.locate(Locator(href: href, mediaType: .mp3, locations: .init(fragments: fragments)), maxDepth: maxDepth) + } + + /// Locates the position moved by `delta` seconds, possibly in the + /// previous track. + func locate(_ position: TimelinePosition, by delta: TimeInterval = 0, maxDepth: Int? = nil) -> TimelineItem? { + var index = position.readingOrderIndex + var offset = position.offset.seconds + delta + if offset < 0 { + index -= 1 + offset += durations[index]! + } + return locate("t\(index + 1)", offset, maxDepth: maxDepth) + } +} + +private extension Timeline { + /// All the items, in pre-order. + var preOrderItems: [TimelineItem] { + items.preOrder + } +} + +private extension [TimelineItem] { + var preOrder: [TimelineItem] { + flatMap { [$0] + $0.children.preOrder } + } +} + +private extension [Timeline.Node] { + var preOrder: [Timeline.Node] { + flatMap { [$0] + $0.children.preOrder } + } +} + +private extension TimelineOffset { + var seconds: TimeInterval { + guard case let .seconds(seconds) = self else { + fatalError("Expected an offset in seconds, got \(self)") + } + return seconds + } +} diff --git a/Tests/SharedTests/Publication/Services/Timeline/WebTimelineServiceTests.swift b/Tests/SharedTests/Publication/Services/Timeline/WebTimelineServiceTests.swift new file mode 100644 index 0000000000..172f5dcf00 --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Timeline/WebTimelineServiceTests.swift @@ -0,0 +1,167 @@ +// +// Copyright 2026 Readium Foundation. All 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 +@_spi(ExperimentalTimeline) @testable import ReadiumShared +import Testing +@_spi(ExperimentalTimeline) import TestSupport + +/// Reading order: `cover`, `ch1a`, `ch1b`, `ch2` and `ch3`, where Chapter 1 is +/// split across `ch1a` and `ch1b`. +/// +/// - (untitled) → `ch1a` +/// - Chapter 1 → `ch1a#start` +/// - Section 1.1 → `ch1a#s11` (same resource as Chapter 1) +/// - Chapter 2 → `ch2#top` +/// - Notes 1 → `ch1b` (goes back) +/// - Chapter 3 → `ch3` +/// - Appendix → `outside` (not in the reading order) +struct WebTimelineServiceTests { + @Test func untitledEntriesAreReplacedByTheirChildren() async throws { + let timeline = try await timeline() + + #expect(timeline.outline == [ + "0 Chapter 1 → ch1a.xhtml#start", + "1 Section 1.1 → ch1a.xhtml#s11", + "2 Chapter 2 → ch2.xhtml#top", + "3 Notes 1 → ch1b.xhtml", + "4 Chapter 3 → ch3.xhtml", + "5 Appendix → outside.xhtml", + ]) + } + + /// Notes 1 is the first entry of `ch1b`, but goes back. Chapter 3 ends + /// with the publication. + @Test func onlyTheFirstEntryOfEachResourceIsPlaced() async throws { + let timeline = try await timeline() + + #expect(timeline.items.map(\.start) == [position(1), nil, position(3), nil, position(4), nil]) + #expect(timeline.items.map(\.end) == [position(3), nil, position(4), nil, position(4, 1), nil]) + } + + struct ResourceChapter: Sendable, CustomTestStringConvertible { + let name: String + let href: String + let progression: Double + /// Title of the located item, `nil` for none. + let expected: String? + + var testDescription: String { + name + } + } + + @Test(arguments: [ + ResourceChapter(name: "the cover is before the first chapter", href: "cover.xhtml", progression: 0.5, expected: nil), + ResourceChapter(name: "the resource of a chapter", href: "ch1a.xhtml", progression: 0.9, expected: "Chapter 1"), + ResourceChapter(name: "the second resource of a split chapter", href: "ch1b.xhtml", progression: 0.5, expected: "Chapter 1"), + ResourceChapter(name: "the start of a chapter with a fragment", href: "ch2.xhtml", progression: 0, expected: "Chapter 2"), + ResourceChapter(name: "the end of the last chapter", href: "ch3.xhtml", progression: 1, expected: "Chapter 3"), + ResourceChapter(name: "a resource outside the reading order", href: "outside.xhtml", progression: 0, expected: nil), + ]) + func locatesTheChapterOfTheResource(_ example: ResourceChapter) async throws { + let timeline = try await timeline() + let locator = Locator(href: AnyURL(string: example.href)!, mediaType: .xhtml, locations: .init(progression: example.progression)) + + #expect(timeline.locate(locator)?.title == example.expected.map { .text($0) }) + } + + @Test func aSplitChapterCoversItsFollowingResources() async throws { + let timeline = try await timeline() + + #expect(timeline.segments(in: AnyURL(string: "ch1b.xhtml")!) == [ + TimelineSegment(item: timeline.items[0], start: position(2), end: position(2, 1)), + ]) + } + + @Test func nextAndPreviousSkipTheUnplacedEntries() async throws { + let timeline = try await timeline() + let chapter1 = timeline.items[0] + let chapter2 = timeline.items[2] + let chapter3 = timeline.items[4] + + #expect(timeline.next(after: chapter1) == chapter2) + #expect(timeline.next(after: chapter2) == chapter3) + #expect(timeline.previous(before: chapter3) == chapter2) + #expect(timeline.previous(before: chapter2) == chapter1) + } + + /// A fixed-layout locator usually has no progression. + @Test func fixedLayoutResourcesAreLikeReflowableOnes() async throws { + let timeline = try await timeline(layout: .fixed) + let locator = Locator(href: AnyURL(string: "ch1a.xhtml")!, mediaType: .xhtml) + + #expect(timeline.items[0].start == position(1)) + #expect(timeline.locate(locator) == timeline.items[0]) + } + + @Test func readsTheTableOfContentsService() async throws { + let timeline = try await timeline(tocService: { _ in + StubTableOfContentsService(toc: [Link(href: "ch2.xhtml", title: "Custom")]) + }) + + #expect(timeline.outline == ["0 Custom → ch2.xhtml"]) + } + + @Test func failsOnceThePublicationIsDeallocated() async { + let service = WebTimelineService(publication: Weak()) + + let error = await #expect(throws: ReadError.self) { + _ = try await service.timeline() + } + guard case .unsupportedOperation? = error else { + Issue.record("Expected an unsupportedOperation error, got \(String(describing: error))") + return + } + } +} + +// MARK: - Helpers + +private func timeline( + layout: Layout? = nil, + tocService: TableOfContentsServiceFactory? = nil +) async throws -> Timeline { + let publication = Publication( + manifest: Manifest( + metadata: Metadata(title: "", layout: layout), + readingOrder: ["cover", "ch1a", "ch1b", "ch2", "ch3"].map { + Link(href: "\($0).xhtml", mediaType: .xhtml) + }, + tableOfContents: [ + Link(href: "ch1a.xhtml", children: [ + Link(href: "ch1a.xhtml#start", title: "Chapter 1"), + Link(href: "ch1a.xhtml#s11", title: "Section 1.1"), + ]), + Link(href: "ch2.xhtml#top", title: "Chapter 2"), + Link(href: "ch1b.xhtml", title: "Notes 1"), + Link(href: "ch3.xhtml", title: "Chapter 3"), + Link(href: "outside.xhtml", title: "Appendix"), + ] + ), + servicesBuilder: PublicationServicesBuilder(setup: { + $0.setTimelineServiceFactory(WebTimelineService.makeFactory()) + if let tocService { + $0.setTableOfContentsServiceFactory(tocService) + } + }) + ) + let timeline = try #require(await publication.timeline()) + withExtendedLifetime(publication) {} + return timeline +} + +private func position(_ index: Int, _ progression: Double = 0) -> TimelinePosition { + TimelinePosition(readingOrderIndex: index, offset: .scroll(progression)) +} + +private struct StubTableOfContentsService: TableOfContentsService { + let toc: [Link] + + func tableOfContents() async -> ReadResult<[Link]> { + .success(toc) + } +} diff --git a/Tests/SharedTests/Toolkit/AsyncThrowingMemoizerTests.swift b/Tests/SharedTests/Toolkit/AsyncThrowingMemoizerTests.swift new file mode 100644 index 0000000000..7d32faaef8 --- /dev/null +++ b/Tests/SharedTests/Toolkit/AsyncThrowingMemoizerTests.swift @@ -0,0 +1,52 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +@testable import ReadiumShared +import Testing + +struct AsyncThrowingMemoizerTests { + @Test func cachesTheFirstSuccess() async throws { + let calls = CallCounter() + let memoizer = AsyncThrowingMemoizer { () async throws(ReadError) -> Int in + await calls.increment() + } + + #expect(try await memoizer() == 1) + #expect(try await memoizer() == 1) + #expect(await calls.count == 1) + } + + @Test func computesAgainAfterAFailure() async throws { + let calls = CallCounter() + let memoizer = AsyncThrowingMemoizer { () async throws(ReadError) -> Int in + let call = await calls.increment() + guard call > 1 else { + throw .decoding("Broken") + } + return call + } + + await #expect(throws: ReadError.self) { + try await memoizer() + } + #expect(try await memoizer() == 2) + #expect(try await memoizer() == 2) + #expect(await calls.count == 2) + } +} + +// MARK: - Helpers + +/// Counts the computations, returning the number of each one. +private actor CallCounter { + private(set) var count = 0 + + func increment() -> Int { + count += 1 + return count + } +} diff --git a/Tests/SharedTests/Toolkit/Selectors/PDFSelectorTests.swift b/Tests/SharedTests/Toolkit/Selectors/PDFSelectorTests.swift new file mode 100644 index 0000000000..b612959cb6 --- /dev/null +++ b/Tests/SharedTests/Toolkit/Selectors/PDFSelectorTests.swift @@ -0,0 +1,82 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@_spi(ExperimentalSelectors) @testable import ReadiumShared +import Testing + +struct PDFSelectorTests { + @Test("page only") + func pageOnly() { + #expect(PDFSelector(fragment: "page=3") == PDFSelector(page: 3)) + } + + @Test("page with viewrect") + func pageWithViewrect() { + #expect( + PDFSelector(fragment: "page=3&viewrect=10.0,20.0,100.0,50.0") == + PDFSelector(page: 3, rect: PDFSelector.Rect(left: 10, top: 20, width: 100, height: 50)) + ) + } + + @Test("page with fractional viewrect") + func pageWithFractionalViewrect() { + #expect( + PDFSelector(fragment: "page=3&viewrect=10.5,20.25,100.75,50.1") == + PDFSelector(page: 3, rect: PDFSelector.Rect(left: 10.5, top: 20.25, width: 100.75, height: 50.1)) + ) + } + + @Test("viewrect before page") + func viewrectBeforePage() { + #expect( + PDFSelector(fragment: "viewrect=10.0,20.0,100.0,50.0&page=3") == + PDFSelector(page: 3, rect: PDFSelector.Rect(left: 10, top: 20, width: 100, height: 50)) + ) + } + + @Test("percent-encoded parameters") + func percentEncodedParameters() { + #expect( + PDFSelector(fragment: "page=%33&viewrect=10%2C20%2C100%2C50") == + PDFSelector(page: 3, rect: PDFSelector.Rect(left: 10, top: 20, width: 100, height: 50)) + ) + } + + @Test("invalid: missing page") + func invalidMissingPage() { + #expect(PDFSelector(fragment: "viewrect=10,20,100,50") == nil) + } + + @Test("invalid: page not integer") + func invalidPageNotInteger() { + #expect(PDFSelector(fragment: "page=abc") == nil) + } + + @Test("fragment: page only") + func fragmentPageOnly() { + #expect(PDFSelector(page: 3).fragment == "page=3") + } + + @Test("fragment: page with rect") + func fragmentPageWithRect() { + #expect( + PDFSelector(page: 3, rect: PDFSelector.Rect(left: 10, top: 20, width: 100, height: 50)).fragment == + "page=3&viewrect=10.0,20.0,100.0,50.0" + ) + } + + @Test("round-trip: page only") + func roundTripPageOnly() { + let selector = PDFSelector(page: 3) + #expect(PDFSelector(fragment: selector.fragment) == selector) + } + + @Test("round-trip: page with rect") + func roundTripPageWithRect() { + let selector = PDFSelector(page: 3, rect: PDFSelector.Rect(left: 10, top: 20, width: 100, height: 50)) + #expect(PDFSelector(fragment: selector.fragment) == selector) + } +} diff --git a/Tests/SharedTests/Toolkit/Selectors/TemporalSelectorTests.swift b/Tests/SharedTests/Toolkit/Selectors/TemporalSelectorTests.swift index 25e1861394..e0871efc8a 100644 --- a/Tests/SharedTests/Toolkit/Selectors/TemporalSelectorTests.swift +++ b/Tests/SharedTests/Toolkit/Selectors/TemporalSelectorTests.swift @@ -5,7 +5,7 @@ // import Foundation -@testable import ReadiumShared +@_spi(ExperimentalSelectors) @testable import ReadiumShared import Testing enum TemporalSelectorTests { diff --git a/Tests/StreamerTests/Fixtures/outline.pdf b/Tests/StreamerTests/Fixtures/outline.pdf new file mode 100644 index 0000000000..cd28cb44a7 Binary files /dev/null and b/Tests/StreamerTests/Fixtures/outline.pdf differ diff --git a/Tests/StreamerTests/Parser/Audio/AudioManifestBuilderTests.swift b/Tests/StreamerTests/Parser/Audio/AudioManifestBuilderTests.swift index 271c31dc0d..e78793d9d2 100644 --- a/Tests/StreamerTests/Parser/Audio/AudioManifestBuilderTests.swift +++ b/Tests/StreamerTests/Parser/Audio/AudioManifestBuilderTests.swift @@ -549,6 +549,34 @@ enum AudioManifestBuilderTests { #expect(warnings.audioMetadataWarnings == [.invalidChapterStart(href: url("1.mp3"), title: "Part 1")]) } } + + struct FilesTableOfContents: Sendable, CustomTestStringConvertible { + let name: String + let files: [AudioMetadata] + let expected: [Link] + + var testDescription: String { + name + } + } + + /// The option only disables the table of contents made of the file + /// titles: the chapters of the files are still merged. + @Test(arguments: [ + FilesTableOfContents( + name: "titled files without chapters", + files: [AudioMetadata(title: "Part 1"), AudioMetadata(title: "Part 2")], + expected: [] + ), + FilesTableOfContents( + name: "the first file has chapters, the second one is titled without chapters", + files: [AudioMetadata(chapters: [.init(title: "Chapter 1", start: 0)]), AudioMetadata(title: "Part 2")], + expected: [Link(href: "1.mp3#t=0", title: "Chapter 1"), Link(href: "2.mp3", title: "Part 2")] + ), + ]) + func isNotMadeOfTheFileTitlesWhenTheOptionIsOff(_ example: FilesTableOfContents) { + #expect(build(example.files, generatesTableOfContentsFromReadingOrder: false).tableOfContents == example.expected) + } } struct ReferenceExample { @@ -644,8 +672,8 @@ private func build(_ files: AudioMetadata?..., warnings: WarningLogger? = nil) - build(files, warnings: warnings) } -private func build(_ files: [AudioMetadata?], warnings: WarningLogger? = nil) -> Manifest { - AudioManifestBuilder().build( +private func build(_ files: [AudioMetadata?], generatesTableOfContentsFromReadingOrder: Bool = true, warnings: WarningLogger? = nil) -> Manifest { + AudioManifestBuilder(generatesTableOfContentsFromReadingOrder: generatesTableOfContentsFromReadingOrder).build( entries: files.enumerated().map { index, metadata in entry("\(index + 1).mp3", .mp3, metadata) }, diff --git a/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift b/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift index 6db6939941..aba035b399 100644 --- a/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift +++ b/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift @@ -5,9 +5,10 @@ // import Foundation -import ReadiumShared +@_spi(ExperimentalTimeline) import ReadiumShared @testable import ReadiumStreamer import Testing +@_spi(ExperimentalTimeline) import TestSupport enum AudioParserTests { struct Formats { @@ -75,7 +76,7 @@ enum AudioParserTests { readsEntries: Bool ) async throws { let reader = FakeAudioMetadataReader { _ in .success(AudioMetadata(title: "Title")) } - let parser = AudioParser(assetRetriever: assetRetriever, metadataReader: reader, containerEntriesMetadataPolicy: policy) + let parser = AudioParser(metadataReader: reader, config: .init(containerEntriesMetadataPolicy: policy)) try await expectEntries(read: readsEntries, by: parser, reader: reader, source: source) } @@ -118,7 +119,7 @@ enum AudioParserTests { @Test func alwaysReadsAStandaloneFile() async throws { let reader = FakeAudioMetadataReader { _ in .success(AudioMetadata(title: "Title")) } - let parser = AudioParser(assetRetriever: assetRetriever, metadataReader: reader, containerEntriesMetadataPolicy: .never) + let parser = AudioParser(metadataReader: reader, config: .init(containerEntriesMetadataPolicy: .never)) let publication = try await parser.parse(asset: fileAsset("audio/tagged.mp3"), warnings: nil).get().build() @@ -163,6 +164,7 @@ enum AudioParserTests { (1, 1), // One file at a time for a value below 1. (0, 1), + (-1, 1), ] as [(Int?, Int)]) func readsABatchOfFilesAtATimeAndKeepsTheReadingOrder(maxConcurrentMetadataReads: Int?, expectedBatchSize: Int) async throws { let fileCount = 6 @@ -185,9 +187,10 @@ enum AudioParserTests { completedFiles.withLock { _ = $0.insert(file) } return file % 2 == 0 ? .failure(.reading(.decoding("Truncated"))) : .success(AudioMetadata(title: "Title \(file)")) } - let parser = maxConcurrentMetadataReads.map { - AudioParser(assetRetriever: assetRetriever, metadataReader: reader, maxConcurrentMetadataReads: $0) - } ?? AudioParser(metadataReader: reader) + let parser = AudioParser( + metadataReader: reader, + config: maxConcurrentMetadataReads.map { .init(maxConcurrentMetadataReads: $0) } ?? .init() + ) let warnings = ListWarningLogger() let publication = try await withFolder(fileCount: fileCount) { folder in @@ -253,6 +256,88 @@ enum AudioParserTests { } } + /// With a reader giving each file a duration of 100 s, on folders of + /// files named `1.mp3`, `2.mp3` and so on. + struct TimelineGeneration { + struct Files: Sendable, CustomTestStringConvertible { + let name: String + var generatesTableOfContentsFromReadingOrder = true + let metadata: [AudioMetadata] + /// HREFs of the expected table of contents. + let expectedTOC: [String] + /// Outline of the expected timeline. + let expectedTimeline: [String] + + var testDescription: String { + name + } + } + + @Test(arguments: [ + Files( + name: "files with chapters", + metadata: [ + AudioMetadata(chapters: [.init(title: "One", start: 0), .init(title: "Two", start: 50)]), + AudioMetadata(chapters: [.init(title: "Three", start: 0)]), + ], + expectedTOC: ["1.mp3#t=0", "1.mp3#t=50", "2.mp3#t=0"], + expectedTimeline: ["0 One → 1.mp3#t=0", "1 Two → 1.mp3#t=50", "2 Three → 2.mp3#t=0"] + ), + Files( + name: "two titled files make the table of contents", + metadata: [AudioMetadata(), AudioMetadata(title: "Two"), AudioMetadata(), AudioMetadata(title: "Four")], + expectedTOC: ["2.mp3", "4.mp3"], + expectedTimeline: ["0 Two → 2.mp3", "1 Four → 4.mp3"] + ), + Files( + name: "a single titled file leaves the table of contents empty", + metadata: [AudioMetadata(title: "One"), AudioMetadata()], + expectedTOC: [], + expectedTimeline: ["0 One → 1.mp3", "1 #2 → 2.mp3"] + ), + Files( + name: "the option off disables both fallbacks", + generatesTableOfContentsFromReadingOrder: false, + metadata: [AudioMetadata(title: "One"), AudioMetadata(title: "Two")], + expectedTOC: [], + expectedTimeline: [] + ), + ]) + func timelineFollowsTheTableOfContents(_ example: Files) async throws { + let publication = try await parse( + example.metadata, + generatesTableOfContentsFromReadingOrder: example.generatesTableOfContentsFromReadingOrder + ) + + #expect(publication.manifest.tableOfContents.map(\.href) == example.expectedTOC) + #expect(try await publication.timeline()?.outline == example.expectedTimeline) + } + + @Test func untitledFileBelongsToThePrecedingItem() async throws { + let publication = try await parse([AudioMetadata(), AudioMetadata(title: "Two"), AudioMetadata(), AudioMetadata(title: "Four")]) + let timeline = try #require(await publication.timeline()) + + #expect(timeline.locate(Locator(href: AnyURL(string: "3.mp3")!, mediaType: .mp3)) == timeline.items[0]) + #expect(timeline.locate(Locator(href: AnyURL(string: "1.mp3")!, mediaType: .mp3)) == nil) + #expect(timeline.segments(in: AnyURL(string: "1.mp3")!) == []) + } + + /// Parses a folder of files having the given metadata, with a + /// duration of 100 s. + private func parse(_ files: [AudioMetadata], generatesTableOfContentsFromReadingOrder: Bool = true) async throws -> Publication { + let reader = FakeAudioMetadataReader { file in + var metadata = files[file - 1] + metadata.duration = 100 + return .success(metadata) + } + let parser = AudioParser(metadataReader: reader, config: .init(generatesTableOfContentsFromReadingOrder: generatesTableOfContentsFromReadingOrder)) + + return try await withFolder(fileCount: files.count) { folder in + try await parser.parse(asset: folder, warnings: nil).get().build() + } + } + } + /// With the default reader, on the fixtures of `Fixtures/audio`. struct DefaultReader { @Test func readsAStandaloneFile() async throws { @@ -462,9 +547,12 @@ private final class FakeAudioMetadataReader: AudioMetadataReader, @unchecked Sen } private extension AudioParser { - /// Creates a parser with its default options. - convenience init(metadataReader: AudioMetadataReader = DefaultAudioMetadataReader()) { - self.init(assetRetriever: assetRetriever, metadataReader: metadataReader) + /// Creates a parser with the shared asset retriever. + convenience init( + metadataReader: AudioMetadataReader = DefaultAudioMetadataReader(), + config: Configuration = .init() + ) { + self.init(assetRetriever: assetRetriever, metadataReader: metadataReader, config: config) } } diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBParserTests.swift new file mode 100644 index 0000000000..b9a6330666 --- /dev/null +++ b/Tests/StreamerTests/Parser/EPUB/EPUBParserTests.swift @@ -0,0 +1,35 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@_spi(ExperimentalTimeline) import ReadiumShared +@testable import ReadiumStreamer +import Testing +@_spi(ExperimentalTimeline) import TestSupport + +struct EPUBParserTests { + /// All the table of contents entries point to `s04.xhtml`, after the cover + /// and the navigation document. + @Test func timelinePlacesTheFirstEntryOfEachResource() async throws { + let publication = try await parse("childrens-literature.epub") + let timeline = try #require(await publication.timeline()) + let section = try #require(timeline.items.first) + + #expect(section.title == .text("SECTION IV FAIRY STORIES—MODERN FANTASTIC TALES")) + #expect(timeline.segments().map(\.item) == [section]) + #expect(timeline.locate(Locator(href: "EPUB/cover.xhtml", mediaType: .xhtml)) == nil) + #expect(timeline.locate(Locator(href: "EPUB/s04.xhtml", mediaType: .xhtml, locations: .init(progression: 0.8))) == section) + } +} + +// MARK: - Helpers + +private func parse(_ filename: String) async throws -> Publication { + let container = try await ZIPArchiveOpener().open( + resource: FileResource(file: FileURL(url: TestPublications.url(for: filename))!), + format: Format(specifications: .zip, .epub, mediaType: .epub, fileExtension: "epub") + ).get() + return try await EPUBParser().parse(asset: .container(container), warnings: nil).get().build() +} diff --git a/Tests/StreamerTests/Parser/PDF/PDFParserTests.swift b/Tests/StreamerTests/Parser/PDF/PDFParserTests.swift new file mode 100644 index 0000000000..1ffff80a9a --- /dev/null +++ b/Tests/StreamerTests/Parser/PDF/PDFParserTests.swift @@ -0,0 +1,45 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@_spi(ExperimentalTimeline) import ReadiumShared +@testable import ReadiumStreamer +import Testing +@_spi(ExperimentalTimeline) import TestSupport + +struct PDFParserTests { + /// `outline.pdf` has 4 pages and this outline: + /// + /// - Part 1 → page 1 + /// - Chapter 1 → page 2 + /// - Chapter 2 → page 4 + @Test func timelinePlacesTheOutlineOnThePages() async throws { + let publication = try await parse("outline.pdf") + let timeline = try #require(await publication.timeline()) + let href = publication.readingOrder[0].href + let chapter1 = timeline.items[0].children[0] + let chapter2 = timeline.items[1] + + #expect(timeline.outline == [ + "0 Part 1 → \(href)#page=1", + " 1 Chapter 1 → \(href)#page=2", + "2 Chapter 2 → \(href)#page=4", + ]) + #expect(chapter1.end?.offset == .page(3)) + #expect(chapter2.end?.offset == .page(4)) + #expect(timeline.locate(Locator(href: href, mediaType: .pdf, locations: .init(fragments: ["page=3"]))) == chapter1) + } +} + +// MARK: - Helpers + +private func parse(_ filename: String) async throws -> Publication { + let asset: Asset = .resource(ResourceAsset( + resource: FileResource(file: Fixtures().url(for: filename)), + format: Format(specifications: .pdf, mediaType: .pdf, fileExtension: "pdf") + )) + return try await PDFParser(pdfFactory: DefaultPDFDocumentFactory()) + .parse(asset: asset, warnings: nil).get().build() +} diff --git a/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift b/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift index d7a3a9bc17..76493e004d 100644 --- a/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift +++ b/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift @@ -4,9 +4,10 @@ // available in the top-level LICENSE file of the project. // -import ReadiumShared +@_spi(ExperimentalTimeline) import ReadiumShared @testable import ReadiumStreamer import Testing +@_spi(ExperimentalTimeline) import TestSupport enum ReadiumWebPubParserTests { struct Formats { @@ -44,41 +45,104 @@ enum ReadiumWebPubParserTests { } struct TableOfContents { - /// Each rule holds its name, the media type of the reading order, the - /// `toc` of the manifest and the expected table of contents. + struct ManifestExample: Sendable, CustomTestStringConvertible { + let name: String + /// Media type of the reading order resources. + var mediaType = "audio/mpeg" + /// JSON of the `toc` of the manifest. + var toc = "[]" + var generatesTableOfContentsFromReadingOrder = true + let expectedTOC: [Link] + /// Outline of the expected timeline, `nil` for no timeline. + let expectedTimeline: [String]? + + var testDescription: String { + name + } + } + @Test(arguments: [ - ( - "audiobook without a table of contents uses the reading order titles", - "audio/mpeg", - "[]", - [Link(href: "part1", title: "Part 1", duration: 10), Link(href: "part2", title: "Part 2")] + ManifestExample( + name: "audiobook without a table of contents uses the reading order titles", + expectedTOC: [Link(href: "part1", title: "Part 1", duration: 10), Link(href: "part2", title: "Part 2")], + expectedTimeline: ["0 Part 1 → part1", "1 Part 2 → part2"] + ), + ManifestExample( + name: "audiobook keeps its table of contents", + toc: #"[{"href": "part1#t=10", "title": "Chapter 1"}]"#, + expectedTOC: [Link(href: "part1#t=10", title: "Chapter 1")], + expectedTimeline: ["0 Chapter 1 → part1#t=10"] ), - ( - "audiobook keeps its table of contents", - "audio/mpeg", - #"[{"href": "part1#t=10", "title": "Chapter 1"}]"#, - [Link(href: "part1#t=10", title: "Chapter 1")] + ManifestExample( + name: "the option off disables both fallbacks", + generatesTableOfContentsFromReadingOrder: false, + expectedTOC: [], + expectedTimeline: [] ), - ( - "other publication without a table of contents has none", - "text/html", - "[]", - [] + ManifestExample( + name: "other publication without a table of contents has none", + mediaType: "text/html", + expectedTOC: [], + expectedTimeline: [] ), - ] as [(String, String, String, [Link])]) - func usesTheReadingOrderTitlesOfAnAudiobookWithoutOne(rule: String, mediaType: String, toc: String, expected: [Link]) async throws { - let manifest = try await parsePackage(manifest: """ + ]) + func usesTheReadingOrderTitlesOfAnAudiobookWithoutOne(_ example: ManifestExample) async throws { + let publication = try await parsePackage(manifest: """ { "metadata": {"title": "Publication"}, "readingOrder": [ - {"href": "part1", "type": "\(mediaType)", "title": "Part 1", "duration": 10}, - {"href": "part2", "type": "\(mediaType)", "title": "Part 2"} + {"href": "part1", "type": "\(example.mediaType)", "title": "Part 1", "duration": 10}, + {"href": "part2", "type": "\(example.mediaType)", "title": "Part 2"} ], - "toc": \(toc) + "toc": \(example.toc) + } + """, generatesTableOfContentsFromReadingOrder: example.generatesTableOfContentsFromReadingOrder) + + #expect(publication.manifest.tableOfContents == example.expectedTOC) + #expect(try await publication.timeline()?.outline == example.expectedTimeline) + } + } + + struct Timelines { + struct ProfileExample: Sendable, CustomTestStringConvertible { + let name: String + /// Media type of the reading order resource. + let mediaType: String + /// JSON of the `conformsTo` of the manifest. + var conformsTo = "[]" + /// Whether the package is protected with LCP. + var isLCP = false + /// Type name of the expected timeline service, `nil` for none. + let expected: String? + + var testDescription: String { + name + } + } + + @Test(arguments: [ + ProfileExample(name: "audiobook", mediaType: "audio/mpeg", expected: "AudioTimelineService"), + ProfileExample(name: "HTML resources", mediaType: "text/html", expected: "WebTimelineService"), + ProfileExample( + name: "EPUB profile without HTML resources", + mediaType: "image/svg+xml", + conformsTo: #"["https://readium.org/webpub-manifest/profiles/epub"]"#, + expected: "WebTimelineService" + ), + ProfileExample(name: "SVG resources without the EPUB profile", mediaType: "image/svg+xml", expected: nil), + ProfileExample(name: "comic", mediaType: "image/png", expected: nil), + ProfileExample(name: "PDF", mediaType: "application/pdf", expected: nil), + ProfileExample(name: "LCP-protected PDF", mediaType: "application/pdf", isLCP: true, expected: "PDFTimelineService"), + ]) + func serviceDependsOnTheProfileAndResources(_ example: ProfileExample) async throws { + let publication = try await parsePackage(manifest: """ + { + "metadata": {"title": "Publication", "conformsTo": \(example.conformsTo)}, + "readingOrder": [{"href": "part1", "type": "\(example.mediaType)"}] } - """) + """, isLCP: example.isLCP) - #expect(manifest.tableOfContents == expected) + #expect(publication.findService(TimelineService.self).map { "\(type(of: $0))" } == example.expected) } } } @@ -87,18 +151,25 @@ enum ReadiumWebPubParserTests { private let fixtures = Fixtures() -private func makeParser() -> ReadiumWebPubParser { - ReadiumWebPubParser(pdfFactory: DefaultPDFDocumentFactory(), httpClient: DefaultHTTPClient()) +private func makeParser(generatesTableOfContentsFromReadingOrder: Bool = true) -> ReadiumWebPubParser { + ReadiumWebPubParser( + pdfFactory: DefaultPDFDocumentFactory(), + httpClient: DefaultHTTPClient(), + config: .init(generatesAudiobookTableOfContentsFromReadingOrder: generatesTableOfContentsFromReadingOrder) + ) } /// Parses a package holding only the given manifest. -private func parsePackage(manifest: String) async throws -> Manifest { +private func parsePackage(manifest: String, generatesTableOfContentsFromReadingOrder: Bool = true, isLCP: Bool = false) async throws -> Publication { let asset: Asset = .container(ContainerAsset( container: SingleResourceContainer( resource: DataResource(string: manifest), at: AnyURL(string: "manifest.json")! ), - format: Format(specifications: .zip, .rpf, mediaType: .readiumWebPub, fileExtension: "webpub") + format: isLCP + ? Format(specifications: .zip, .rpf, .lcp, mediaType: .lcpProtectedPDF, fileExtension: "lcpdf") + : Format(specifications: .zip, .rpf, mediaType: .readiumWebPub, fileExtension: "webpub") )) - return try await makeParser().parse(asset: asset, warnings: nil).get().build().manifest + return try await makeParser(generatesTableOfContentsFromReadingOrder: generatesTableOfContentsFromReadingOrder) + .parse(asset: asset, warnings: nil).get().build() } diff --git a/Tests/TestSupport/Timeline+Outline.swift b/Tests/TestSupport/Timeline+Outline.swift new file mode 100644 index 0000000000..32b646505e --- /dev/null +++ b/Tests/TestSupport/Timeline+Outline.swift @@ -0,0 +1,28 @@ +// +// Copyright 2026 Readium Foundation. All 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 +@_spi(ExperimentalTimeline) import ReadiumShared + +@_spi(ExperimentalTimeline) +public extension Timeline { + /// One line per item, with its id, title and HREF, indented by depth. + /// + /// An untitled item shows its number, such as `#2`. + var outline: [String] { + func lines(_ items: [TimelineItem], indent: String) -> [String] { + items.flatMap { item -> [String] in + let title = switch item.title { + case let .text(text): text + case let .number(number): "#\(number)" + } + return ["\(indent)\(item.id) \(title) → \(item.link.href)"] + + lines(item.children, indent: indent + " ") + } + } + return lines(items, indent: "") + } +} diff --git a/docs/Guides/Navigator/Audiobook.md b/docs/Guides/Navigator/Audiobook.md index 27f7be810c..ab3075e29c 100644 --- a/docs/Guides/Navigator/Audiobook.md +++ b/docs/Guides/Navigator/Audiobook.md @@ -84,7 +84,7 @@ Readium reads the audio files stored on the device, including the ones inside a ### Choosing when the audio files of a container are read -Reading the tags opens every audio file of a container (e.g. ZIP file or folder). The `containerEntriesMetadataPolicy` of the `AudioParser` tells when to do it: +Reading the tags opens every audio file of a container (e.g. ZIP file or folder). The `containerEntriesMetadataPolicy` of the `AudioParser.Configuration` tells when to do it: * `.localOnly` (default) reads only a container stored on the device. * `.always` also reads a remote container. Each audio file takes several requests, so opening the publication can last minutes with a slow server. @@ -106,7 +106,7 @@ let publicationOpener = PublicationOpener( additionalParsers: [ AudioParser( assetRetriever: assetRetriever, - containerEntriesMetadataPolicy: .always + config: .init(containerEntriesMetadataPolicy: .always) ), ] ) @@ -118,7 +118,7 @@ See the [Opening a publication guide](../Open%20Publication.md) for more informa > [!NOTE] > A container is stored on the device when its `sourceURL` is a file URL. If you build your own `Container`, for example with a `TransformingContainer`, it has no source URL and is only read with `.always`. -The audio files of a container are read three at a time. Set `maxConcurrentMetadataReads` on the `AudioParser` to change it: a larger value opens a container faster, and a smaller one uses less memory. +The audio files of a container are read three at a time. Set `maxConcurrentMetadataReads` in the `AudioParser.Configuration` to change it: a larger value opens a container faster, and a smaller one uses less memory. ### Customizing the metadata