Skip to content
Draft
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
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,32 @@ All notable changes to this project will be documented in this file. Take a look
* The `ReadiumInternal` package has been removed. Its utilities were internal helpers and are now folded into `ReadiumShared` with `package` visibility. If you imported `ReadiumInternal` directly, remove the import.


<!-- ## [Unreleased] -->
## [Unreleased]

### Added

#### Shared

* The content of fixed-layout publications (PDF, EPUB FXL) is now re-segmented into sentences: each `TextContentElement` holds exactly one sentence, merged across printed lines, block elements and page boundaries (up to 4 pages), with de-hyphenated word cuts. Parts of a sentence found on the next line or page become extra segments marked with the new `continued` content attribute, each keeping a locator targeting its own page. Emitted elements carry the new `sentenceAligned` attribute and pass through `makeTextContentTokenizer` untouched. See the [Content guide](docs/Guides/Content.md).
* Page-boundary noise (page numbers, running headers and footers) is now detected in fixed-layout publications and emitted as standalone elements marked with the new `pageArtifact` content attribute. You can customize the detection with your own `PageArtifactDetector` implementations, passed to `DefaultContentService.makeFactory()`.
* Standalone display text in fixed-layout publications (part headings such as "P A R T O N E", "Chapter 1" lines, title pages) is now detected as a *hard break*: it forms its own element and is never merged into a surrounding sentence. Customize with your own `HardBreakDetector` implementations, passed to `DefaultContentService.makeFactory()`.
* `ContentSearchService` now skips elements marked `pageArtifact` by default, so a query spanning a fixed-layout page boundary matches even when a page number sits between the two halves of the sentence. Restore artifact searchability with the new `ignoresPageArtifacts: false` parameter of `ContentSearchService.makeFactory()`. Note that search-result locators carry the normalized *logical* text (e.g. de-hyphenated), which may differ from the on-page form.

#### Navigator

* `PublicationSpeechSynthesizer` now speaks each fixed-layout sentence as a single utterance — across printed lines, block elements and page boundaries — and skips page numbers and running headers. See the [TTS guide](docs/Guides/TTS.md).

### Changed

#### Navigator

* `PublicationSpeechSynthesizer.Utterance` gains an ordered `parts` list with per-part locators, used to render a cross-page sentence on each of its pages and to turn the page when the speech crosses the boundary (via `utterance.locator(forSpokenRange:)`). The existing `text` and `locator` properties are unchanged for single-part utterances.

### Fixed

#### Shared

* `PDFResourceContentIterator` now starts *past* the last page when given a locator with a progression of 1.0, consistently with `HTMLResourceContentIterator`. Backward iteration across the resources of a multi-PDF publication no longer skips the last page of the previous resource.

## [3.11.0] - 2026-07-17

Expand Down
14 changes: 14 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Domain glossary

Terms used throughout the toolkit's code and documentation, in particular for the fixed-layout sentence re-segmentation feature.

* **Seam** – the boundary between two adjacent fixed-layout pages in the content stream. A PDF page boundary is a seam between two `page=` fragments of the same resource; an EPUB FXL page boundary is a seam between two resources.
* **Fragment** – the atomic unit of re-segmentation: a printed line of a PDF page blob, or one segment of a fixed-layout block element. Fragments are classified as body text, page artifacts or hard breaks.
* **Re-segmentation** – decomposing raw fixed-layout elements into fragments, joining them into normalized logical text and emitting one `TextContentElement` per sentence, so that no element starts or ends mid-sentence. See `SentenceContentIterator`.
* **Page Artifact** – page-boundary noise that is not part of the reading flow: a page number, a running header or footer. Detected by `PageArtifactDetector` implementations and emitted as standalone elements marked with the `pageArtifact` content attribute (skipped by TTS and, by default, by search).
* **Hard Break** – standalone display text that is not part of any sentence: a part or chapter heading ("P A R T O N E", "Chapter 1"), a title page line. Detected by `HardBreakDetector` implementations and emitted as its own element; unlike a page artifact it is spoken and searchable, but sentences never merge across it.
* **Region** – a maximal run of body fragments between two anchors, joined into logical text and tokenized into sentences as one unit. A region spans at most 4 pages and 200 fragments.
* **Anchor** – a position forcing a sentence boundary during re-segmentation: a publication edge, a non-text neighbor, a hard break, a paragraph gap, a seam failing the bridge test, or a size cap.
* **Bridge Test** – how a seam is checked for a spanning sentence: the tail of the left page's body is joined with the head of the right page's and re-tokenized; the seam is *bridged* only when a sentence token straddles it.
* **Continuation** – a part of a per-sentence element: a `TextContentElement.Segment` marked with the `continued` attribute, carrying the portion of the sentence found on the next fragment. Its locator targets its own page, so it stays renderable there.
* **Page Identity** – what distinguishes one fixed-layout page from another in the content stream: the resource `href` plus the `page=` locator fragment.
136 changes: 117 additions & 19 deletions Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,82 @@ public final class PublicationSpeechSynthesizer: Loggable {
/// An utterance is an arbitrary text (e.g. sentence) extracted from the publication, that can be synthesized by
/// the TTS engine.
public struct Utterance: Equatable, Sendable {
/// A portion of the utterance with its own locator.
///
/// A fixed-layout sentence has one part per printed line or block
/// element it touches; a regular utterance has a single part.
public struct Part: Equatable, Sendable {
/// Text spoken for this part.
public let text: String
/// Locator to this part in the publication.
public let locator: Locator
}

/// Text to be spoken.
public let text: String
/// Locator to the utterance in the publication.
public let locator: Locator
/// Language of this utterance, if it dffers from the default publication language.
public let language: Language?
/// Ordered portions of the utterance, each with a locator targeting
/// its own page. Contains a single part for regular utterances.
public let parts: [Part]

init(parts: [Part], language: Language?) {
precondition(!parts.isEmpty)
self.parts = parts
text = parts.map(\.text).joined()
locator = parts[0].locator
self.language = language
}

/// Returns a locator to the given range of the spoken `text`,
/// narrowed inside the part containing it.
///
/// This can be used to render the word being spoken, or to turn the
/// page when the speech crosses a fixed-layout page boundary.
public func locator(forSpokenRange range: Range<String.Index>) -> Locator {
let textCount = text.utf16.count
let lower = min(max(0, range.lowerBound.utf16Offset(in: text)), textCount)
let upper = min(max(lower, range.upperBound.utf16Offset(in: text)), textCount)

var partStart = 0
for (index, part) in parts.enumerated() {
let partCount = part.text.utf16.count
let partEnd = partStart + partCount
guard lower < partEnd || index == parts.count - 1 else {
partStart = partEnd
continue
}

guard let highlight = part.locator.text.highlight else {
return part.locator
}

// The spoken text and the on-page highlight may differ
// slightly at a page seam (joining space, dropped hyphen), so
// we shift by the joining whitespace and clamp instead of
// assuming a one-to-one mapping.
let spokenLeading = part.text.prefix(while: \.isWhitespace).utf16.count
let highlightLeading = highlight.prefix(while: \.isWhitespace).utf16.count
let shift = max(0, spokenLeading - highlightLeading)
let highlightCount = highlight.utf16.count
let start = min(max(0, lower - partStart - shift), highlightCount)
let end = min(max(start, upper - partStart - shift), highlightCount)

let utf16 = highlight.utf16
guard
let startIndex = utf16.index(utf16.startIndex, offsetBy: start).samePosition(in: highlight),
let endIndex = utf16.index(utf16.startIndex, offsetBy: end).samePosition(in: highlight)
else {
return part.locator
}

return part.locator.copy(text: { $0 = $0[startIndex ..< endIndex] })
}

return locator
}
}

/// Represents a state of the `PublicationSpeechSynthesizer`.
Expand Down Expand Up @@ -281,19 +351,12 @@ public final class PublicationSpeechSynthesizer: Loggable {
return
}

// The locator is narrowed inside the part containing the
// spoken range, so that the navigator turns the page when the
// speech crosses a fixed-layout page boundary.
self.state = .playing(
utterance,
range: utterance.locator.copy(
text: { text in
guard
let highlight = text.highlight,
highlight.startIndex <= range.lowerBound, highlight.endIndex >= range.upperBound
else {
return
}
text = text[range]
}
)
range: utterance.locator(forSpokenRange: range)
)
}
)
Expand Down Expand Up @@ -379,14 +442,19 @@ public final class PublicationSpeechSynthesizer: Loggable {

/// Splits a publication `ContentElement` item into the utterances to be spoken.
private func utterances(for element: ContentElement) -> [Utterance] {
func utterance(text: String, locator: Locator, language: Language? = nil) -> Utterance? {
guard text.contains(where: { $0.isLetter || $0.isNumber }) else {
// Page artifacts (e.g. a standalone page number in a fixed-layout
// publication) are not spoken.
guard element.attribute(.pageArtifact) == nil else {
return []
}

func utterance(parts: [Utterance.Part], language: Language? = nil) -> Utterance? {
guard parts.contains(where: { $0.text.contains(where: { $0.isLetter || $0.isNumber }) }) else {
return nil
}

return Utterance(
text: text,
locator: locator,
parts: parts,
language: language
// If the language is the same as the one declared globally in the publication,
// we omit it. This way, the app can customize the default language used in the
Expand All @@ -397,16 +465,46 @@ public final class PublicationSpeechSynthesizer: Loggable {

switch element {
case let element as TextContentElement:
return element.segments
.compactMap { segment in
utterance(text: segment.text, locator: segment.locator, language: segment.language)
var utterances: [Utterance] = []
var parts: [Utterance.Part] = []
var language: Language?

func flush() {
if let utterance = utterance(parts: parts, language: language) {
utterances.append(utterance)
}
parts = []
language = nil
}

for segment in element.segments {
guard segment.attribute(.pageArtifact) == nil else {
continue
}

if let joiner = segment.attribute(.continued), !parts.isEmpty {
// The segment carries the cross-page continuation of the
// sentence started in the previous segment: absorb it
// into the current utterance as an additional part.
var text = String(segment.text.drop(while: \.isWhitespace))
if joiner == .space {
text = " " + text
}
parts.append(Utterance.Part(text: text, locator: segment.locator))
} else {
flush()
parts = [Utterance.Part(text: segment.text, locator: segment.locator)]
language = segment.language
}
}
flush()
return utterances

case let element as TextualContentElement:
guard let text = element.text.takeIf({ !$0.isEmpty }) else {
return []
}
return Array(ofNotNil: utterance(text: text, locator: element.locator))
return Array(ofNotNil: utterance(parts: [Utterance.Part(text: text, locator: element.locator)]))

default:
return []
Expand Down
7 changes: 7 additions & 0 deletions Sources/Shared/Publication/Services/Content/Content.swift
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,13 @@ public extension ContentAttributesHolder {
}

/// Iterates through a list of `ContentElement` items.
///
/// Implementations behave like a cursor sitting *between* elements: `next()`
/// returns the element to the right of the cursor and moves right, while
/// `previous()` returns the element to the left and moves left. As a
/// consequence, after `next()` returned element N, `previous()` returns
/// element N-1 (not N), and vice-versa. A call returning `nil` does not move
/// the cursor.
public protocol ContentIterator: AnyObject, Sendable {
/// Retrieves the next element, or nil if we reached the end.
func next() async throws -> ContentElement?
Expand Down
73 changes: 67 additions & 6 deletions Sources/Shared/Publication/Services/Content/ContentService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,42 +22,103 @@ public protocol ContentService: PublicationService {
public final class DefaultContentService: ContentService, Sendable {
private let publication: Weak<Publication>
private let resourceContentIteratorFactories: [ResourceContentIteratorFactory]
private let pageArtifactDetectors: [PageArtifactDetector]
private let hardBreakDetectors: [HardBreakDetector]

public init(publication: Weak<Publication>, resourceContentIteratorFactories: [ResourceContentIteratorFactory]) {
/// - Parameters:
/// - resourceContentIteratorFactories: Factories used to create the
/// iterator for each resource, tried in order until there's a match.
/// - pageArtifactDetectors: Detectors used to identify page-boundary
/// noise (page numbers, running headers) when re-segmenting
/// fixed-layout content into sentences.
/// - hardBreakDetectors: Detectors used to identify standalone display
/// text (part headings, title pages) which must never be merged into
/// a surrounding sentence.
public init(
publication: Weak<Publication>,
resourceContentIteratorFactories: [ResourceContentIteratorFactory],
pageArtifactDetectors: [PageArtifactDetector] = [PageNumberArtifactDetector(), RunningHeaderArtifactDetector()],
hardBreakDetectors: [HardBreakDetector] = [SpacedCapsHardBreakDetector(), HeadingHardBreakDetector(), StandalonePageHardBreakDetector()]
) {
self.publication = publication
self.resourceContentIteratorFactories = resourceContentIteratorFactories
self.pageArtifactDetectors = pageArtifactDetectors
self.hardBreakDetectors = hardBreakDetectors
}

public static func makeFactory(resourceContentIteratorFactories: [ResourceContentIteratorFactory]) -> (PublicationServiceContext) -> DefaultContentService? {
public static func makeFactory(
resourceContentIteratorFactories: [ResourceContentIteratorFactory],
pageArtifactDetectors: [PageArtifactDetector] = [PageNumberArtifactDetector(), RunningHeaderArtifactDetector()],
hardBreakDetectors: [HardBreakDetector] = [SpacedCapsHardBreakDetector(), HeadingHardBreakDetector(), StandalonePageHardBreakDetector()]
) -> (PublicationServiceContext) -> DefaultContentService? {
{ context in
DefaultContentService(publication: context.publication, resourceContentIteratorFactories: resourceContentIteratorFactories)
DefaultContentService(
publication: context.publication,
resourceContentIteratorFactories: resourceContentIteratorFactories,
pageArtifactDetectors: pageArtifactDetectors,
hardBreakDetectors: hardBreakDetectors
)
}
}

public func content(from start: Locator?) -> Content? {
guard let pub = publication() else {
return nil
}
return DefaultContent(publication: pub, start: start, resourceContentIteratorFactories: resourceContentIteratorFactories)
return DefaultContent(
publication: pub,
start: start,
resourceContentIteratorFactories: resourceContentIteratorFactories,
pageArtifactDetectors: pageArtifactDetectors,
hardBreakDetectors: hardBreakDetectors
)
}

private class DefaultContent: Content {
let publication: Publication
let start: Locator?
let resourceContentIteratorFactories: [ResourceContentIteratorFactory]
let pageArtifactDetectors: [PageArtifactDetector]
let hardBreakDetectors: [HardBreakDetector]

init(publication: Publication, start: Locator?, resourceContentIteratorFactories: [ResourceContentIteratorFactory]) {
init(
publication: Publication,
start: Locator?,
resourceContentIteratorFactories: [ResourceContentIteratorFactory],
pageArtifactDetectors: [PageArtifactDetector],
hardBreakDetectors: [HardBreakDetector]
) {
self.publication = publication
self.start = start
self.resourceContentIteratorFactories = resourceContentIteratorFactories
self.pageArtifactDetectors = pageArtifactDetectors
self.hardBreakDetectors = hardBreakDetectors
}

func iterator() -> ContentIterator {
PublicationContentIterator(
let iterator = PublicationContentIterator(
publication: publication,
start: start,
resourceContentIteratorFactories: resourceContentIteratorFactories
)

// Fixed-layout content is paginated by construction, cutting
// sentences between printed lines, block elements and pages;
// re-segment it so that each element holds one full sentence.
//
// Known limitation: this checks the publication-wide layout, so
// per-spine-item `rendition:layout` overrides in mixed EPUBs are
// ignored.
guard publication.metadata.layout == .fixed || publication.conforms(to: .pdf) else {
return iterator
}

return SentenceContentIterator(
iterator: iterator,
language: publication.metadata.language,
artifactDetectors: pageArtifactDetectors,
hardBreakDetectors: hardBreakDetectors
)
}
}
}
Expand Down
Loading