diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e04b26a7a..1199002f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] + +### 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 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..ff6e229325 --- /dev/null +++ b/CONTEXT.md @@ -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. diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index 0d55840915..84464cfa5b 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -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) -> 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`. @@ -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) ) } ) @@ -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 @@ -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 [] diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index 65a6a273f8..22f4acf0ea 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -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? diff --git a/Sources/Shared/Publication/Services/Content/ContentService.swift b/Sources/Shared/Publication/Services/Content/ContentService.swift index 95d39577b2..eb7967ee74 100644 --- a/Sources/Shared/Publication/Services/Content/ContentService.swift +++ b/Sources/Shared/Publication/Services/Content/ContentService.swift @@ -22,15 +22,42 @@ public protocol ContentService: PublicationService { public final class DefaultContentService: ContentService, Sendable { private let publication: Weak private let resourceContentIteratorFactories: [ResourceContentIteratorFactory] + private let pageArtifactDetectors: [PageArtifactDetector] + private let hardBreakDetectors: [HardBreakDetector] - public init(publication: Weak, 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, + 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 + ) } } @@ -38,26 +65,60 @@ public final class DefaultContentService: ContentService, Sendable { 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 + ) } } } diff --git a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift index 7445039461..cc909391af 100644 --- a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift +++ b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift @@ -40,6 +40,13 @@ public func makeTextContentTokenizer( } func tokenize(_ content: ContentElement) throws -> [ContentElement] { + // Elements produced by the `SentenceContentIterator` are already + // aligned on sentence boundaries; re-tokenizing them would replace + // the on-page `highlight` of their locators (e.g. "particu-") with + // the normalized spoken text, breaking on-page decorations. + if content.attribute(.sentenceAligned) == true { + return [content] + } if var content = content as? TextContentElement { content.segments = try content.segments.flatMap(tokenize(segment:)) return [content] diff --git a/Sources/Shared/Publication/Services/Content/HardBreakDetector.swift b/Sources/Shared/Publication/Services/Content/HardBreakDetector.swift new file mode 100644 index 0000000000..95453cb489 --- /dev/null +++ b/Sources/Shared/Publication/Services/Content/HardBreakDetector.swift @@ -0,0 +1,139 @@ +// +// 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 candidate piece of fixed-layout text which might be a hard break: +/// standalone display text (a part or chapter heading, a title page line) +/// which is not part of any sentence. +/// +/// Unlike a page artifact, a hard break is real reading-order content: it is +/// spoken by TTS and searchable. But it forms its own standalone element, and +/// sentences are never merged across it. +public struct HardBreakCandidate: Sendable { + /// Candidate text, trimmed of surrounding whitespace. + public var text: String + + /// Role of the element the candidate comes from. + public var role: TextContentElement.Role + + /// Whether the candidate is the only body text on its page (e.g. a title + /// page). + public var isWholePage: Bool + + public init( + text: String, + role: TextContentElement.Role = .body, + isWholePage: Bool = false + ) { + self.text = text + self.role = role + self.isWholePage = isWholePage + } +} + +/// Detects hard breaks in fixed-layout content: standalone display text which +/// must never be merged into a surrounding sentence. +public protocol HardBreakDetector: Sendable { + /// Returns whether `candidate` is a hard break. + func isHardBreak(_ candidate: HardBreakCandidate) -> Bool +} + +/// Detects all-caps display lines, such as "P A R T O N E", "PROLOGUE" or +/// "PART ONE". +/// +/// A candidate is a hard break when it contains no lowercase letters, has at +/// least 3 uppercase letters, is at most 60 characters long and has no +/// terminal punctuation. This excludes shouted sentences ("STOP RIGHT +/// THERE!"), mixed-case lines containing acronyms, and case-less scripts +/// (CJK). +public struct SpacedCapsHardBreakDetector: HardBreakDetector { + public init() {} + + public func isHardBreak(_ candidate: HardBreakCandidate) -> Bool { + let text = candidate.text + guard + text.count <= 60, + !text.contains(where: \.isLowercase), + text.filter(\.isUppercase).count >= 3, + !endsWithTerminalPunctuation(text) + else { + return false + } + return true + } +} + +/// Detects section headings: elements with a `heading` role (EPUB FXL), or +/// "Chapter 12"-style lines (PDF). +public struct HeadingHardBreakDetector: HardBreakDetector { + public init() {} + + public func isHardBreak(_ candidate: HardBreakCandidate) -> Bool { + if case .heading = candidate.role { + return true + } + + let text = candidate.text + guard text.count <= 60, !endsWithTerminalPunctuation(text) else { + return false + } + return text.lowercased().range( + of: "^(chapter|chapitre|part|partie|book|section|prologue|epilogue|act|scene)\\b[\\s.:]*([0-9]{1,4}|[ivxlcdm]{1,8})?$", + options: .regularExpression + ) != nil + } +} + +/// Detects short punctuation-less standalone pages, such as a title page +/// holding only "The Great Gatsby". +public struct StandalonePageHardBreakDetector: HardBreakDetector { + public init() {} + + public func isHardBreak(_ candidate: HardBreakCandidate) -> Bool { + guard candidate.isWholePage else { + return false + } + let text = candidate.text + let hyphens: Set = ["-", "\u{2010}", "\u{00AD}"] + guard + !text.isEmpty, + text.count <= 60, + !endsWithTerminalPunctuation(text), + // A page starting lowercase or ending with a word cut is the + // middle of a sentence spilling across pages, not a display page. + text.first(where: \.isLetter)?.isLowercase != true, + text.last.map({ !hyphens.contains($0) }) == true + else { + return false + } + + // Titles are title-cased ("The Lord of the Rings"); a sentence + // spilling onto the next page is not ("The story begins with"). + let words = text.split(whereSeparator: \.isWhitespace) + .filter { $0.contains(where: \.isLetter) } + guard !words.isEmpty else { + return false + } + let capitalized = words.filter { $0.first(where: \.isLetter)?.isUppercase == true } + return Double(capitalized.count) / Double(words.count) >= 0.5 + } +} + +/// Returns whether the text ends a sentence, i.e. finishes with terminal +/// punctuation, possibly followed by closing quotes or brackets. +func endsWithTerminalPunctuation(_ text: String) -> Bool { + let closers: Set = ["\"", "'", "”", "’", "»", "›", ")", "]", "}"] + let terminals: Set = [".", "!", "?", "…", "。", "!", "?"] + var text = Substring(text.trimmingCharacters(in: .whitespacesAndNewlines)) + while let last = text.last, closers.contains(last) { + text = text.dropLast() + } + guard let last = text.last else { + return false + } + return terminals.contains(last) +} diff --git a/Sources/Shared/Publication/Services/Content/Iterators/BufferedContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/BufferedContentIterator.swift new file mode 100644 index 0000000000..f5d838558f --- /dev/null +++ b/Sources/Shared/Publication/Services/Content/Iterators/BufferedContentIterator.swift @@ -0,0 +1,227 @@ +// +// 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 + +/// Wraps a `ContentIterator` to provide random relative access to the +/// elements surrounding the last returned one, without perturbing the +/// wrapped iterator's cursor semantics. +/// +/// The wrapped iterator must honor the cursor invariant documented in +/// `ContentIterator`: after `next()` returned element N, `previous()` returns +/// element N-1, and vice-versa. This class absorbs the cursor bookkeeping: +/// peeking pulls elements into a bounded buffer, and direction flips issue +/// compensating calls on the wrapped iterator to bring its cursor back in +/// sync with the buffer. +actor BufferedContentIterator { + private let inner: ContentIterator + + /// Maximum number of elements kept in the buffer. + private let capacity: Int + + /// Number of elements preserved around the last returned element when + /// trimming the buffer, so that nearby peeks don't trigger new pulls. + private let keepMargin = 2 + + /// Contiguous slice of the underlying element list. + /// + /// Elements are addressed with stable *virtual coordinates*: the first + /// element ever pulled forward is coordinate 0, elements pulled backward + /// from there get negative coordinates. + private var buffer: [ContentElement] = [] + + /// Virtual coordinate of `buffer.first`. + private var bufferStart: Int = 0 + + /// Virtual coordinate of the element the wrapped iterator last returned, + /// or `nil` if it wasn't pulled yet. + private var innerLast: Int? + + /// Virtual coordinate of the last element returned by `next()` or + /// `previous()`, or `nil` if no element was returned yet. + private var lastIndex: Int? + + /// Virtual coordinate of the start of the list, when reached. + private var startCoordinate: Int? + + /// Virtual coordinate one past the end of the list, when reached. + private var endCoordinate: Int? + + init(_ inner: ContentIterator, capacity: Int = 8) { + precondition(capacity >= 4) + self.inner = inner + self.capacity = capacity + } + + /// Advances to the next element and returns it, or `nil` when reaching + /// the end. + func next() async throws -> ContentElement? { + let target = lastIndex.map { $0 + 1 } ?? 0 + guard let element = try await element(at: target) else { + return nil + } + lastIndex = target + return element + } + + /// Moves back to the previous element and returns it, or `nil` when + /// reaching the beginning. + func previous() async throws -> ContentElement? { + let target = (lastIndex ?? 0) - 1 + guard let element = try await element(at: target) else { + return nil + } + lastIndex = target + return element + } + + /// Returns the `offset`-th element after the last returned one, without + /// moving the cursor. `peekAfter(1)` is the element `next()` would return. + func peekAfter(_ offset: Int = 1) async throws -> ContentElement? { + precondition(offset >= 1) + return try await element(at: (lastIndex ?? -1) + offset) + } + + /// Returns the `offset`-th element before the last returned one, without + /// moving the cursor. `peekBefore(1)` is the element `previous()` would + /// return. + func peekBefore(_ offset: Int = 1) async throws -> ContentElement? { + precondition(offset >= 1) + return try await element(at: (lastIndex ?? 0) - offset) + } + + /// Returns the element at the given absolute virtual `coordinate` (the + /// first element pulled forward is coordinate 0), re-centering the buffer + /// around it. + /// + /// This is an absolute-addressing alternative to the `next()`/ + /// `previous()` cursor API; the two styles should not be mixed on the + /// same instance. + func element(atCoordinate coordinate: Int) async throws -> ContentElement? { + guard let element = try await element(at: coordinate) else { + return nil + } + lastIndex = coordinate + return element + } + + // MARK: - Buffer management + + /// Returns the element at the given virtual `coordinate`, extending the + /// buffer with pulls on the wrapped iterator when needed. + private func element(at coordinate: Int) async throws -> ContentElement? { + if let start = startCoordinate, coordinate < start { + return nil + } + if let end = endCoordinate, coordinate >= end { + return nil + } + + while coordinate < bufferStart { + guard try await extendHead() else { + return nil + } + } + while coordinate >= bufferStart + buffer.count { + guard try await extendTail() else { + return nil + } + } + + return buffer[coordinate - bufferStart] + } + + /// Pulls one more element at the tail of the buffer. + /// + /// Returns `false` when the end of the list is reached. + private func extendTail() async throws -> Bool { + try await syncInner(toLast: bufferStart + buffer.count - 1) + + guard let element = try await inner.next() else { + endCoordinate = bufferStart + buffer.count + return false + } + buffer.append(element) + innerLast = bufferStart + buffer.count - 1 + trim() + return true + } + + /// Pulls one more element at the head of the buffer. + /// + /// Returns `false` when the beginning of the list is reached. + private func extendHead() async throws -> Bool { + if innerLast != nil { + try await syncInner(toLast: bufferStart) + } + + guard let element = try await inner.previous() else { + startCoordinate = bufferStart + return false + } + buffer.insert(element, at: 0) + bufferStart -= 1 + innerLast = bufferStart + trim() + return true + } + + /// Moves the wrapped iterator's cursor so that its last returned element + /// sits at the `target` coordinate, by replaying already buffered + /// elements. These compensating calls happen after a direction flip. + private func syncInner(toLast target: Int) async throws { + while (innerLast ?? -1) < target { + let expected = (innerLast ?? -1) + 1 + let element = try await inner.next() + assertMatches(element, at: expected, call: "next()") + innerLast = expected + } + while let last = innerLast, last > target { + let expected = last - 1 + let element = try await inner.previous() + assertMatches(element, at: expected, call: "previous()") + innerLast = expected + } + } + + private func assertMatches(_ element: ContentElement?, at coordinate: Int, call: String) { + let index = coordinate - bufferStart + guard buffer.indices.contains(index) else { + return + } + assert( + element?.isEqualTo(buffer[index]) == true, + "The wrapped ContentIterator does not honor the cursor invariant: a compensating \(call) returned a different element" + ) + } + + /// Drops elements far from the last returned one to bound memory usage. + /// + /// Trimmed elements can be pulled again from the wrapped iterator if + /// needed later. + private func trim() { + while buffer.count > capacity { + let frontCoordinate = bufferStart + let backCoordinate = bufferStart + buffer.count - 1 + + // The wrapped cursor replays through the buffer during + // compensation, so we never trim past it. + let canDropFront = frontCoordinate < (innerLast ?? Int.max) + && frontCoordinate < (lastIndex ?? Int.max) - keepMargin + let canDropBack = backCoordinate >= (innerLast ?? Int.min) + && backCoordinate > (lastIndex ?? Int.min) + keepMargin + + if canDropFront { + buffer.removeFirst() + bufferStart += 1 + } else if canDropBack { + buffer.removeLast() + } else { + break + } + } + } +} diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift index ff154056c7..57233c62c9 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift @@ -19,8 +19,10 @@ public enum PDFResourceContentIteratorError: Error, Sendable { /// If you want to start mid-resource, the `locator` must contain a `page=` /// fragment, `position`, or a `progression` value. /// -/// If you want to start from the end of the resource, the `locator` must have -/// a `progression` of 1.0. +/// If you want to start from the end of the resource (e.g. for backward +/// iteration), the `locator` must have a `progression` of 1.0. The iteration +/// then starts *past* the last page: `next()` returns nil and `previous()` +/// returns the last page, consistently with `HTMLResourceContentIterator`. /// /// This ``ContentIterator`` requires the ``Publication`` to have a /// ``PDFDocumentService``. @@ -176,7 +178,9 @@ public actor PDFResourceContentIterator: ContentIterator, Loggable { } else if let position = locator.locations.position { return clampPageIndex(position - positionOffset - 1) } else if locator.locations.progression == 1.0 { - return pageCount - 1 + // Past the last page, so that a backward iteration starts on the + // last page instead of skipping it. + return pageCount } else if let progression = locator.locations.progression, progression > 0 { return clampPageIndex(Int(progression * Double(pageCount))) } else { diff --git a/Sources/Shared/Publication/Services/Content/Iterators/SentenceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/SentenceContentIterator.swift new file mode 100644 index 0000000000..641e7f2b56 --- /dev/null +++ b/Sources/Shared/Publication/Services/Content/Iterators/SentenceContentIterator.swift @@ -0,0 +1,489 @@ +// +// 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 + +public extension ContentAttributeKey { + /// Marks a `TextContentElement` whose segments are already aligned on + /// sentence boundaries, produced by the `SentenceContentIterator`. + /// + /// `makeTextContentTokenizer` returns such elements untouched, preserving + /// the on-page `highlight` of their locators. + static var sentenceAligned: ContentAttributeKey { + .init("sentenceAligned") + } +} + +/// A `ContentIterator` decorator which re-segments fixed-layout content +/// (PDF, EPUB FXL) into sentences, so that each returned `TextContentElement` +/// holds exactly one sentence. +/// +/// Fixed-layout content is paginated by construction: a sentence is cut by +/// printed line breaks, by block element boundaries and by page boundaries, +/// and stray page text (page numbers, running headers) sits in the middle of +/// the reading flow. This iterator: +/// +/// - decomposes the raw elements into fragments (a printed line of a PDF page +/// blob, or one segment of an FXL block element); +/// - marks page artifacts (via `PageArtifactDetector`) and hard breaks (via +/// `HardBreakDetector`), emitting them as standalone elements; +/// - joins the remaining body fragments into normalized logical text, +/// de-hyphenating word cuts, and tokenizes it into sentences; +/// - emits one element per sentence, with one segment per fragment the +/// sentence touches. Segments after the first carry the `continued` +/// attribute, and each keeps a locator targeting its own page. +/// +/// The output is a pure function of the raw content: forward and backward +/// iteration produce the exact same stream of elements, in reverse order. +public actor SentenceContentIterator: ContentIterator, Loggable { + private let buffered: BufferedContentIterator + private let builder: SentenceRegionBuilder + + /// Maximum number of raw elements walked when fetching the head edge of a + /// neighboring multi-element (FXL) page. + private let neighborWalkCap = 32 + + public init( + iterator: ContentIterator, + language: Language? = nil, + artifactDetectors: [PageArtifactDetector] = [PageNumberArtifactDetector(), RunningHeaderArtifactDetector()], + hardBreakDetectors: [HardBreakDetector] = [SpacedCapsHardBreakDetector(), HeadingHardBreakDetector(), StandalonePageHardBreakDetector()], + textTokenizerFactory: @escaping @Sendable (Language?) -> TextTokenizer = { + makeDefaultTextTokenizer(unit: .sentence, language: $0) + } + ) { + buffered = BufferedContentIterator(iterator, capacity: 256) + builder = SentenceRegionBuilder( + language: language, + artifactDetectors: artifactDetectors, + hardBreakDetectors: hardBreakDetectors, + tokenize: textTokenizerFactory(language) + ) + } + + // MARK: - ContentIterator + + /// Output elements derived from a contiguous range of raw elements + /// bounded by anchors, so that no sentence crosses two windows. + private struct Window { + var outputs: [SentenceOutput] + var range: Range + } + + private var window: Window? + + /// Index of the last returned output in the current window, or nil when + /// the cursor sits between two outputs without having returned one on + /// this side (window entry, mid-start). + private var last: Int? + + /// When `last` is nil, the cursor sits right before this output index. + private var entryIndex = 0 + + private var initialized = false + + public func next() async throws -> ContentElement? { + try await initializeIfNeeded() + guard var current = window else { + return nil + } + var target = last.map { $0 + 1 } ?? entryIndex + while target >= current.outputs.count { + guard let nextWindow = try await window(startingAt: current.range.upperBound) else { + return nil + } + window = nextWindow + current = nextWindow + last = nil + entryIndex = 0 + target = 0 + } + last = target + return current.outputs[target].element + } + + public func previous() async throws -> ContentElement? { + try await initializeIfNeeded() + guard var current = window else { + return nil + } + var target = last.map { $0 - 1 } ?? (entryIndex - 1) + while target < 0 { + guard let previousWindow = try await window(containing: current.range.lowerBound - 1) else { + return nil + } + window = previousWindow + current = previousWindow + last = nil + entryIndex = previousWindow.outputs.count + target = previousWindow.outputs.count - 1 + } + last = target + return current.outputs[target].element + } + + /// Positions the cursor on the window containing the iteration start. + /// + /// When starting mid-publication, the cursor is placed right before the + /// first output touching the first raw element, so that `next()` returns + /// the full sentence containing the start position — even if it began on + /// a previous page — and `previous()` returns the element before it. + private func initializeIfNeeded() async throws { + guard !initialized else { + return + } + initialized = true + if try await raw(at: 0) != nil { + guard let start = try await window(containing: 0) else { + return + } + window = start + last = nil + entryIndex = start.outputs.firstIndex { $0.maxElementCoordinate >= 0 } + ?? start.outputs.count + } else if try await raw(at: -1) != nil { + // Starting at the very end of the publication, for backward + // iteration. + guard let start = try await window(containing: -1) else { + return + } + window = start + last = nil + entryIndex = start.outputs.count + } + } + + // MARK: - Raw access + + private func raw(at coordinate: Int) async throws -> ContentElement? { + try await buffered.element(atCoordinate: coordinate) + } + + // MARK: - Windows + + private var windowCache = FIFOCache(capacity: 8) + + /// Builds the window starting at the given raw coordinate, which must be + /// an anchor boundary: a run of page runs extended while the seams + /// between them are bridged by a sentence. + private func window(startingAt start: Int) async throws -> Window? { + if let cached = windowCache[start] { + return cached + } + guard let element = try await raw(at: start) else { + return nil + } + + let window: Window + if element is TextContentElement { + var runs: [PageRun] = [] + var end = start + while true { + let run = try await pageRun(startingAt: end) + runs.append(run) + end = run.end + guard let next = try await raw(at: end), next is TextContentElement else { + break + } + if try await isSeamAnchor(at: end) { + break + } + } + window = try Window(outputs: builder.outputs(for: runs), range: start ..< end) + } else { + window = Window( + outputs: [SentenceOutput( + element: element, + sortKey: .init(elementCoordinate: start, fragmentIndex: -1, charOffset: 0), + maxElementCoordinate: start + )], + range: start ..< start + 1 + ) + } + windowCache[start] = window + return window + } + + /// Returns the window containing the given raw coordinate, scanning + /// backward to its opening anchor first. + private func window(containing coordinate: Int) async throws -> Window? { + guard try await raw(at: coordinate) != nil else { + return nil + } + let start = try await windowStart(containing: coordinate) + guard let window = try await window(startingAt: start) else { + return nil + } + assert(window.range.contains(coordinate), "The window derivation is not consistent across directions") + return window + } + + /// Scans backward from `coordinate` to the nearest opening anchor: the + /// start of the publication, a non-text neighbor, or an anchored seam. + /// + /// Bounded: at most 3 consecutive seams can be non-anchored (see + /// `isSeamAnchor`), so the scan spans at most ~4 pages plus the seam + /// lookback. + private func windowStart(containing coordinate: Int) async throws -> Int { + guard let element = try await raw(at: coordinate), element is TextContentElement else { + return coordinate + } + var start = try await pageRunStart(containing: coordinate) + while true { + guard let previous = try await raw(at: start - 1), previous is TextContentElement else { + return start + } + if try await isSeamAnchor(at: start) { + return start + } + start = try await pageRunStart(containing: start - 1) + } + } + + // MARK: - Seam anchors + + private var bridgeCache = FIFOCache(capacity: 32) + + /// Returns whether the seam at the given junction forces a sentence + /// boundary. + /// + /// A seam anchors when no sentence bridges it, or — to bound the size of + /// a region — when its 3 preceding seams all raw-bridge. The cap uses raw + /// bridge statuses, never effective anchors, so it needs no recursion and + /// is direction-independent. + private func isSeamAnchor(at seam: Int) async throws -> Bool { + guard try await rawBridge(at: seam) else { + return true + } + var current = seam + for _ in 0 ..< 3 { + guard + let previous = try await previousSeam(before: current), + try await rawBridge(at: previous) + else { + return false + } + current = previous + } + return true + } + + /// Returns the coordinate of the seam preceding the given one, or nil at + /// a hard boundary (publication start, non-text element). + private func previousSeam(before seam: Int) async throws -> Int? { + let start = try await pageRunStart(containing: seam - 1) + guard let previous = try await raw(at: start - 1), previous is TextContentElement else { + return nil + } + return start + } + + /// Returns whether a sentence spans the seam at the given junction, + /// joining the two surrounding page bodies and re-tokenizing them. + private func rawBridge(at seam: Int) async throws -> Bool { + if let cached = bridgeCache[seam] { + return cached + } + let leftStart = try await pageRunStart(containing: seam - 1) + let left = try await pageRun(startingAt: leftStart) + let right = try await pageRun(startingAt: seam) + let result = try builder.rawBridge(leftFragments: left.fragments, rightFragments: right.fragments) + bridgeCache[seam] = result + return result + } + + // MARK: - Page runs + + private var runCache = FIFOCache(capacity: 16) + + /// Returns the coordinate of the first element of the page containing + /// `coordinate`. + private func pageRunStart(containing coordinate: Int) async throws -> Int { + guard + let element = try await raw(at: coordinate), + let text = element as? TextContentElement + else { + return coordinate + } + let page = PageIdentity(of: text) + var start = coordinate + while + let previous = try await raw(at: start - 1), + let previousText = previous as? TextContentElement, + PageIdentity(of: previousText) == page + { + start -= 1 + } + return start + } + + /// Assembles and classifies the fragments of the page starting at the + /// given coordinate. + private func pageRun(startingAt start: Int) async throws -> PageRun { + if let cached = runCache[start] { + return cached + } + guard let firstText = try await raw(at: start) as? TextContentElement else { + assertionFailure("pageRun(startingAt:) called on a non-text coordinate") + return PageRun(page: PageIdentity(href: "", pageFragment: nil), start: start, end: start, fragments: [], passthroughs: []) + } + let page = PageIdentity(of: firstText) + + var fragments: [Fragment] = [] + var passthroughs: [(coordinate: Int, element: ContentElement)] = [] + var end = start + while + let element = try await raw(at: end), + let text = element as? TextContentElement, + PageIdentity(of: text) == page + { + let elementFragments = builder.fragments(of: text, at: end) + if elementFragments.isEmpty { + passthroughs.append((end, element)) + } else { + fragments += elementFragments + } + end += 1 + } + + var run = PageRun( + page: page, + start: start, + end: end, + fragments: fragments, + passthroughs: passthroughs + ) + let neighbors = try await neighborEdges(around: run) + builder.classify(&run.fragments, neighbors: neighbors) + + if let width = pageProgressionWidth(of: run, firstLocator: firstText.locator) { + for i in run.fragments.indices { + run.fragments[i].progressionWidth = width + } + } + + runCache[start] = run + return run + } + + // MARK: - Neighbor context + + /// Fetches the edge texts of the pages surrounding a run, used by + /// artifact detectors requiring neighbors (running headers). + private func neighborEdges(around run: PageRun) async throws -> NeighborEdges { + var edges = NeighborEdges() + if let previous = try await pageEdgeTexts(of: run.start - 1, walkingBackward: true) { + edges.previousHead = previous.head + edges.previousTail = previous.tail + } + if let next = try await pageEdgeTexts(of: run.end, walkingBackward: false) { + edges.nextHead = next.head + edges.nextTail = next.tail + } + return edges + } + + /// Returns the head and tail edge texts of the page containing + /// `coordinate`, walking away from the adjacent run. + /// + /// The walk is capped for multi-element (FXL) pages: the far edge may be + /// unknown for very large pages, which detectors must tolerate. + private func pageEdgeTexts(of coordinate: Int, walkingBackward: Bool) async throws -> (head: [String], tail: [String])? { + guard + let element = try await raw(at: coordinate), + let text = element as? TextContentElement + else { + return nil + } + let page = PageIdentity(of: text) + if page.isPageBlob { + let lines = (text.text ?? "").components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + let max = SentenceRegionBuilder.maxArtifactsPerEdge + return (head: Array(lines.prefix(max)), tail: Array(lines.suffix(max))) + } + + var texts: [String] = [] + var reachedFarEdge = false + var next = coordinate + for _ in 0 ..< neighborWalkCap { + guard + let element = try await raw(at: next), + let text = element as? TextContentElement, + PageIdentity(of: text) == page + else { + reachedFarEdge = true + break + } + if let elementText = text.text?.trimmingCharacters(in: .whitespacesAndNewlines), !elementText.isEmpty { + texts.append(elementText) + } + next += walkingBackward ? -1 : 1 + } + + // `texts` is ordered from the near edge outward. + let max = SentenceRegionBuilder.maxArtifactsPerEdge + let near = Array(texts.prefix(max)) + let far = reachedFarEdge ? Array(texts.suffix(max).reversed()) : [] + return walkingBackward + ? (head: far, tail: near) + : (head: near, tail: far) + } + + // MARK: - PDF progressions + + /// Progression span of a PDF page bracket in its resource, derived from + /// the page number and progression of its locator. + /// + /// The resource iterator produces `progression == (N - 1) / pageCount` + /// for page N, so the bracket width is recoverable from any page but the + /// first. Single-page brackets at progression 0 are left uninterpolated. + private func pageProgressionWidth(of run: PageRun, firstLocator: Locator) -> Double? { + guard + run.page.isPageBlob, + let fragment = run.page.pageFragment, + let number = Int(fragment.dropFirst("page=".count)), + number > 1, + let progression = firstLocator.locations.progression, + progression > 0 + else { + return nil + } + return progression / Double(number - 1) + } +} + +// MARK: - FIFO cache + +/// A tiny fixed-capacity cache evicting the oldest inserted entries first. +/// +/// Purely an optimization: every cached value is a pure function of the raw +/// content, so eviction never changes the produced elements. +private struct FIFOCache { + private let capacity: Int + private var values: [Key: Value] = [:] + private var order: [Key] = [] + + init(capacity: Int) { + self.capacity = capacity + } + + subscript(key: Key) -> Value? { + get { values[key] } + set { + guard let newValue else { + return + } + if values.updateValue(newValue, forKey: key) == nil { + order.append(key) + while order.count > capacity { + values.removeValue(forKey: order.removeFirst()) + } + } + } + } +} diff --git a/Sources/Shared/Publication/Services/Content/Iterators/SentenceRegionBuilder.swift b/Sources/Shared/Publication/Services/Content/Iterators/SentenceRegionBuilder.swift new file mode 100644 index 0000000000..298a37c7f5 --- /dev/null +++ b/Sources/Shared/Publication/Services/Content/Iterators/SentenceRegionBuilder.swift @@ -0,0 +1,823 @@ +// +// 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 + +/// Identifies the fixed-layout page an element belongs to. +/// +/// Elements from different resources, or from the same resource with +/// different `page=` fragments (PDF), are on different pages. +struct PageIdentity: Hashable { + let href: String + let pageFragment: String? + + init(href: String, pageFragment: String?) { + self.href = href + self.pageFragment = pageFragment + } + + init(of element: ContentElement) { + href = element.locator.href.string + pageFragment = element.locator.locations.fragments + .first { $0.lowercased().hasPrefix("page=") }? + .lowercased() + } + + /// Whether the element holds the text of a whole page (PDF), as opposed + /// to being one of several elements on its page (FXL). + var isPageBlob: Bool { + pageFragment != nil + } +} + +/// The atomic unit of sentence re-segmentation: a printed line of a PDF page +/// blob, or one segment of a fixed-layout block element. +struct Fragment { + enum Kind { + /// Regular reading-order text, re-segmented into sentences. + case body + /// Page-boundary noise, emitted as a standalone element skipped by TTS. + case artifact(PageArtifactKind) + /// Standalone display text (heading, title page line), emitted as its + /// own element; sentences never merge across it. + case hardBreak + + var isBody: Bool { + if case .body = self { return true } else { return false } + } + + var isArtifact: Bool { + if case .artifact = self { return true } else { return false } + } + } + + var page: PageIdentity + + /// Virtual coordinate of the raw element this fragment comes from. + var elementCoordinate: Int + + /// Index of this fragment among the fragments of its element. + var indexInElement: Int + + /// Text as it appears on the page: a trimmed printed line (PDF blob) or a + /// raw segment text (FXL). + var text: String + + var sourceLocator: Locator + var elementAttributes: [ContentAttribute] + var segmentAttributes: [ContentAttribute] + var role: TextContentElement.Role + var kind: Kind = .body + + /// A blank line separates this fragment from the previous one on the + /// page, forcing a sentence boundary. + var paragraphGapBefore = false + + /// The fragment continues the previous one within the same element, with + /// the original spacing preserved (FXL segments concatenate directly). + var isContiguousWithPrevious = false + + /// Character offset of the fragment in its page blob, used to interpolate + /// PDF progressions. + var charOffsetInPage = 0 + + /// Total character count of the page blob. + var pageCharCount = 0 + + /// Progression span of the page bracket in its resource, when known. + var progressionWidth: Double? +} + +/// A contiguous run of raw elements belonging to the same fixed-layout page. +struct PageRun { + var page: PageIdentity + /// Virtual coordinate of the first raw element of the run. + var start: Int + /// Virtual coordinate past the last raw element of the run. + var end: Int + /// Classified fragments of the run, in reading order. + var fragments: [Fragment] + /// Elements of the run producing no fragments (blank text elements), + /// passed through unchanged. + var passthroughs: [(coordinate: Int, element: ContentElement)] + + /// First few fragment texts, used as neighbor context when classifying + /// the adjacent pages' artifacts. + var headEdgeTexts: [String] { + fragments.prefix(SentenceRegionBuilder.maxArtifactsPerEdge).map(\.text) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + } + + /// Last few fragment texts, used as neighbor context when classifying + /// the adjacent pages' artifacts. + var tailEdgeTexts: [String] { + fragments.suffix(SentenceRegionBuilder.maxArtifactsPerEdge).map(\.text) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + } +} + +/// Edge texts of the pages neighboring a page run, used by artifact detectors +/// requiring neighbors (running headers). +struct NeighborEdges { + var previousHead: [String] = [] + var previousTail: [String] = [] + var nextHead: [String] = [] + var nextTail: [String] = [] +} + +/// An element produced by the re-segmentation, with its position in the +/// document used to order the output stream. +struct SentenceOutput { + var element: ContentElement + + /// Position of the output's first fragment in the document. + var sortKey: SortKey + + /// Virtual coordinate of the last raw element the output touches. + var maxElementCoordinate: Int + + struct SortKey: Comparable { + var elementCoordinate: Int + var fragmentIndex: Int + /// Character offset of the sentence in its region's logical text, + /// disambiguating sentences starting in the same fragment. + var charOffset: Int + + static func < (lhs: SortKey, rhs: SortKey) -> Bool { + (lhs.elementCoordinate, lhs.fragmentIndex, lhs.charOffset) + < (rhs.elementCoordinate, rhs.fragmentIndex, rhs.charOffset) + } + } +} + +/// Pure sentence re-segmentation pipeline: decomposes raw fixed-layout +/// elements into fragments, classifies page artifacts and hard breaks, joins +/// fragments into normalized logical text and emits one `TextContentElement` +/// per sentence. +/// +/// All functions are pure: the output only depends on the given fragments and +/// neighbor context, never on the order in which pages were visited. +struct SentenceRegionBuilder { + /// Maximum number of page artifacts detected at each page edge. + static let maxArtifactsPerEdge = 3 + + /// Maximum amount of text considered on each side of a seam by the + /// bridge test. + static let seamChunkLength = 600 + + /// Maximum number of fragments in a region: a sentence boundary is forced + /// at the cap, bounding regions on pathological punctuation-less content. + static let maxRegionFragments = 200 + + /// Length of the `before` and `after` context snippets in the produced + /// locators. + static let contextSnippetLength = 50 + + let language: Language? + let artifactDetectors: [PageArtifactDetector] + let hardBreakDetectors: [HardBreakDetector] + let tokenize: TextTokenizer + + // MARK: - Fragmentation + + /// Decomposes a raw text element into fragments: one per printed line for + /// a PDF page blob, one per segment for an FXL block element. + func fragments(of element: TextContentElement, at coordinate: Int) -> [Fragment] { + let page = PageIdentity(of: element) + if page.isPageBlob { + return blobFragments(of: element, page: page, at: coordinate) + } else { + return segmentFragments(of: element, page: page, at: coordinate) + } + } + + private func blobFragments(of element: TextContentElement, page: PageIdentity, at coordinate: Int) -> [Fragment] { + let text = element.text ?? "" + let baseAttributes = element.segments.first?.attributes ?? [] + var result: [Fragment] = [] + var offset = 0 + var pendingGap = false + for line in text.components(separatedBy: "\n") { + defer { offset += line.count + 1 } + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if trimmedLine.isEmpty { + // Blank lines force a paragraph gap, but only between body + // lines: page margins encoded as leading or trailing blank + // lines must not disable seam merging. + if !result.isEmpty { + pendingGap = true + } + continue + } + result.append(Fragment( + page: page, + elementCoordinate: coordinate, + indexInElement: result.count, + text: trimmedLine, + sourceLocator: element.locator, + elementAttributes: element.attributes, + segmentAttributes: baseAttributes, + role: element.role, + paragraphGapBefore: pendingGap, + charOffsetInPage: offset + line.prefix(while: \.isWhitespace).count, + pageCharCount: text.count + )) + pendingGap = false + } + return result + } + + private func segmentFragments(of element: TextContentElement, page: PageIdentity, at coordinate: Int) -> [Fragment] { + var result: [Fragment] = [] + // Whitespace-only segments are folded into the neighboring fragment + // to preserve the element's original spacing. + var pendingBlank = "" + for segment in element.segments { + if segment.text.allSatisfy(\.isWhitespace) { + if result.isEmpty { + pendingBlank += segment.text + } else { + result[result.count - 1].text += segment.text + } + continue + } + result.append(Fragment( + page: page, + elementCoordinate: coordinate, + indexInElement: result.count, + text: pendingBlank + segment.text, + sourceLocator: segment.locator, + elementAttributes: element.attributes, + segmentAttributes: segment.attributes, + role: element.role, + isContiguousWithPrevious: !result.isEmpty + )) + pendingBlank = "" + } + return result + } + + // MARK: - Classification + + /// Marks the page artifacts sitting at the edges of a page run, then the + /// hard breaks among the remaining body fragments. + /// + /// The classification is a pure function of the run's fragments and the + /// neighboring pages' edge texts, so it is deterministic regardless of + /// the direction of traversal. + func classify(_ fragments: inout [Fragment], neighbors: NeighborEdges) { + classifyArtifacts(&fragments, neighbors: neighbors) + classifyHardBreaks(&fragments) + } + + private func classifyArtifacts(_ fragments: inout [Fragment], neighbors: NeighborEdges) { + var marked = 0 + var head = 0 + while marked < Self.maxArtifactsPerEdge, head < fragments.count { + guard let kind = detectArtifact( + in: fragments[head], + edge: .head, + previousEdgeLines: neighbors.previousHead, + nextEdgeLines: neighbors.nextHead + ) else { + break + } + fragments[head].kind = .artifact(kind) + marked += 1 + head += 1 + } + + marked = 0 + var tail = fragments.count - 1 + while marked < Self.maxArtifactsPerEdge, tail >= head { + guard let kind = detectArtifact( + in: fragments[tail], + edge: .tail, + previousEdgeLines: neighbors.previousTail, + nextEdgeLines: neighbors.nextTail + ) else { + break + } + fragments[tail].kind = .artifact(kind) + marked += 1 + tail -= 1 + } + } + + /// Runs the artifact detectors on a fragment, pairing it with each of the + /// neighboring pages' edge lines. + private func detectArtifact( + in fragment: Fragment, + edge: PageArtifactCandidate.Edge, + previousEdgeLines: [String], + nextEdgeLines: [String] + ) -> PageArtifactKind? { + let text = fragment.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + return nil + } + let scope: PageArtifactCandidate.Scope = fragment.page.isPageBlob ? .line : .element + + for detector in artifactDetectors { + if detector.requiresNeighbors { + let previous: [String?] = previousEdgeLines.isEmpty ? [nil] : previousEdgeLines + let next: [String?] = nextEdgeLines.isEmpty ? [nil] : nextEdgeLines + for previousLine in previous { + for nextLine in next { + guard previousLine != nil || nextLine != nil else { + continue + } + if let kind = detector.detectArtifact(in: PageArtifactCandidate( + text: text, + edge: edge, + scope: scope, + previousPageEdgeText: previousLine, + nextPageEdgeText: nextLine + )) { + return kind + } + } + } + } else if let kind = detector.detectArtifact(in: PageArtifactCandidate( + text: text, + edge: edge, + scope: scope, + previousPageEdgeText: previousEdgeLines.first, + nextPageEdgeText: nextEdgeLines.first + )) { + return kind + } + } + return nil + } + + private func classifyHardBreaks(_ fragments: inout [Fragment]) { + let bodyCount = fragments.count(where: \.kind.isBody) + var i = 0 + while i < fragments.count { + guard fragments[i].kind.isBody else { + i += 1 + continue + } + // FXL segments of the same element form one candidate; PDF lines + // are individual candidates. + var end = i + 1 + if !fragments[i].page.isPageBlob { + while end < fragments.count, + fragments[end].elementCoordinate == fragments[i].elementCoordinate, + fragments[end].kind.isBody + { + end += 1 + } + } + let candidate = HardBreakCandidate( + text: fragments[i ..< end].map(\.text).joined() + .trimmingCharacters(in: .whitespacesAndNewlines), + role: fragments[i].role, + isWholePage: end - i == bodyCount + ) + if hardBreakDetectors.contains(where: { $0.isHardBreak(candidate) }) { + for k in i ..< end { + fragments[k].kind = .hardBreak + } + } + i = end + } + } + + // MARK: - Bridge test + + /// Returns whether a sentence spans the seam between the two classified + /// fragment runs: the tail of the left page joined with the head of the + /// right page is re-tokenized, and the seam is bridged when a sentence + /// token straddles it. + func rawBridge(leftFragments: [Fragment], rightFragments: [Fragment]) throws -> Bool { + let left = leftFragments.filter { !$0.kind.isArtifact } + let right = rightFragments.filter { !$0.kind.isArtifact } + + var leftChunk: [Fragment] = [] + var count = 0 + for fragment in left.reversed() { + leftChunk.insert(fragment, at: 0) + count += fragment.text.count + if count >= Self.seamChunkLength { break } + } + var rightChunk: [Fragment] = [] + count = 0 + for fragment in right { + rightChunk.append(fragment) + count += fragment.text.count + if count >= Self.seamChunkLength { break } + } + guard !leftChunk.isEmpty, !rightChunk.isEmpty else { + return false + } + + let joined = join(leftChunk + rightChunk) + guard let lastLeft = joined.fragments.dropLast(rightChunk.count).last else { + return false + } + let seamIndex = lastLeft.range.upperBound + let text = joined.text + + return try tokenize(text).contains { token in + let lower = text.distance(from: text.startIndex, to: token.lowerBound) + let upper = text.distance(from: text.startIndex, to: token.upperBound) + return lower < seamIndex && upper > seamIndex + } + } + + // MARK: - Joining + + /// A fragment's contribution to a region's logical text. + struct JoinedFragment { + var fragment: Fragment + + /// Normalized text contributed to the logical text. + var logicalText: String + + /// Character range of `logicalText` within the region's logical text. + var range: Range + + /// How this fragment joins the previous one (nil for the first). + var joiner: ContentContinuationJoiner? + + /// Whether a space character was inserted before this fragment in the + /// logical text. + var insertsSpace: Bool + + /// Whether a trailing hyphen was dropped during de-hyphenation; the + /// on-page highlight keeps it. + var droppedTrailingHyphen: Bool + + /// Number of characters trimmed from the head of the on-page text. + var leadingTrimCount: Int + } + + struct JoinResult { + var text: String + var fragments: [JoinedFragment] + } + + /// Joins the fragments of a region into normalized logical text: + /// contiguous same-element segments concatenate directly, hyphenated word + /// cuts are re-joined without the hyphen, space-less scripts join + /// directly, and everything else joins with a single space. + func join(_ fragments: [Fragment]) -> JoinResult { + let hardHyphens: Set = ["-", "\u{2010}"] + let softHyphen: Character = "\u{00AD}" + + // First pass: per-fragment logical text and join decisions. Trailing + // hyphens and whitespace are resolved on the previous fragment before + // offsets are assigned. + var joined: [JoinedFragment] = [] + for fragment in fragments { + var logicalText: String + var leadingTrimCount = 0 + var joiner: ContentContinuationJoiner? + var insertsSpace = false + + // The contiguity flag only holds against the fragment's actual + // element predecessor; when it was peeled off (classified as an + // artifact or hard break), fall back to the regular joining rule. + let isContiguous = fragment.isContiguousWithPrevious + && joined.last.map { + $0.fragment.elementCoordinate == fragment.elementCoordinate + && $0.fragment.indexInElement == fragment.indexInElement - 1 + } == true + + if !joined.isEmpty, isContiguous { + logicalText = fragment.text + let boundaryHasWhitespace = + joined[joined.count - 1].logicalText.last?.isWhitespace == true + || fragment.text.first?.isWhitespace == true + joiner = boundaryHasWhitespace ? .space : .direct + } else { + (logicalText, leadingTrimCount) = trimmingHeadCounting(fragment.text) + if !joined.isEmpty { + var previous = joined[joined.count - 1] + previous.logicalText = trimmingTail(previous.logicalText) + if previous.logicalText.last == softHyphen + || (previous.logicalText.last.map { hardHyphens.contains($0) } == true + && logicalText.first?.isLowercase == true) + { + previous.logicalText.removeLast() + previous.droppedTrailingHyphen = true + joiner = .direct + } else if isSpacelessScript(effectiveLanguage(of: fragment)) { + joiner = .direct + } else { + joiner = .space + insertsSpace = true + } + joined[joined.count - 1] = previous + } + } + + joined.append(JoinedFragment( + fragment: fragment, + logicalText: logicalText, + range: 0 ..< 0, + joiner: joiner, + insertsSpace: insertsSpace, + droppedTrailingHyphen: false, + leadingTrimCount: leadingTrimCount + )) + } + if !joined.isEmpty { + joined[joined.count - 1].logicalText = trimmingTail(joined[joined.count - 1].logicalText) + } + + // Second pass: assemble the logical text and assign offsets. + var text = "" + var offset = 0 + for i in joined.indices { + if joined[i].insertsSpace { + text += " " + offset += 1 + } + let count = joined[i].logicalText.count + joined[i].range = offset ..< offset + count + text += joined[i].logicalText + offset += count + } + return JoinResult(text: text, fragments: joined) + } + + private func effectiveLanguage(of fragment: Fragment) -> Language? { + attribute(.language, in: fragment.segmentAttributes) + ?? attribute(.language, in: fragment.elementAttributes) + ?? language + } + + private func attribute(_ key: ContentAttributeKey, in attributes: [ContentAttribute]) -> T? { + attributes.first { $0.key == key.key }?.value as? T + } + + /// Whether words of the given language are not separated by spaces. + private func isSpacelessScript(_ language: Language?) -> Bool { + guard let code = language?.code.bcp47 + .split(separator: "-").first? + .lowercased() + else { + return false + } + return ["zh", "ja", "th", "km", "lo", "my"].contains(code) + } + + // MARK: - Output + + /// Produces the ordered output elements for a window of page runs: one + /// element per sentence, plus standalone artifact, hard break and + /// passthrough elements, sorted by first-fragment document position. + func outputs(for runs: [PageRun]) throws -> [SentenceOutput] { + var outputs: [SentenceOutput] = [] + for run in runs { + for (coordinate, element) in run.passthroughs { + outputs.append(SentenceOutput( + element: element, + sortKey: .init(elementCoordinate: coordinate, fragmentIndex: -1, charOffset: 0), + maxElementCoordinate: coordinate + )) + } + } + + let fragments = runs.flatMap(\.fragments) + var region: [Fragment] = [] + + func closeRegion() throws { + guard !region.isEmpty else { return } + try outputs.append(contentsOf: sentenceOutputs(for: region)) + region = [] + } + + var i = 0 + while i < fragments.count { + let fragment = fragments[i] + switch fragment.kind { + case let .artifact(kind): + outputs.append(artifactOutput(fragment, kind: kind)) + i += 1 + + case .hardBreak: + try closeRegion() + var end = i + 1 + while end < fragments.count, + fragments[end].elementCoordinate == fragment.elementCoordinate, + !fragments[end].kind.isBody, !fragments[end].kind.isArtifact + { + end += 1 + } + if let output = try hardBreakOutput(Array(fragments[i ..< end])) { + outputs.append(output) + } + i = end + + case .body: + if fragment.paragraphGapBefore || region.count >= Self.maxRegionFragments { + try closeRegion() + } + region.append(fragment) + i += 1 + } + } + try closeRegion() + + return outputs.sorted { $0.sortKey < $1.sortKey } + } + + /// Tokenizes a region into sentences and emits one element per sentence. + private func sentenceOutputs(for region: [Fragment]) throws -> [SentenceOutput] { + let joined = join(region) + let text = joined.text + var outputs: [SentenceOutput] = [] + for token in try tokenize(text) { + let lower = text.distance(from: text.startIndex, to: token.lowerBound) + let upper = text.distance(from: text.startIndex, to: token.upperBound) + guard upper > lower else { continue } + if let output = sentenceOutput(in: joined, tokenRange: lower ..< upper) { + outputs.append(output) + } + } + return outputs + } + + /// Builds the per-sentence element for a token of the region's logical + /// text: one segment per fragment the sentence touches, with the + /// normalized text as `segment.text` and the on-page form as the + /// locator's `highlight`. + private func sentenceOutput(in joined: JoinResult, tokenRange: Range) -> SentenceOutput? { + let text = joined.text + var segments: [TextContentElement.Segment] = [] + var first: JoinedFragment? + var maxCoordinate = Int.min + + for jf in joined.fragments { + guard + jf.range.upperBound > tokenRange.lowerBound, + jf.range.lowerBound < tokenRange.upperBound + else { + continue + } + let isFirst = first == nil + let partStart = isFirst + ? max(tokenRange.lowerBound, jf.range.lowerBound) + : jf.range.lowerBound - (jf.insertsSpace ? 1 : 0) + let partEnd = min(tokenRange.upperBound, jf.range.upperBound) + guard partEnd > partStart else { continue } + + // Map the logical slice back to the on-page form. + let sliceStart = max(partStart, jf.range.lowerBound) - jf.range.lowerBound + let sliceEnd = partEnd - jf.range.lowerBound + var highlightEnd = jf.leadingTrimCount + sliceEnd + if sliceEnd == jf.logicalText.count, jf.droppedTrailingHyphen { + highlightEnd += 1 + } + let highlight = substring( + jf.fragment.text, + (jf.leadingTrimCount + sliceStart) ..< min(highlightEnd, jf.fragment.text.count) + ) + let (before, after) = context(of: partStart ..< partEnd, in: text) + + var attributes = jf.fragment.segmentAttributes + if !isFirst, let joiner = jf.joiner { + attributes.append(ContentAttribute(key: .continued, value: joiner)) + } + + segments.append(TextContentElement.Segment( + locator: locator( + for: jf.fragment, + characterOffset: jf.leadingTrimCount + sliceStart, + text: Locator.Text(after: after, before: before, highlight: highlight) + ), + text: substring(text, partStart ..< partEnd), + attributes: attributes + )) + if isFirst { + first = jf + } + maxCoordinate = max(maxCoordinate, jf.fragment.elementCoordinate) + } + + guard let first, !segments.isEmpty else { + return nil + } + + let (before, after) = context(of: tokenRange, in: text) + var attributes = first.fragment.elementAttributes + attributes.append(ContentAttribute(key: .sentenceAligned, value: true)) + + let element = TextContentElement( + locator: locator( + for: first.fragment, + characterOffset: first.leadingTrimCount + max(0, tokenRange.lowerBound - first.range.lowerBound), + text: Locator.Text( + after: after, + before: before, + highlight: substring(text, tokenRange) + ) + ), + role: first.fragment.role, + segments: segments, + attributes: attributes + ) + return SentenceOutput( + element: element, + sortKey: .init( + elementCoordinate: first.fragment.elementCoordinate, + fragmentIndex: first.fragment.indexInElement, + charOffset: tokenRange.lowerBound + ), + maxElementCoordinate: maxCoordinate + ) + } + + private func artifactOutput(_ fragment: Fragment, kind: PageArtifactKind) -> SentenceOutput { + let text = fragment.text.trimmingCharacters(in: .whitespacesAndNewlines) + let attribute = ContentAttribute(key: .pageArtifact, value: kind) + let locator = locator( + for: fragment, + characterOffset: 0, + text: Locator.Text(highlight: text) + ) + let element = TextContentElement( + locator: locator, + role: fragment.role, + segments: [TextContentElement.Segment( + locator: locator, + text: text, + attributes: fragment.segmentAttributes + [attribute] + )], + attributes: fragment.elementAttributes + [attribute] + ) + return SentenceOutput( + element: element, + sortKey: .init( + elementCoordinate: fragment.elementCoordinate, + fragmentIndex: fragment.indexInElement, + charOffset: 0 + ), + maxElementCoordinate: fragment.elementCoordinate + ) + } + + /// A hard break is emitted like a single-sentence element covering all + /// its fragments, so it is spoken as one utterance and searchable. + private func hardBreakOutput(_ fragments: [Fragment]) throws -> SentenceOutput? { + let joined = join(fragments) + guard !joined.text.isEmpty else { + return nil + } + return sentenceOutput(in: joined, tokenRange: 0 ..< joined.text.count) + } + + // MARK: - Text helpers + + private func locator(for fragment: Fragment, characterOffset: Int, text: Locator.Text) -> Locator { + fragment.sourceLocator.copy( + locations: { + if let width = fragment.progressionWidth, + fragment.pageCharCount > 0, + let progression = $0.progression + { + let fraction = Double(fragment.charOffsetInPage + characterOffset) + / Double(fragment.pageCharCount) + $0.progression = min(progression + width * fraction, progression + width) + } + }, + text: { $0 = text } + ) + } + + private func context(of range: Range, in text: String) -> (before: String?, after: String?) { + let count = text.count + let before = substring(text, max(0, range.lowerBound - Self.contextSnippetLength) ..< range.lowerBound) + let after = substring(text, range.upperBound ..< min(count, range.upperBound + Self.contextSnippetLength)) + return (before.isEmpty ? nil : before, after.isEmpty ? nil : after) + } + + private func substring(_ string: String, _ range: Range) -> String { + guard range.lowerBound >= 0, range.upperBound <= string.count, !range.isEmpty else { + return "" + } + let start = string.index(string.startIndex, offsetBy: range.lowerBound) + let end = string.index(start, offsetBy: range.count) + return String(string[start ..< end]) + } + + private func trimmingHeadCounting(_ string: String) -> (String, Int) { + let trimmed = String(string.drop(while: \.isWhitespace)) + return (trimmed, string.count - trimmed.count) + } + + private func trimmingTail(_ string: String) -> String { + var string = string + while let last = string.last, last.isWhitespace { + string.removeLast() + } + return string + } +} diff --git a/Sources/Shared/Publication/Services/Content/PageArtifactDetector.swift b/Sources/Shared/Publication/Services/Content/PageArtifactDetector.swift new file mode 100644 index 0000000000..e5b65b8ef3 --- /dev/null +++ b/Sources/Shared/Publication/Services/Content/PageArtifactDetector.swift @@ -0,0 +1,220 @@ +// +// 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 + +/// Kind of page-boundary noise found in fixed-layout content. +public enum PageArtifactKind: String, Hashable, Sendable { + /// A standalone page number (e.g. "42", "- 42 -", "xii", "Page 42"). + case pageNumber + + /// A running header or footer repeated on consecutive pages (e.g. the + /// book or chapter title). + case runningHeader +} + +/// How the continuation of a cross-page sentence is joined to its first part. +public enum ContentContinuationJoiner: String, Hashable, Sendable { + /// The parts are joined with a single space (regular word boundary). + case space + + /// The parts are joined directly, without any separator (the first part + /// ended with a hyphenated word cut). + case direct +} + +public extension ContentAttributeKey { + /// Marks a `ContentElement` or `TextContentElement.Segment` as + /// page-boundary noise which should not be spoken by TTS. + static var pageArtifact: ContentAttributeKey { + .init("pageArtifact") + } + + /// Marks a segment as the cross-page continuation of the sentence started + /// in the immediately preceding segment. + /// + /// The value indicates how to join the segment to the previous one to + /// reconstruct the full sentence. + static var continued: ContentAttributeKey { + .init("continued") + } +} + +/// A candidate piece of text sitting at the edge of a fixed-layout page, +/// which might be page-boundary noise (page number, running header, etc.). +public struct PageArtifactCandidate: Sendable { + /// Edge of the page the candidate sits on. + public enum Edge: Sendable { + case head, tail + } + + /// Granularity of the candidate. + public enum Scope: Sendable { + /// A single line within a page text blob (e.g. a PDF page). + case line + + /// A whole standalone element of a page (e.g. in a fixed-layout EPUB + /// where the page number is its own element). + case element + } + + /// Candidate text, trimmed of surrounding whitespace. + public var text: String + + /// Edge of the page the candidate sits on. + public var edge: Edge + + /// Granularity of the candidate. + public var scope: Scope + + /// Text found at the same edge of the previous page, if known. + /// + /// `nil` at the edges of the publication or when the neighboring page has + /// not been observed yet; detectors must tolerate its absence. + public var previousPageEdgeText: String? + + /// Text found at the same edge of the next page, if known. + public var nextPageEdgeText: String? + + public init( + text: String, + edge: Edge, + scope: Scope, + previousPageEdgeText: String? = nil, + nextPageEdgeText: String? = nil + ) { + self.text = text + self.edge = edge + self.scope = scope + self.previousPageEdgeText = previousPageEdgeText + self.nextPageEdgeText = nextPageEdgeText + } +} + +/// Detects page-boundary noise (page numbers, running headers) in +/// fixed-layout content. +public protocol PageArtifactDetector: Sendable { + /// Whether this detector needs the neighboring pages' edge text to work. + /// + /// Neighbor-free detectors can run early, before any lookahead of the next + /// page is available. + var requiresNeighbors: Bool { get } + + /// Returns the kind of artifact `candidate` is, or `nil` if it looks like + /// regular content. + func detectArtifact(in candidate: PageArtifactCandidate) -> PageArtifactKind? +} + +/// Detects standalone page numbers, without needing the neighboring pages. +/// +/// Recognizes arabic numbers (up to 4 digits), roman numerals (up to 8 +/// characters) and common decorations such as "- 42 -", "Page 42" or +/// "42 / 300". +public struct PageNumberArtifactDetector: PageArtifactDetector { + public init() {} + + public var requiresNeighbors: Bool { false } + + public func detectArtifact(in candidate: PageArtifactCandidate) -> PageArtifactKind? { + isPageNumber(candidate.text) ? .pageNumber : nil + } + + private func isPageNumber(_ text: String) -> Bool { + var text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, text.count <= 24 else { + return false + } + + // Strip symmetric decorations, e.g. "- 42 -", "— 42 —", "[42]", "(42)". + text = text.trimmingCharacters(in: CharacterSet(charactersIn: "-–—•·*[]()|. \u{00A0}")) + + // "Page 42", "page 42", "p. 42", "P42". + for prefix in ["page", "p."] { + if text.lowercased().hasPrefix(prefix) { + text = String(text.dropFirst(prefix.count)) + .trimmingCharacters(in: .whitespaces) + } + } + + // "42 / 300", "42 of 300". + for separator in ["/", " of "] { + if let range = text.range(of: separator) { + let lhs = text[.. Bool { + guard !text.isEmpty else { + return false + } + + if text.count <= 4, text.allSatisfy({ $0.isASCII && $0.isNumber }) { + return true + } + + return isRomanNumeral(text) + } + + private func isRomanNumeral(_ text: String) -> Bool { + // Roman page numbers are consistently cased ("xii" or "XII"), which + // rules out regular words such as "Mix" or "Civil". + guard + text.count <= 8, + text == text.lowercased() || text == text.uppercased() + else { + return false + } + + // Validates the numeral structure, to avoid flagging words made of + // roman digits only ("civil", "did", "mild"). + return text.lowercased().range( + of: "^m{0,4}(cm|cd|d?c{0,3})(xc|xl|l?x{0,3})(ix|iv|v?i{0,3})$", + options: .regularExpression + ) != nil && !text.isEmpty + } +} + +/// Detects running headers and footers by comparing a page edge with the same +/// edge of the neighboring pages. +/// +/// A candidate is considered a running header when its normalized text (case +/// folded, digits and punctuation stripped) is non-trivial and matches the +/// neighboring page's. +public struct RunningHeaderArtifactDetector: PageArtifactDetector { + /// Minimum length of the normalized text to consider a match, to avoid + /// false positives on very short lines. + private let minimumLength: Int + + public init(minimumLength: Int = 4) { + self.minimumLength = minimumLength + } + + public var requiresNeighbors: Bool { true } + + public func detectArtifact(in candidate: PageArtifactCandidate) -> PageArtifactKind? { + let text = normalize(candidate.text) + guard text.count >= minimumLength else { + return nil + } + + let neighbors = [candidate.previousPageEdgeText, candidate.nextPageEdgeText] + .compactMap { $0.map(normalize) } + + return neighbors.contains(text) ? .runningHeader : nil + } + + private func normalize(_ text: String) -> String { + text.lowercased() + .filter { $0.isLetter || $0.isWhitespace } + .coalescingWhitespaces() + .trimmingCharacters(in: .whitespaces) + } +} diff --git a/Sources/Shared/Publication/Services/Search/ContentSearchService.swift b/Sources/Shared/Publication/Services/Search/ContentSearchService.swift index 9333a43bd4..c6f614af63 100644 --- a/Sources/Shared/Publication/Services/Search/ContentSearchService.swift +++ b/Sources/Shared/Publication/Services/Search/ContentSearchService.swift @@ -30,16 +30,23 @@ public final class ContentSearchService: SearchService, Loggable { /// snippets in the returned locators. /// - searchAlgorithm: Implements the actual search algorithm in the /// sanitized text. + /// - ignoresPageArtifacts: When enabled (the default), elements marked + /// with the `pageArtifact` attribute (page numbers, running headers + /// in fixed-layout publications) are excluded from the searched text. + /// This lets queries match sentences spanning a page boundary, at the + /// cost of not finding the artifacts themselves. public static func makeFactory( snippetLength: Int = 200, - searchAlgorithm: StringSearchAlgorithm = BasicStringSearchAlgorithm() + searchAlgorithm: StringSearchAlgorithm = BasicStringSearchAlgorithm(), + ignoresPageArtifacts: Bool = true ) -> @Sendable (PublicationServiceContext) -> ContentSearchService? { { context in ContentSearchService( publication: context.publication, language: context.manifest.metadata.language, snippetLength: snippetLength, - searchAlgorithm: searchAlgorithm + searchAlgorithm: searchAlgorithm, + ignoresPageArtifacts: ignoresPageArtifacts ) } } @@ -50,17 +57,20 @@ public final class ContentSearchService: SearchService, Loggable { private let language: Language? private let snippetLength: Int private let searchAlgorithm: StringSearchAlgorithm + private let ignoresPageArtifacts: Bool public init( publication: Weak, language: Language?, snippetLength: Int, - searchAlgorithm: StringSearchAlgorithm + searchAlgorithm: StringSearchAlgorithm, + ignoresPageArtifacts: Bool = true ) { self.publication = publication self.language = language self.snippetLength = snippetLength self.searchAlgorithm = searchAlgorithm + self.ignoresPageArtifacts = ignoresPageArtifacts var options = searchAlgorithm.options options.language = language ?? Language.current @@ -78,7 +88,8 @@ public final class ContentSearchService: SearchService, Loggable { snippetLength: snippetLength, searchAlgorithm: searchAlgorithm, query: query, - options: options + options: options, + ignoresPageArtifacts: ignoresPageArtifacts )) } } @@ -111,6 +122,7 @@ private actor Iterator: SearchIterator, Loggable { private let query: String private let options: SearchOptions private let currentLanguage: Language? + private let ignoresPageArtifacts: Bool /// Danger-zone capacity used for regex queries (heuristic). private static let regexTailCapacity = 256 @@ -180,7 +192,8 @@ private actor Iterator: SearchIterator, Loggable { snippetLength: Int, searchAlgorithm: StringSearchAlgorithm, query: String, - options: SearchOptions? + options: SearchOptions?, + ignoresPageArtifacts: Bool ) { let options = options ?? SearchOptions() @@ -189,6 +202,7 @@ private actor Iterator: SearchIterator, Loggable { self.searchAlgorithm = searchAlgorithm self.query = query self.options = options + self.ignoresPageArtifacts = ignoresPageArtifacts currentLanguage = options.language ?? language tailCapacity = (options.regularExpression ?? false) ? Iterator.regexTailCapacity @@ -205,7 +219,8 @@ private actor Iterator: SearchIterator, Loggable { guard let textElement = element as? TextContentElement, - !textElement.segments.isEmpty + !textElement.segments.isEmpty, + isSearchable(textElement) else { continue } @@ -284,6 +299,16 @@ private actor Iterator: SearchIterator, Loggable { return await rawNextElement() } + /// Returns whether the element contributes to the searched text. + /// + /// Page artifacts (page numbers, running headers in fixed-layout + /// publications) are excluded by default: keeping them in the sliding + /// window would break queries spanning a page boundary, since they sit + /// between the two halves of a cross-page sentence. + private func isSearchable(_ element: ContentElement) -> Bool { + !ignoresPageArtifacts || element.attribute(.pageArtifact) == nil + } + /// Advances the ContentIterator, returning `nil` only on exhaustion. /// On error, logs the warning and retries so that a single failing element /// does not truncate the rest of the search results. @@ -353,7 +378,7 @@ private actor Iterator: SearchIterator, Loggable { // beyond searchCeiling and same-resource text in lookaheadBuffer. var textCount = max(0, windowTextCount - searchCeiling) for el in lookaheadBuffer { - guard let textEl = el as? TextContentElement, !textEl.segments.isEmpty else { continue } + guard let textEl = el as? TextContentElement, !textEl.segments.isEmpty, isSearchable(textEl) else { continue } guard textEl.locator.href == currentHREF else { break } textCount += textEl.text?.count ?? 0 } @@ -364,19 +389,20 @@ private actor Iterator: SearchIterator, Loggable { guard !Task.isCancelled else { break } guard let el = await rawNextElement() else { break } lookaheadBuffer.append(el) - guard let textEl = el as? TextContentElement, !textEl.segments.isEmpty else { continue } + guard let textEl = el as? TextContentElement, !textEl.segments.isEmpty, isSearchable(textEl) else { continue } guard textEl.locator.href == currentHREF else { break } textCount += textEl.text?.count ?? 0 } // Append same-resource lookahead elements to the window (they become // the lookahead slice — beyond searchCeiling, not searched yet). - // Non-text elements and stale old-resource elements are skipped in-place - // so they remain in the buffer for nextElement() to return in order. + // Non-text elements, page artifacts and stale old-resource elements + // are skipped in-place so they remain in the buffer for nextElement() + // to return in order. var i = lookaheadBuffer.startIndex while i < lookaheadBuffer.endIndex { let el = lookaheadBuffer[i] - guard let textEl = el as? TextContentElement, !textEl.segments.isEmpty else { + guard let textEl = el as? TextContentElement, !textEl.segments.isEmpty, isSearchable(textEl) else { i += 1 continue } @@ -619,7 +645,7 @@ private actor Iterator: SearchIterator, Loggable { // Trim trailing whitespace if we're at resource end (no more same- // resource elements in the lookahead buffer). let hasMoreSameResource = lookaheadBuffer.contains { el in - guard let textEl = el as? TextContentElement, !textEl.segments.isEmpty else { return false } + guard let textEl = el as? TextContentElement, !textEl.segments.isEmpty, isSearchable(textEl) else { return false } return textEl.locator.href == currentHREF } diff --git a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift index ef44cae87a..c33c60e8d3 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift @@ -47,7 +47,7 @@ final class TTSViewModel: ObservableObject, Loggable { private let navigator: Navigator private let synthesizer: PublicationSpeechSynthesizer - @Published private var playingUtterance: Locator? + @Published private var playingUtteranceParts: [PublicationSpeechSynthesizer.Utterance.Part]? private let playingWordRangeSubject = PassthroughSubject() private var isMoving = false @@ -65,19 +65,21 @@ final class TTSViewModel: ObservableObject, Loggable { synthesizer.delegate = self - // Highlight the currently spoken utterance. + // Highlight the currently spoken utterance, with one decoration per + // part: a fixed-layout sentence spanning several lines or pages keeps + // a highlight on each of them. if let navigator = navigator as? DecorableNavigator { - $playingUtterance + $playingUtteranceParts .removeDuplicates() - .sink { locator in - var decorations: [Decoration] = [] - if let locator = locator { - decorations.append(Decoration( - id: "tts-utterance", - locator: locator, - style: .highlight(tint: .red) - )) - } + .sink { parts in + let decorations: [Decoration] = (parts ?? []).enumerated() + .map { index, part in + Decoration( + id: "tts-utterance-\(index)", + locator: part.locator, + style: .highlight(tint: .red) + ) + } navigator.apply(decorations: decorations, in: "tts") } .store(in: &subscriptions) @@ -181,13 +183,13 @@ extension TTSViewModel: PublicationSpeechSynthesizerDelegate { case .stopped: state.showControls = false state.isPlaying = false - playingUtterance = nil + playingUtteranceParts = nil clearNowPlaying() case let .playing(utterance, range: wordRange): state.showControls = true state.isPlaying = true - playingUtterance = utterance.locator + playingUtteranceParts = utterance.parts if let wordRange = wordRange { playingWordRangeSubject.send(wordRange) } @@ -195,7 +197,7 @@ extension TTSViewModel: PublicationSpeechSynthesizerDelegate { case let .paused(utterance): state.showControls = true state.isPlaying = false - playingUtterance = utterance.locator + playingUtteranceParts = utterance.parts } } diff --git a/Tests/NavigatorTests/TTS/PublicationSpeechSynthesizerUtteranceTests.swift b/Tests/NavigatorTests/TTS/PublicationSpeechSynthesizerUtteranceTests.swift new file mode 100644 index 0000000000..d02fc49f42 --- /dev/null +++ b/Tests/NavigatorTests/TTS/PublicationSpeechSynthesizerUtteranceTests.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. +// + +import Foundation +@testable import ReadiumNavigator +import ReadiumShared +import Testing + +struct PublicationSpeechSynthesizerUtteranceTests { + /// A sentence hyphenated across two PDF pages: "particu-" / "larly". + private let utterance = PublicationSpeechSynthesizer.Utterance( + parts: [ + .init(text: "There is a particu", locator: locator(page: 1, highlight: "There is a particu-")), + .init(text: "larly comfortable hotel.", locator: locator(page: 2, highlight: "larly comfortable hotel.")), + ], + language: nil + ) + + @Test func textAndLocatorAreDerivedFromParts() { + #expect(utterance.text == "There is a particularly comfortable hotel.") + #expect(utterance.locator.locations.fragments == ["page=1"]) + } + + @Test func spokenRangeInFirstPartUsesItsLocator() throws { + let range = try #require(utterance.text.range(of: "There")) + let locator = utterance.locator(forSpokenRange: range) + #expect(locator.locations.fragments == ["page=1"]) + #expect(locator.text.highlight == "There") + } + + @Test func spokenRangeInSecondPartTurnsToItsPage() throws { + let range = try #require(utterance.text.range(of: "comfortable")) + let locator = utterance.locator(forSpokenRange: range) + #expect(locator.locations.fragments == ["page=2"]) + #expect(locator.text.highlight == "comfortable") + } + + @Test func spaceJoinedContinuationShiftsIntoTheHighlight() throws { + // The continuation's spoken text carries a joining space which is not + // part of the on-page highlight. + let utterance = PublicationSpeechSynthesizer.Utterance( + parts: [ + .init(text: "The hungry cat sat on", locator: locator(page: 1, highlight: "The hungry cat sat on")), + .init(text: " the mat with style.", locator: locator(page: 2, highlight: "the mat with style.")), + ], + language: nil + ) + let range = try #require(utterance.text.range(of: "mat")) + let result = utterance.locator(forSpokenRange: range) + #expect(result.locations.fragments == ["page=2"]) + #expect(result.text.highlight == "mat") + } + + @Test func rangeSpanningTheSeamClampsIntoTheFirstPart() throws { + // "particularly" straddles the two parts; the locator maps to the + // part containing the start of the range, clamped to its on-page + // highlight (which keeps the hyphen). + let range = try #require(utterance.text.range(of: "particularly")) + let result = utterance.locator(forSpokenRange: range) + #expect(result.locations.fragments == ["page=1"]) + #expect(result.text.highlight == "particu-") + } + + @Test func singlePartIsTheDegenerateCase() { + let utterance = PublicationSpeechSynthesizer.Utterance( + parts: [.init(text: "A sentence.", locator: locator(page: 1, highlight: "A sentence."))], + language: nil + ) + #expect(utterance.text == "A sentence.") + #expect(utterance.parts.count == 1) + } +} + +private func locator(page: Int, highlight: String) -> Locator { + Locator(href: AnyURL(string: "book.pdf")!, mediaType: .pdf).copy( + locations: { $0.fragments = ["page=\(page)"] }, + text: { $0 = Locator.Text(highlight: highlight) } + ) +} diff --git a/Tests/SharedTests/Publication/Services/Content/ContentTokenizerTests.swift b/Tests/SharedTests/Publication/Services/Content/ContentTokenizerTests.swift new file mode 100644 index 0000000000..00fb0499b8 --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Content/ContentTokenizerTests.swift @@ -0,0 +1,71 @@ +// +// 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. +// + +@testable import ReadiumShared +import Testing + +struct ContentTokenizerTests { + private let tokenizer = makeTextContentTokenizer( + defaultLanguage: Language(code: .bcp47("en")), + textTokenizerFactory: { language in + makeDefaultTextTokenizer(unit: .sentence, language: language) + } + ) + + @Test func sentenceAlignedElementPassesThroughUntouched() throws { + // An element produced by the `SentenceContentIterator`: already one + // sentence, with an on-page highlight differing from the spoken text. + let locator = Locator(href: "book.pdf", mediaType: .pdf).copy(text: { + $0 = Locator.Text(highlight: "There is a particu-") + }) + let element = TextContentElement( + locator: locator, + role: .body, + segments: [ + TextContentElement.Segment(locator: locator, text: "There is a particu"), + TextContentElement.Segment( + locator: locator, + text: "larly comfortable hotel.", + attributes: [ContentAttribute(key: .continued, value: ContentContinuationJoiner.direct)] + ), + ], + attributes: [ContentAttribute(key: .sentenceAligned, value: true)] + ) + + let result = try tokenizer(element) + try #require(result.count == 1) + let passedThrough = try #require(result[0] as? TextContentElement) + #expect(passedThrough == element) + // The on-page highlight is preserved. + #expect(passedThrough.segments[0].locator.text.highlight == "There is a particu-") + } + + @Test func reflowableElementIsStillTokenizedIntoSentences() throws { + let locator = Locator(href: "chapter.xhtml", mediaType: .xhtml) + let element = TextContentElement( + locator: locator, + role: .body, + segments: [ + TextContentElement.Segment(locator: locator, text: "One sentence here. Two sentences there."), + ] + ) + + let result = try tokenizer(element) + try #require(result.count == 1) + let tokenized = try #require(result[0] as? TextContentElement) + try #require(tokenized.segments.count == 2) + + #expect(tokenized.segments[0].text == "One sentence here.") + #expect(tokenized.segments[0].locator.text.highlight == "One sentence here.") + #expect(tokenized.segments[0].locator.text.before == nil) + #expect(tokenized.segments[0].locator.text.after == " Two sentences there.") + + #expect(tokenized.segments[1].text == "Two sentences there.") + #expect(tokenized.segments[1].locator.text.highlight == "Two sentences there.") + #expect(tokenized.segments[1].locator.text.before == "One sentence here. ") + #expect(tokenized.segments[1].locator.text.after == nil) + } +} diff --git a/Tests/SharedTests/Publication/Services/Content/DefaultContentServiceTests.swift b/Tests/SharedTests/Publication/Services/Content/DefaultContentServiceTests.swift new file mode 100644 index 0000000000..08550999ab --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Content/DefaultContentServiceTests.swift @@ -0,0 +1,44 @@ +// +// 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 ReadiumShared +import Testing + +struct DefaultContentServiceTests { + @Test func reflowablePublicationUsesPlainIterator() throws { + let publication = makePublication(layout: nil) + let iterator = try makeIterator(for: publication) + #expect(!(iterator is SentenceContentIterator)) + } + + @Test func fixedLayoutPublicationUsesSentenceResegmentation() throws { + let publication = makePublication(layout: .fixed) + let iterator = try makeIterator(for: publication) + #expect(iterator is SentenceContentIterator) + } + + @Test func pdfPublicationUsesSentenceResegmentation() throws { + let publication = makePublication(layout: nil, mediaType: .pdf) + let iterator = try makeIterator(for: publication) + #expect(iterator is SentenceContentIterator) + } + + private func makePublication(layout: Layout?, mediaType: MediaType = .xhtml) -> Publication { + Publication(manifest: Manifest( + metadata: Metadata(title: "Publication", layout: layout), + readingOrder: [Link(href: "chapter1", mediaType: mediaType)] + )) + } + + private func makeIterator(for publication: Publication) throws -> ContentIterator { + let service = DefaultContentService( + publication: Weak(publication), + resourceContentIteratorFactories: [] + ) + let content = try #require(service.content(from: nil)) + return content.iterator() + } +} diff --git a/Tests/SharedTests/Publication/Services/Content/HardBreakDetectorTests.swift b/Tests/SharedTests/Publication/Services/Content/HardBreakDetectorTests.swift new file mode 100644 index 0000000000..fba7f73e42 --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Content/HardBreakDetectorTests.swift @@ -0,0 +1,145 @@ +// +// 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. +// + +@testable import ReadiumShared +import Testing + +enum HardBreakDetectorTests { + struct SpacedCaps { + private let detector = SpacedCapsHardBreakDetector() + + @Test( + "All-caps display lines break", + arguments: [ + "P A R T O N E", + "PROLOGUE", + "PART ONE", + "THE END", + "CHAPTER 12", + ] + ) + func breaks(text: String) { + #expect(detector.isHardBreak(HardBreakCandidate(text: text))) + } + + @Test( + "Regular text does not break", + arguments: [ + // Terminal punctuation: a shouted sentence, not a heading. + "STOP RIGHT THERE!", + "IT WAS OVER.", + // Mixed-case lines containing acronyms. + "NASA launched the rocket", + "He works at the FBI", + // Fewer than 3 uppercase letters: page numbers, initials. + "42", + "IV", + "A B", + // Case-less scripts have no lowercase letters either. + "これは長い文章で", + // Longer than 60 characters. + String(repeating: "A", count: 61), + ] + ) + func doesNotBreak(text: String) { + #expect(!detector.isHardBreak(HardBreakCandidate(text: text))) + } + } + + struct Heading { + private let detector = HeadingHardBreakDetector() + + @Test( + "Chapter-style lines break", + arguments: [ + "Chapter 1", + "Chapter 12", + "chapter 3", + "Part II", + "Section 4", + "Prologue", + "Epilogue", + "Act 2", + "Scene IV", + ] + ) + func breaks(text: String) { + #expect(detector.isHardBreak(HardBreakCandidate(text: text))) + } + + @Test( + "Regular text does not break", + arguments: [ + // A sentence starting with a heading word. + "Part of the reason was obvious", + "Chapter after chapter went by", + "Act now", + // Terminal punctuation. + "Chapter 1.", + "The chapter ends here.", + ] + ) + func doesNotBreak(text: String) { + #expect(!detector.isHardBreak(HardBreakCandidate(text: text))) + } + + @Test func headingRoleAlwaysBreaks() { + #expect(detector.isHardBreak(HardBreakCandidate( + text: "There Lived a Dragon", + role: .heading(level: 1) + ))) + } + + @Test func bodyRoleDoesNotBreakOnItsOwn() { + #expect(!detector.isHardBreak(HardBreakCandidate( + text: "There Lived a Dragon", + role: .body + ))) + } + } + + struct StandalonePage { + private let detector = StandalonePageHardBreakDetector() + + @Test( + "Short title-cased standalone pages break", + arguments: [ + "The Great Gatsby", + "The Lord of the Rings", + "Bella", + ] + ) + func breaks(text: String) { + #expect(detector.isHardBreak(HardBreakCandidate(text: text, isWholePage: true))) + } + + @Test func requiresWholePage() { + #expect(!detector.isHardBreak(HardBreakCandidate( + text: "The Great Gatsby", + isWholePage: false + ))) + } + + @Test( + "Mid-sentence or regular pages do not break", + arguments: [ + // Terminal punctuation: a regular short page. + "It was over.", + // Starts lowercase: continuation of a sentence from the + // previous page. + "and only reachable by boat", + // Ends with a word cut. + "There is a particu-", + // Not title-cased: a sentence spilling onto the next page. + "The story begins with something very", + "The hungry cat sat on", + ] + ) + func doesNotBreak(text: String) { + #expect(!detector.isHardBreak(HardBreakCandidate(text: text, isWholePage: true))) + } + } +} diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/BufferedContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/BufferedContentIteratorTests.swift new file mode 100644 index 0000000000..238f176322 --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/BufferedContentIteratorTests.swift @@ -0,0 +1,131 @@ +// +// 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. +// + +@testable import ReadiumShared +import Testing + +struct BufferedContentIteratorTests { + private let elements: [ContentElement] = ["A", "B", "C", "D"].map(element(_:)) + + @Test func forwardIterationPullsEachElementOnce() async throws { + let (iter, mock) = makeIterator(elements) + + for expected in elements { + let result = try await iter.next() + #expect(result?.equatable() == expected.equatable()) + } + #expect(try await iter.next() == nil) + #expect(await mock.nextCallCount == 5) + #expect(await mock.previousCallCount == 0) + } + + @Test func previousIsNilFromTheBeginning() async throws { + let (iter, _) = makeIterator(elements) + #expect(try await iter.previous() == nil) + } + + @Test func nextThenPreviousReturnsNil() async throws { + // Mirrors the resource iterators' cursor invariant: after `next()` + // returned element N, `previous()` returns element N-1. + let (iter, _) = makeIterator(elements) + _ = try await iter.next() + #expect(try await iter.previous() == nil) + let second = try await iter.next() + #expect(second?.equatable() == elements[1].equatable()) + } + + @Test func peekingDoesNotMoveTheCursor() async throws { + let (iter, mock) = makeIterator(elements) + _ = try await iter.next() // A + + let peeked = try await iter.peekAfter(1) + #expect(peeked?.equatable() == elements[1].equatable()) + #expect(await mock.nextCallCount == 2) + + // The peeked element is served from the buffer. + let second = try await iter.next() + #expect(second?.equatable() == elements[1].equatable()) + #expect(await mock.nextCallCount == 2) + + let before = try await iter.peekBefore(1) + #expect(before?.equatable() == elements[0].equatable()) + #expect(await mock.previousCallCount == 0) + } + + @Test func directionFlipIssuesCompensatingCalls() async throws { + let (iter, mock) = makeIterator(elements) + _ = try await iter.next() // A + _ = try await iter.next() // B + _ = try await iter.peekAfter(1) // C, moves the inner cursor to C + #expect(await mock.nextCallCount == 3) + + // Served from the buffer, no inner call. + let back = try await iter.previous() + #expect(back?.equatable() == elements[0].equatable()) + #expect(await mock.previousCallCount == 0) + + // Walking before A: the inner cursor sits on C, so two compensating + // `previous()` replay B and A before the final pull returns nil. + #expect(try await iter.previous() == nil) + #expect(await mock.previousCallCount == 3) + #expect(await mock.nextCallCount == 3) + + // Forward again, from the buffer. + let second = try await iter.next() + #expect(second?.equatable() == elements[1].equatable()) + #expect(await mock.nextCallCount == 3) + } + + @Test func peeksPastTheEndAreNil() async throws { + let (iter, mock) = makeIterator(elements) + for _ in elements { + _ = try await iter.next() + } + #expect(try await iter.peekAfter(1) == nil) + #expect(try await iter.peekAfter(2) == nil) + // The end is memoized: no extra pull for the second peek. + #expect(await mock.nextCallCount == 5) + } + + @Test func trimmedElementsAreRefetched() async throws { + let manyElements = (0 ..< 20).map { element("\($0)") } + let (iter, _) = makeIterator(manyElements, capacity: 4) + + for expected in manyElements { + let result = try await iter.next() + #expect(result?.equatable() == expected.equatable()) + } + + // Walk all the way back: elements trimmed from the buffer are pulled + // again from the wrapped iterator. + var backward: [AnyEquatableContentElement] = [] + while let element = try await iter.previous() { + backward.append(element.equatable()) + } + #expect(backward == manyElements.dropLast().reversed().map { $0.equatable() }) + } +} + +// MARK: - Helpers + +private func element(_ text: String) -> TextContentElement { + let locator = Locator(href: "res.xhtml", mediaType: .xhtml).copy( + text: { $0 = Locator.Text(highlight: text) } + ) + return TextContentElement( + locator: locator, + role: .body, + segments: [TextContentElement.Segment(locator: locator, text: text)] + ) +} + +private func makeIterator( + _ elements: [ContentElement], + capacity: Int = 8 +) -> (BufferedContentIterator, MockContentIterator) { + let mock = MockContentIterator(elements) + return (BufferedContentIterator(mock, capacity: capacity), mock) +} diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift index 34b91c2f6f..815ba2e0e7 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift @@ -50,14 +50,13 @@ enum PDFResourceContentIteratorTests { @Test func iterateFullyBackwardFromEnd() async throws { let iter = makeIterator(start: makeLocator(progression: 1.0)) - _ = try await iter.next() // Position at last element var backwardElements: [AnyEquatableContentElement] = [] while let element = try await iter.previous() { backwardElements.append(element.equatable()) } - #expect(backwardElements == sampleElements.dropLast().reversed()) + #expect(backwardElements == sampleElements.reversed()) } } @@ -80,11 +79,12 @@ enum PDFResourceContentIteratorTests { } @Test func startingFromEndProgression() async throws { + // A progression of 1.0 starts *past* the last page: nothing comes + // after, and backward iteration begins on the last page. let iter = makeIterator(start: makeLocator(progression: 1.0)) - let first = try await iter.next() - #expect(first?.equatable() == makeElement(pageNumber: 9, text: p9Text)) - let second = try await iter.next() - #expect(second == nil) + #expect(try await iter.next() == nil) + let last = try await iter.previous() + #expect(last?.equatable() == makeElement(pageNumber: 9, text: p9Text)) } } diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/SentenceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/SentenceContentIteratorTests.swift new file mode 100644 index 0000000000..b319502f8d --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/SentenceContentIteratorTests.swift @@ -0,0 +1,722 @@ +// +// 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 + +enum SentenceContentIteratorTests { + struct PDFPages { + @Test func lineBreaksWithinAPageAreResegmented() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "First sentence. There is a particu-\nlarly comfortable hotel in\nthis town. Done here."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "First sentence.") + #expect(first.attribute(.sentenceAligned) == true) + + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "There is a particularly comfortable hotel in this town.") + let segments = second.segments + try #require(segments.count == 3) + + // Part 1 drops the hyphen in its text but keeps the on-page form + // in the highlight. + #expect(segments[0].text == "There is a particu") + #expect(segments[0].locator.text.highlight == "There is a particu-") + #expect(segments[0].attribute(.continued) == nil) + + // Part 2 is joined directly (de-hyphenation). + #expect(segments[1].text == "larly comfortable hotel in") + #expect(segments[1].attribute(.continued) == ContentContinuationJoiner.direct) + + // Part 3 carries its joining space. + #expect(segments[2].text == " this town.") + #expect(segments[2].locator.text.highlight == "this town.") + #expect(segments[2].attribute(.continued) == ContentContinuationJoiner.space) + + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "Done here.") + + #expect(try await iter.next() == nil) + } + + @Test func sentenceAcrossTwoPagesWithHyphenation() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "First sentence. There is a particu-"), + pdfPage(2, "larly comfortable hotel. Second page done."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "First sentence.") + + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "There is a particularly comfortable hotel.") + try #require(second.segments.count == 2) + #expect(second.segments[0].text == "There is a particu") + #expect(second.segments[0].locator.text.highlight == "There is a particu-") + #expect(second.segments[0].locator.locations.fragments == ["page=1"]) + #expect(second.segments[0].locator.text.after?.hasPrefix("larly comfortable hotel.") == true) + #expect(second.segments[1].text == "larly comfortable hotel.") + #expect(second.segments[1].attribute(.continued) == ContentContinuationJoiner.direct) + #expect(second.segments[1].locator.locations.fragments == ["page=2"]) + #expect(second.segments[1].locator.text.highlight == "larly comfortable hotel.") + #expect(second.locator.locations.fragments == ["page=1"]) + + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "Second page done.") + #expect(third.locator.locations.fragments == ["page=2"]) + + #expect(try await iter.next() == nil) + } + + @Test func spaceJoinedSentenceAcrossTwoPages() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "One starts here. The hungry cat sat on"), + pdfPage(2, "the mat with style. Another sentence."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "One starts here.") + + let second = try #require(try await iter.next() as? TextContentElement) + try #require(second.segments.count == 2) + #expect(second.segments[0].text == "The hungry cat sat on") + #expect(second.segments[1].text == " the mat with style.") + #expect(second.segments[1].locator.text.highlight == "the mat with style.") + #expect(second.segments[1].attribute(.continued) == ContentContinuationJoiner.space) + + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "Another sentence.") + } + + @Test func abbreviationSeamNowMerges() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "A first sentence lives here. He greeted Mr."), + pdfPage(2, "Smith warmly. Done."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "A first sentence lives here.") + + // "…said Mr." / "Smith came." used to be kept apart by the + // terminal-punctuation fast path; the bridge test merges it. + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "He greeted Mr. Smith warmly.") + try #require(second.segments.count == 2) + #expect(second.segments[0].locator.locations.fragments == ["page=1"]) + #expect(second.segments[1].locator.locations.fragments == ["page=2"]) + + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "Done.") + } + + @Test func uppercaseContinuationKeepsHyphenAndSpace() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "Starting out well. They visited Salt Lake-"), + pdfPage(2, "City on their way westwards. Done."), + ]) + + _ = try await iter.next() // "Starting out well." + let element = try #require(try await iter.next() as? TextContentElement) + try #require(element.segments.count == 2) + #expect(element.segments[0].text == "They visited Salt Lake-") + #expect(element.segments[1].text == " City on their way westwards.") + #expect(element.segments[1].attribute(.continued) == ContentContinuationJoiner.space) + } + + @Test func threePageSentenceProducesOneElement() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "It begins. The story continues with something very"), + pdfPage(2, "strange that keeps going on and on"), + pdfPage(3, "until it finally ends here. Done."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "It begins.") + + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "The story continues with something very strange that keeps going on and on until it finally ends here.") + try #require(second.segments.count == 3) + #expect(second.segments.map(\.locator.locations.fragments) == [["page=1"], ["page=2"], ["page=3"]]) + + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "Done.") + + #expect(try await iter.next() == nil) + } + + @Test func paragraphGapNeverMerges() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "A paragraph without terminal punctuation\n\nAnother paragraph starts fresh"), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "A paragraph without terminal punctuation") + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "Another paragraph starts fresh") + #expect(try await iter.next() == nil) + } + + @Test func nonTextElementBreaksSeam() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "An unfinished sentence that keeps"), + imageElement(), + pdfPage(2, "going strong. Done."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "An unfinished sentence that keeps") + #expect(try await iter.next() is ImageContentElement) + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "going strong.") + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "Done.") + } + + @Test func pageNumberBecomesStandaloneArtifactElement() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "A full sentence.\n42"), + pdfPage(2, "Second page done."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "A full sentence.") + + let artifact = try #require(try await iter.next() as? TextContentElement) + #expect(artifact.text == "42") + #expect(artifact.attribute(.pageArtifact) == PageArtifactKind.pageNumber) + + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "Second page done.") + } + + @Test func runningFooterBecomesStandaloneArtifactElement() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "A full sentence here.\nMy Great Book"), + pdfPage(2, "Second page text here.\nMy Great Book"), + ]) + + let elements = try await allForward(iter) + let texts = elements.compactMap { ($0 as? TextContentElement)?.text } + #expect(texts == [ + "A full sentence here.", + "My Great Book", + "Second page text here.", + "My Great Book", + ]) + let artifacts = elements.filter { $0.attribute(.pageArtifact) == PageArtifactKind.runningHeader } + #expect(artifacts.count == 2) + } + + @Test func lookaheadIsBounded() async throws { + let (iter, mock) = makeIterator( + (1 ... 20).map { pdfPage($0, "Page \($0) text ends here.") } + ) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "Page 1 text ends here.") + // Building the first window must not pull the whole publication. + #expect(await mock.nextCallCount <= 6) + } + } + + struct HardBreaks { + @Test func spacedCapsPageIsStandalone() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "Some intro sentence without end"), + pdfPage(2, "P A R T O N E"), + pdfPage(3, "The story begins here. More."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "Some intro sentence without end") + + let hardBreak = try #require(try await iter.next() as? TextContentElement) + #expect(hardBreak.text == "P A R T O N E") + #expect(hardBreak.attribute(.pageArtifact) == nil) + #expect(hardBreak.locator.locations.fragments == ["page=2"]) + + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "The story begins here.") + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "More.") + } + + @Test func spacedCapsPageIsStandaloneBackward() async throws { + let elements = [ + pdfPage(1, "Some intro sentence without end"), + pdfPage(2, "P A R T O N E"), + pdfPage(3, "The story begins here. More."), + ] + let (forward, _) = makeIterator(elements) + let forwardElements = try await allForward(forward) + + let (backward, _) = makeIterator(elements, startingAtEnd: true) + let backwardElements = try await allBackward(backward) + + #expect(backwardElements.map { $0.equatable() } == forwardElements.reversed().map { $0.equatable() }) + } + + @Test func chapterLineIsStandalone() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "Chapter 1\nIt was a dark night. More text."), + ]) + + let hardBreak = try #require(try await iter.next() as? TextContentElement) + #expect(hardBreak.text == "Chapter 1") + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "It was a dark night.") + } + + @Test func headingRoleElementIsStandalone() async throws { + let (iter, _) = makeIterator([ + fxlElement("p1.xhtml", "A Very Nice Story", role: .heading(level: 1)), + fxlElement("p1.xhtml", "It was a dark night. The end."), + ]) + + let heading = try #require(try await iter.next() as? TextContentElement) + #expect(heading.text == "A Very Nice Story") + #expect(heading.role == .heading(level: 1)) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "It was a dark night.") + } + } + + struct FixedLayoutElements { + @Test func samePageBlockElementsMergeIntoOneSentence() async throws { + let (iter, _) = makeIterator([ + fxlElement("bella.xhtml", "There lived a beautiful", cssSelector: "#div1"), + fxlElement("bella.xhtml", "kind, friendly dragon,", cssSelector: "#div2"), + fxlElement("bella.xhtml", "named Bella.", cssSelector: "#div3"), + ]) + + let element = try #require(try await iter.next() as? TextContentElement) + #expect(element.text == "There lived a beautiful kind, friendly dragon, named Bella.") + try #require(element.segments.count == 3) + #expect(element.segments[0].text == "There lived a beautiful") + #expect(element.segments[1].text == " kind, friendly dragon,") + #expect(element.segments[1].attribute(.continued) == ContentContinuationJoiner.space) + #expect(element.segments[2].text == " named Bella.") + #expect(element.segments[2].attribute(.continued) == ContentContinuationJoiner.space) + + // Each part keeps its own block element locator. + #expect(element.segments.map(\.locator.locations.cssSelector) == ["#div1", "#div2", "#div3"]) + + #expect(try await iter.next() == nil) + } + + @Test func ellipsisMergesLowercaseAndSplitsUppercase() async throws { + let (iter, _) = makeIterator([ + fxlElement("p1.xhtml", "Far, far away ..."), + fxlElement("p1.xhtml", "and beyond the sea."), + fxlElement("p1.xhtml", "There it was."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "Far, far away ... and beyond the sea.") + #expect(first.segments.count == 2) + + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "There it was.") + } + + @Test func sentenceAcrossResourcesLandsInLeftResource() async throws { + let (iter, _) = makeIterator([ + fxlElement("p1.xhtml", "Intro sentence one. The hungry cat sat on"), + fxlElement("p2.xhtml", "the mat with style. Another sentence here."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "Intro sentence one.") + + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "The hungry cat sat on the mat with style.") + try #require(second.segments.count == 2) + #expect(second.segments[0].locator.href.string == "p1.xhtml") + #expect(second.segments[1].locator.href.string == "p2.xhtml") + #expect(second.locator.href.string == "p1.xhtml") + + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "Another sentence here.") + #expect(third.locator.href.string == "p2.xhtml") + } + + @Test func artifactSegmentWithinAnElementDoesNotGlueWords() async throws { + // The page number is the first *segment* of the next page's + // element: the body segment after it must not be joined directly + // to the previous page as if it were contiguous with the number. + let (iter, _) = makeIterator([ + fxlElement("p1.xhtml", "Intro sentence one. The hungry cat sat on"), + fxlElement("p2.xhtml", segments: ["42", "the mat with style. Done here."]), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "Intro sentence one.") + + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "The hungry cat sat on the mat with style.") + #expect(second.segments.last?.attribute(.continued) == ContentContinuationJoiner.space) + + let artifact = try #require(try await iter.next() as? TextContentElement) + #expect(artifact.text == "42") + #expect(artifact.attribute(.pageArtifact) == PageArtifactKind.pageNumber) + + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "Done here.") + } + + @Test func pageNumberElementBetweenSentenceParts() async throws { + let (iter, _) = makeIterator([ + fxlElement("p1.xhtml", "Intro sentence one. The hungry cat sat on"), + fxlElement("p2.xhtml", "42"), + fxlElement("p2.xhtml", "the mat with style. Another sentence here."), + ]) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "Intro sentence one.") + + // The sentence merges past the standalone page number element, + // which is emitted right after it. + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "The hungry cat sat on the mat with style.") + + let artifact = try #require(try await iter.next() as? TextContentElement) + #expect(artifact.text == "42") + #expect(artifact.attribute(.pageArtifact) == PageArtifactKind.pageNumber) + + let third = try #require(try await iter.next() as? TextContentElement) + #expect(third.text == "Another sentence here.") + } + } + + struct MidStart { + @Test func startsWithTheFullStraddlingSentence() async throws { + let elements = [ + pdfPage(1, "First one. The hungry cat sat on"), + pdfPage(2, "the mat with style. Another one here."), + ] + + // Start on the second page, as `PublicationContentIterator` does + // when given a mid-publication locator. + let (iter, _) = makeIterator(elements, startIndex: 1) + + // The first returned element is the full sentence containing the + // start position, even though it began on the previous page. + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "The hungry cat sat on the mat with style.") + + // Going backward returns the element before it. + let previous = try #require(try await iter.previous() as? TextContentElement) + #expect(previous.text == "First one.") + } + } + + struct BackwardIteration { + @Test func backwardFromEndEqualsReversedForward() async throws { + // Running footers force neighbor-dependent classification, and + // 12 single-sentence pages produce more windows than the window + // cache holds, exercising re-derivation after eviction. + let elements = (1 ... 12).map { + pdfPage($0, "Sentence number \($0) lives right here.\nMy Great Book") + } + + let (forward, _) = makeIterator(elements) + let forwardElements = try await allForward(forward) + + let (backward, _) = makeIterator(elements, startingAtEnd: true) + let backwardElements = try await allBackward(backward) + + #expect(backwardElements.map { $0.equatable() } == forwardElements.reversed().map { $0.equatable() }) + } + + @Test func alternatingDirectionsIsConsistent() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "First sentence. There is a particu-"), + pdfPage(2, "larly comfortable hotel. Second page done."), + ]) + + let first = try #require(try await iter.next()).equatable() + let second = try #require(try await iter.next()).equatable() + let backToFirst = try #require(try await iter.previous()).equatable() + let secondAgain = try #require(try await iter.next()).equatable() + + #expect(backToFirst == first) + #expect(secondAgain == second) + } + } + + struct Caps { + @Test func punctuationlessPagesStayBounded() async throws { + let elements = (1 ... 6).map { + pdfPage($0, "alpha bravo charlie delta page \($0)") + } + + let (forward, _) = makeIterator(elements) + let forwardElements = try await allForward(forward) + + // Every element spans at most 4 pages. + for element in forwardElements { + let text = try #require(element as? TextContentElement) + let pages = Set(text.segments.flatMap(\.locator.locations.fragments)) + #expect(pages.count <= 4) + } + // All 6 pages are covered. + let allPages = Set( + forwardElements + .compactMap { $0 as? TextContentElement } + .flatMap { $0.segments.flatMap(\.locator.locations.fragments) } + ) + #expect(allPages.count == 6) + + let (backward, _) = makeIterator(elements, startingAtEnd: true) + let backwardElements = try await allBackward(backward) + #expect(backwardElements.map { $0.equatable() } == forwardElements.reversed().map { $0.equatable() }) + } + + @Test func fragmentCapBoundsPunctuationlessFXLPages() async throws { + let elements = (1 ... 250).map { i in + fxlElement("page.xhtml", "word\(i)") + } + + let (iter, _) = makeIterator(elements) + let results = try await allForward(iter) + .compactMap { $0 as? TextContentElement } + + // The region is split at the fragment cap instead of growing + // without bound. + #expect(results.count >= 2) + for element in results { + #expect(element.segments.count <= 200) + } + let totalSegments = results.map(\.segments.count).reduce(0, +) + #expect(totalSegments == 250) + } + } + + struct SearchInvariant { + @Test func segmentTextsJoinToTheLogicalSentence() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "First sentence. There is a particu-\nlarly comfortable hotel in"), + pdfPage(2, "this town. Second page done."), + ]) + + // Expected logical sentences, computed independently of the + // iterator's own text assembly. + let expected = [ + "First sentence.", + "There is a particularly comfortable hotel in this town.", + "Second page done.", + ] + + let elements = try await allForward(iter).compactMap { $0 as? TextContentElement } + #expect(elements.map { $0.segments.map(\.text).joined() } == expected) + } + + @Test func searchWindowReconstructionSkipsPageArtifacts() async throws { + let (iter, _) = makeIterator([ + pdfPage(1, "The cat sat on the mat.\n7"), + pdfPage(2, "A dog barked loudly. End."), + ]) + + let elements = try await allForward(iter).compactMap { $0 as? TextContentElement } + + // Reconstruct the text the search service's sliding window sees: + // element texts joined by single spaces, page artifacts skipped. + let searched = elements + .filter { $0.attribute(.pageArtifact) == nil } + .map { $0.segments.map(\.text).joined() } + .joined(separator: " ") + #expect(searched == "The cat sat on the mat. A dog barked loudly. End.") + + // A query spanning the two sentences around the seam matches, + // despite the page number sitting between them on the page. + #expect(searched.contains("mat. A dog")) + } + } + + struct Progressions { + @Test func pdfProgressionsAreMonotonicAndWithinPageBrackets() async throws { + let pageCount = 4 + let (iter, _) = makeIterator([ + pdfPage(1, "One one one. Two two two.", pageCount: pageCount), + pdfPage(2, "Three three. Four four four.", pageCount: pageCount), + pdfPage(3, "Five five five. Six six six.", pageCount: pageCount), + ]) + + let elements = try await allForward(iter).compactMap { $0 as? TextContentElement } + try #require(elements.count == 6) + + var lastProgression = 0.0 + for element in elements { + let progression = try #require(element.locator.locations.progression) + #expect(progression >= lastProgression) + lastProgression = progression + + // In-bracket: restarting from this locator must land on the + // element's own page. + let page = try #require(element.locator.locations.fragments.first + .flatMap { Int($0.dropFirst("page=".count)) }) + let bracket = Double(page - 1) / Double(pageCount) ..< Double(page) / Double(pageCount) + #expect(bracket.contains(progression) || progression == bracket.lowerBound) + } + } + } + + struct CJK { + @Test func spacelessScriptJoinsDirectly() async throws { + let (iter, _) = makeIterator( + [ + pdfPage(1, "これは長い文章で"), + pdfPage(2, "続きです。次の文です。"), + ], + language: Language(code: .bcp47("ja")) + ) + + let first = try #require(try await iter.next() as? TextContentElement) + #expect(first.text == "これは長い文章で続きです。") + try #require(first.segments.count == 2) + #expect(first.segments[1].text == "続きです。") + #expect(first.segments[1].attribute(.continued) == ContentContinuationJoiner.direct) + + let second = try #require(try await iter.next() as? TextContentElement) + #expect(second.text == "次の文です。") + } + } +} + +// MARK: - Helpers + +private func allForward(_ iterator: SentenceContentIterator) async throws -> [ContentElement] { + var elements: [ContentElement] = [] + while let element = try await iterator.next() { + elements.append(element) + } + return elements +} + +private func allBackward(_ iterator: SentenceContentIterator) async throws -> [ContentElement] { + var elements: [ContentElement] = [] + while let element = try await iterator.previous() { + elements.append(element) + } + return elements +} + +private func pdfPage(_ number: Int, _ text: String, pageCount: Int? = nil) -> TextContentElement { + let locator = Locator(href: "book.pdf", mediaType: .pdf).copy( + locations: { + $0.fragments = ["page=\(number)"] + $0.position = number + if let pageCount { + $0.progression = Double(number - 1) / Double(pageCount) + } + }, + text: { + $0 = Locator.Text(highlight: text) + } + ) + return TextContentElement( + locator: locator, + role: .body, + segments: [TextContentElement.Segment(locator: locator, text: text)] + ) +} + +private func fxlElement( + _ href: String, + _ text: String, + role: TextContentElement.Role = .body, + cssSelector: String? = nil +) -> TextContentElement { + let locator = Locator(href: href, mediaType: .xhtml).copy( + locations: { + $0.cssSelector = cssSelector + }, + text: { + $0 = Locator.Text(highlight: text) + } + ) + return TextContentElement( + locator: locator, + role: role, + segments: [TextContentElement.Segment(locator: locator, text: text)] + ) +} + +private func fxlElement(_ href: String, segments: [String]) -> TextContentElement { + let locator = Locator(href: href, mediaType: .xhtml).copy( + text: { + $0 = Locator.Text(highlight: segments.joined()) + } + ) + return TextContentElement( + locator: locator, + role: .body, + segments: segments.map { TextContentElement.Segment(locator: locator, text: $0) } + ) +} + +private func imageElement() -> ImageContentElement { + ImageContentElement( + locator: Locator(href: "img.png", mediaType: .png), + embeddedLink: Link(href: "img.png") + ) +} + +private func makeIterator( + _ elements: [ContentElement], + startIndex: Int? = nil, + startingAtEnd: Bool = false, + language: Language? = Language(code: .bcp47("en")) +) -> (SentenceContentIterator, MockContentIterator) { + let mock = MockContentIterator(elements, startIndex: startIndex, startingAtEnd: startingAtEnd) + let iterator = SentenceContentIterator(iterator: mock, language: language) + return (iterator, mock) +} + +/// A `ContentIterator` over a fixed list of elements, with the same cursor +/// semantics as `PDFResourceContentIterator`, counting the calls it receives. +actor MockContentIterator: ContentIterator { + private let elements: [ContentElement] + + /// Index of the last returned element. + private var index: Int? + + private(set) var nextCallCount = 0 + private(set) var previousCallCount = 0 + + init(_ elements: [ContentElement], startIndex: Int? = nil, startingAtEnd: Bool = false) { + self.elements = elements + if let startIndex { + index = startIndex - 1 + } else { + index = startingAtEnd ? elements.count : nil + } + } + + func next() async throws -> ContentElement? { + nextCallCount += 1 + let target = (index ?? -1) + 1 + guard elements.indices.contains(target) else { + return nil + } + index = target + return elements[target] + } + + func previous() async throws -> ContentElement? { + previousCallCount += 1 + let target = (index ?? 0) - 1 + guard elements.indices.contains(target) else { + return nil + } + index = target + return elements[target] + } +} diff --git a/Tests/SharedTests/Publication/Services/Content/PageArtifactDetectorTests.swift b/Tests/SharedTests/Publication/Services/Content/PageArtifactDetectorTests.swift new file mode 100644 index 0000000000..6cdc72aeac --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Content/PageArtifactDetectorTests.swift @@ -0,0 +1,127 @@ +// +// 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 ReadiumShared +import Testing + +enum PageArtifactDetectorTests { + struct PageNumbers { + private let detector = PageNumberArtifactDetector() + + @Test func isNeighborFree() { + #expect(detector.requiresNeighbors == false) + } + + @Test(arguments: [ + "42", + "1234", + "xii", + "IV", + "- 42 -", + "— 42 —", + "[42]", + "(42)", + "Page 42", + "page 42", + "p. 42", + "42 / 300", + "42 of 300", + ]) + func detectsPageNumbers(_ text: String) { + #expect(detector.detectArtifact(in: candidate(text)) == .pageNumber) + } + + @Test(arguments: [ + "12345", + "Chapter 42", + "Hello", + "A regular sentence.", + "mmmmmmmmm", + // Words made of roman digits only are not numerals. + "civil", + "did", + "mild", + "Mix", + "", + ]) + func ignoresRegularText(_ text: String) { + #expect(detector.detectArtifact(in: candidate(text)) == nil) + } + + @Test func worksForBothScopes() { + #expect(detector.detectArtifact(in: candidate("42", scope: .line)) == .pageNumber) + #expect(detector.detectArtifact(in: candidate("42", scope: .element)) == .pageNumber) + } + } + + struct RunningHeaders { + private let detector = RunningHeaderArtifactDetector() + + @Test func requiresNeighbors() { + #expect(detector.requiresNeighbors == true) + } + + @Test func detectsHeaderMatchingPreviousPage() { + let result = detector.detectArtifact( + in: candidate("My Great Book", previous: "My Great Book") + ) + #expect(result == .runningHeader) + } + + @Test func detectsHeaderMatchingNextPage() { + let result = detector.detectArtifact( + in: candidate("My Great Book", next: "My Great Book") + ) + #expect(result == .runningHeader) + } + + @Test func matchIgnoresCaseDigitsAndPunctuation() { + let result = detector.detectArtifact( + in: candidate("MY GREAT BOOK — 12", previous: "My Great Book, 13") + ) + #expect(result == .runningHeader) + } + + @Test func ignoresCandidateWithoutNeighbors() { + #expect(detector.detectArtifact(in: candidate("My Great Book")) == nil) + } + + @Test func ignoresDifferentNeighborText() { + let result = detector.detectArtifact( + in: candidate("My Great Book", previous: "Some other line") + ) + #expect(result == nil) + } + + @Test func ignoresVeryShortMatches() { + // Too short to be a reliable running header signal. + #expect(detector.detectArtifact(in: candidate("Ab", previous: "Ab")) == nil) + } + + @Test func worksForElementScope() { + let result = detector.detectArtifact( + in: candidate("My Great Book", previous: "My Great Book", scope: .element) + ) + #expect(result == .runningHeader) + } + } +} + +private func candidate( + _ text: String, + previous: String? = nil, + next: String? = nil, + edge: PageArtifactCandidate.Edge = .tail, + scope: PageArtifactCandidate.Scope = .line +) -> PageArtifactCandidate { + PageArtifactCandidate( + text: text, + edge: edge, + scope: scope, + previousPageEdgeText: previous, + nextPageEdgeText: next + ) +} diff --git a/docs/Guides/Content.md b/docs/Guides/Content.md index 92bb1a2d98..460bf36798 100644 --- a/docs/Guides/Content.md +++ b/docs/Guides/Content.md @@ -137,6 +137,68 @@ If you are not interested in the segment attributes, you can also use `element.t All types of `ContentElement` can have associated attributes. Custom `ContentService` implementations can use this as an extensibility point. +## Fixed-layout sentence re-segmentation + +Fixed-layout publications (PDF, EPUB FXL) are paginated by construction: a sentence is cut by printed line breaks, by block element boundaries and by page boundaries, and stray page text (page numbers, running headers) sits in the middle of the reading flow. For these publications, the default `ContentService` automatically re-segments the content so that **each `TextContentElement` holds exactly one sentence**. + +### Sentence elements + +A sentence element has one segment per fragment the sentence touches — a printed line of a PDF page, or a block element of an FXL page. Segments after the first are marked with the `continued` attribute, and each keeps a locator targeting its own page and element, so every part stays renderable where it appears. + +```swift +TextContentElement( + role: .body, + segments: [ + TextContentElement.Segment(text: "There is a particu"), // page 1 + TextContentElement.Segment( + text: "larly comfortable hotel.", // page 2 + attributes: [ContentAttribute(key: .continued, value: ContentContinuationJoiner.direct)] + ), + ], + attributes: [ContentAttribute(key: .sentenceAligned, value: true)] +) +``` + +The `continued` value indicates how the segment joins the previous one: `.space` for a regular word boundary (the segment `text` carries the joining space), or `.direct` when a word was hyphenated at the break (the hyphen is dropped from the segment `text`, but kept in the locator's `highlight`). + +The segment `text` values always concatenate to the sentence's normalized *logical* text — what TTS speaks and search matches — while each segment locator's `highlight` keeps the on-page form. Locators produced from this content (TTS ranges, search results) therefore carry the logical text, which may be de-hyphenated relative to the page; navigation relies on the `page=` fragment and the per-segment locators. + +Sentence elements carry the `sentenceAligned` attribute and pass through `makeTextContentTokenizer` untouched. + +### Page artifacts + +Page-boundary noise – page numbers, running headers and footers – is detected and emitted as standalone elements marked with the `pageArtifact` attribute. Marked elements are skipped by TTS and, by default, by `ContentSearchService` (see its `ignoresPageArtifacts` parameter). + +```swift +if let kind = element.attribute(.pageArtifact) { + // kind is .pageNumber or .runningHeader +} +``` + +### Hard breaks + +Standalone display text – a part heading such as "P A R T O N E" or "Chapter 1", a title page line – is emitted as its own element too. Unlike a page artifact it is spoken by TTS and searchable, but sentences are never merged across it. + +Both detections are extensible: implement `PageArtifactDetector` or `HardBreakDetector` and pass your detectors when creating the service, in your parser configuration. + +```swift +DefaultContentService.makeFactory( + resourceContentIteratorFactories: [...], + pageArtifactDetectors: [ + PageNumberArtifactDetector(), + RunningHeaderArtifactDetector(), + MyCustomDetector(), + ], + hardBreakDetectors: [ + SpacedCapsHardBreakDetector(), + HeadingHardBreakDetector(), + StandalonePageHardBreakDetector(), + ] +) +``` + +Known limitations: a sentence spanning more than 4 pages is cut at the cap, and the re-segmentation is enabled from the publication-wide layout hint, so per-resource layout overrides in mixed EPUBs are ignored. + ## Use cases ### An index of all images embedded in the publication diff --git a/docs/Guides/Search.md b/docs/Guides/Search.md index 06964ac084..6853b2cb75 100644 --- a/docs/Guides/Search.md +++ b/docs/Guides/Search.md @@ -110,6 +110,8 @@ struct SearchResultRow: View { In a real UI you will typically want to truncate `before` and `after` so the UI stays compact. +With fixed-layout publications (PDF, EPUB FXL), the snippet text is the *normalized logical* form of the content: word cuts are de-hyphenated and page-boundary noise (page numbers, running headers) is skipped, so it may differ slightly from the text printed on the page. Navigating to the result still lands on the right page, via the locator's `page=` fragment. To make page numbers and running headers searchable again — at the cost of queries spanning a page boundary — pass `ignoresPageArtifacts: false` to `ContentSearchService.makeFactory()`. + ## Search options Pass a **`SearchOptions`** value to `search(query:options:)` to override the defaults. Any option you leave as `nil` falls back to the default behavior. diff --git a/docs/Guides/TTS.md b/docs/Guides/TTS.md index 6c3eb7c253..cf23526597 100644 --- a/docs/Guides/TTS.md +++ b/docs/Guides/TTS.md @@ -52,6 +52,27 @@ The `PublicationSpeechSynthesizer` should be the single source of truth to repre When pairing the `PublicationSpeechSynthesizer` with a `Navigator`, you can use the `utterance.locator` and `range` properties to highlight spoken utterances and turn pages automatically. +## Fixed-layout publications + +With fixed-layout publications (PDF, EPUB FXL), the synthesizer speaks each full sentence as a single utterance — even when it spans printed lines, block elements or page boundaries — and skips page-boundary noise such as page numbers and running headers. See [the Content guide](Content.md) for how the underlying sentence re-segmentation works. + +A cross-page utterance is composed of several `parts`, each with a locator targeting its own page. Regular utterances have a single part, so you can treat every utterance uniformly: + +* To highlight the spoken sentence with a `DecorableNavigator`, apply one decoration *per part* instead of a single one on `utterance.locator`. This keeps the sentence highlighted on both pages when the navigator turns the page mid-sentence. + +```swift +let decorations = utterance.parts.enumerated().map { index, part in + Decoration( + id: "tts-utterance-\(index)", + locator: part.locator, + style: .highlight(tint: .red) + ) +} +navigator.apply(decorations: decorations, in: "tts") +``` + +* The `range` of the `.playing` state is already narrowed inside the part containing the spoken word, so passing it to `navigator.go(to:)` turns the page when the speech crosses the page boundary. + ## Configuring the TTS > [!WARNING] diff --git a/docs/adr/0001-sentence-resegmentation-iterator.md b/docs/adr/0001-sentence-resegmentation-iterator.md new file mode 100644 index 0000000000..d53f3ed5a3 --- /dev/null +++ b/docs/adr/0001-sentence-resegmentation-iterator.md @@ -0,0 +1,32 @@ +# ADR 0001 – Sentence re-segmentation as a `ContentIterator` decorator + +## Status + +Accepted + +## Context + +TTS is unusable with PDF and EPUB FXL publications: sentences cut between printed lines, block elements and pages are spoken as broken fragments, and stray page text (page numbers, running headers) is pasted mid-sentence. + +The root cause is structural. Each PDF page becomes one single-segment `TextContentElement` whose text keeps a `\n` between printed lines — and `NLTokenizer` breaks sentences at every newline. Each FXL page is a separate resource whose blocks are separate elements, so one sentence commonly spans several `
` elements of the same page. Sentence tokenization (`makeTextContentTokenizer`) is a pure per-element function which can never see across element boundaries. + +An earlier design stitched sentences only across page *seams*, moving the continuation of a cut sentence onto the previous element. It could not fix mid-resource splits (newlines within a PDF page, sibling FXL blocks), and its terminal-punctuation fast path glued "P A R T O N E"-style display pages to the next sentence. + +## Decision + +Implement the fix as a `ContentIterator` decorator (`SentenceContentIterator`) wrapping the composite `PublicationContentIterator`, which re-segments the whole stream so that **each returned element holds exactly one sentence**. + +* `Tokenizer` is a pure per-element function. Splitting fits there, but re-segmentation cannot: it needs cross-element state (a lookahead window over the neighboring pages), which is exactly what an iterator can hold. +* The wrap point is above the composite iterator, not inside resource iterators, so PDF same-`href` page seams and FXL cross-`href` seams flow through one handler. +* Raw elements are decomposed into **fragments** (a printed line of a PDF page blob, one segment of an FXL block). Page artifacts (`PageArtifactDetector`) and hard breaks (`HardBreakDetector`) become standalone elements; the remaining body fragments are joined into normalized logical text (de-hyphenated word cuts, direct joins for space-less scripts) and tokenized as **regions** — maximal fragment runs between *anchors*. +* Anchors force sentence boundaries at: publication edges, non-text neighbors, hard breaks, paragraph gaps (`\n\n`), seams failing the *bridge test* (the joined tail+head of the two pages re-tokenized; merged only when a sentence token straddles the seam), and caps bounding a region to 4 pages and 200 fragments. +* Every derivation is a pure function of a bounded window of raw elements: forward and backward iteration produce the exact same stream, caches are pure optimizations, and starting mid-publication returns the full sentence containing the start position. +* Emitted sentence elements carry the `sentenceAligned` attribute; `makeTextContentTokenizer` returns them untouched so their on-page locator highlights (e.g. a hyphenated "particu-") survive for decorations. + +The decorator is applied automatically by `DefaultContentService`, for fixed-layout publications only (`metadata.layout == .fixed` or conforming to the PDF profile). Reflowable publications don't cut sentences between resources in a way visible to users, and their elements are semantic blocks rather than pages. + +## Consequences + +* All consumers of the `Content` API (TTS, search, extraction) see per-sentence elements without opting in. TTS speaks one utterance per sentence, with per-part locators for page turns and decorations; `ContentSearchService` reconstructs cross-page sentences in its sliding window and skips page-artifact elements by default (`ignoresPageArtifacts`). +* `element.segments.map(\.text).joined()` always equals the sentence's normalized logical text; the on-page form lives in each segment locator's `highlight`. Search-result locators therefore carry the *logical* form, which may be de-hyphenated relative to the page; navigation relies on the `page=` fragment and per-part locators. +* Known limitations, accepted: multi-*sentence* queries across an FXL resource boundary still fail (the search window flushes at resource boundaries); the activation gate uses the publication-wide layout, ignoring per-spine-item `rendition:layout` overrides in mixed EPUBs; a sentence spanning more than 4 pages is cut at the cap; letter-spaced display text ("P A R T O N E") is spoken as spelled letters — PDF extraction flattens typographic letter-spacing into ordinary spaces, so reconstructing the words would be guesswork, and a confident wrong reconstruction is worse than a spelled-out honest one. The fragment-level spoken-form/on-page-form split leaves room to slot in a normalizer later if this ever matters.