Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion .claude/skills/write-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`?
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/Navigator/Audiobook/AudioNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import AVFoundation
import Foundation
import ReadiumShared
@_spi(ExperimentalSelectors) import ReadiumShared

/// Status of a played media resource.
public enum MediaPlaybackState: Sendable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions Sources/Shared/Publication/Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: URLConvertible>(_ href: T) -> Int? {
let href = href.anyURL.normalized

Expand All @@ -242,6 +242,7 @@ public extension [Link] {
}

return index(matchingHREF: href.string)
?? index(matchingHREF: href.removingFragment().string)
?? index(matchingHREF: href.removingQuery().removingFragment().string)
}

Expand Down
1 change: 1 addition & 0 deletions Sources/Shared/Publication/Manifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<TimeInterval>

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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Timeline, ReadError>

init(publication: Weak<Publication>, 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()
}
}
Original file line number Diff line number Diff line change
@@ -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<Int>

/// - 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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Timeline, ReadError>

init(publication: Weak<Publication>) {
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()
}
}
Loading
Loading