From 5ef44b3e331fb74ad01f3014e972b5201b341d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dark=C3=B8=20Tasevski?= Date: Sat, 8 Nov 2025 23:48:41 +0100 Subject: [PATCH 1/3] Add Text-to-Speech support for PDF publications This commit adds TTS (Text-to-Speech) functionality for PDF publications by integrating PDFKit-based text extraction capabilities. Changes: - Add PDFResourceContentIterator for extracting text content from PDFs - Intelligent paragraph detection using multiple strategies - Precise locator positioning with page and paragraph indices - Platform-specific implementation using PDFKit (iOS, macOS, Catalyst) - Update PDFParser to register ContentService with PDF text iterator - Enables automatic TTS support for PDFs with extractable text - Maintains backward compatibility with existing code The implementation is modular and follows the existing ContentIterator pattern used for EPUB publications, ensuring consistency across the toolkit. --- Sources/Streamer/Parser/PDF/PDFParser.swift | 9 + .../PDF/PDFResourceContentIterator.swift | 500 ++++++++++++++++++ .../Reader/Common/TTS/TTSViewModel.swift | 4 + .../Reader/PDF/PDFViewController.swift | 36 +- docs/Guides/TTS.md | 52 +- 5 files changed, 599 insertions(+), 2 deletions(-) create mode 100644 Sources/Streamer/Parser/PDF/PDFResourceContentIterator.swift diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index ba57715f0..060b56ac6 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -68,6 +68,15 @@ public final class PDFParser: PublicationParser, Loggable { ), container: container, servicesBuilder: PublicationServicesBuilder( + // ContentService enables TTS (Text-to-Speech) for PDFs by extracting + // text content from PDF pages. This allows PublicationSpeechSynthesizer + // to work with PDF publications that have extractable text. + // See PDFResourceContentIterator for the implementation. + content: DefaultContentService.makeFactory( + resourceContentIteratorFactories: [ + PDFResourceContentIterator.Factory(pdfFactory: pdfFactory) + ] + ), cover: document.cover().map(GeneratedCoverService.makeFactory(cover:)), positions: PDFPositionsService.makeFactory() ) diff --git a/Sources/Streamer/Parser/PDF/PDFResourceContentIterator.swift b/Sources/Streamer/Parser/PDF/PDFResourceContentIterator.swift new file mode 100644 index 000000000..d0b30e6b9 --- /dev/null +++ b/Sources/Streamer/Parser/PDF/PDFResourceContentIterator.swift @@ -0,0 +1,500 @@ +// +// Copyright 2025 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 +import ReadiumInternal +import ReadiumShared +#if canImport(PDFKit) + import PDFKit +#endif + +/// Iterates a PDF `resource`, starting from the given `locator`. +/// +/// Extracts text content from PDF pages using PDFKit. Each page is converted to a +/// `TextContentElement` with proper locators for navigation and TTS. +/// +/// If you want to start mid-resource, the `locator` must contain a `position` key +/// in its `Locator.Locations` object indicating the page number. +/// +/// If you want to start from the end of the resource, the `locator` must have +/// a `progression` of 1.0. +/// +/// **Note**: This implementation requires PDFKit and is only available on platforms +/// that support it (iOS, macOS, Mac Catalyst). +public class PDFResourceContentIterator: ContentIterator { + /// Factory for a `PDFResourceContentIterator`. + /// + /// **Note**: Requires PDFKit for text extraction. Returns `nil` on platforms + /// where PDFKit is not available. + public class Factory: ResourceContentIteratorFactory { + private let pdfFactory: PDFDocumentFactory + + public init(pdfFactory: PDFDocumentFactory) { + self.pdfFactory = pdfFactory + } + + public func make( + publication: Publication, + readingOrderIndex: Int, + resource: Resource, + locator: Locator + ) -> ContentIterator? { + #if canImport(PDFKit) + guard publication.readingOrder.getOrNil(readingOrderIndex)?.mediaType == .pdf else { + return nil + } + + return PDFResourceContentIterator( + resource: resource, + pdfFactory: pdfFactory, + totalProgressionRange: { + let positions = await publication.positionsByReadingOrder().getOrNil() ?? [] + return positions.getOrNil(readingOrderIndex)? + .first?.locations.totalProgression + .map { start in + let end = positions.getOrNil(readingOrderIndex + 1)? + .first?.locations.totalProgression + ?? 1.0 + + return start ... end + } + }, + locator: locator + ) + #else + return nil + #endif + } + } + + #if canImport(PDFKit) + private let resource: Resource + private let pdfFactory: PDFDocumentFactory + private let locator: Locator + private let beforeMaxLength: Int = 50 + private let totalProgressionRange: Task?, Never> + // Keep a strong reference to the PDF document to prevent deallocation during text extraction + private var pdfDocument: PDFKit.PDFDocument? + + public init( + resource: Resource, + pdfFactory: PDFDocumentFactory, + totalProgressionRange: @escaping () async -> ClosedRange?, + locator: Locator + ) { + self.resource = resource + self.pdfFactory = pdfFactory + self.locator = locator + self.totalProgressionRange = Task { await totalProgressionRange() } + } + + public func previous() async throws -> ContentElement? { + let elements = try await elements() + let index = (currentIndex ?? elements.startIndex) - 1 + + guard let content = elements.elements.getOrNil(index) else { + return nil + } + + currentIndex = index + return content + } + + public func next() async throws -> ContentElement? { + let elements = try await elements() + let index = (currentIndex ?? (elements.startIndex - 1)) + 1 + + guard let content = elements.elements.getOrNil(index) else { + return nil + } + + currentIndex = index + return content + } + + private var currentIndex: Int? + + private func elements() async throws -> ParsedElements { + await elementsTask.value + } + + private lazy var elementsTask = Task { + let parsed = await extractPDFText( + resource: resource, + pdfFactory: pdfFactory, + locator: locator, + beforeMaxLength: beforeMaxLength + ) + return await adjustProgressions(of: parsed) + } + + /// Extracts text content from PDF pages and converts to ContentElements. + /// + /// This is an async operation because it needs to read the PDF resource + /// and extract text from pages using PDFKit. + private func extractPDFText( + resource: Resource, + pdfFactory: PDFDocumentFactory, + locator: Locator, + beforeMaxLength: Int + ) async -> ParsedElements { + do { + // For text extraction, we need PDFKit.PDFDocument directly + // Try to get it from the resource data, since PDFKit requires the full document in memory + let result = await resource.read() + let data: Data + switch result { + case let .success(resultData): + data = resultData + case .failure: + return ParsedElements(elements: [], startIndex: 0) + } + + // Create a copy of the data to ensure it's retained + let dataCopy = Data(data) + guard let pdfDocument = PDFKit.PDFDocument(data: dataCopy) else { + return ParsedElements(elements: [], startIndex: 0) + } + + // Store a strong reference to prevent deallocation + self.pdfDocument = pdfDocument + + let pageCount = pdfDocument.pageCount + guard pageCount > 0 else { + return ParsedElements(elements: [], startIndex: 0) + } + + // Determine starting page from locator + let startPageIndex: Int = { + if let position = locator.locations.position { + // Page numbers are typically 1-based in PDFs, but PDFKit uses 0-based indices + return max(0, min(position - 1, pageCount - 1)) + } else if let progression = locator.locations.progression { + // Calculate page from progression (0.0 to 1.0) + return Int(progression * Double(pageCount)) + } else { + return 0 + } + }() + + // Extract text from each page + var elements: [ContentElement] = [] + var wholeText = "" + + for pageIndex in 0 ..< pageCount { + guard let page = pdfDocument.page(at: pageIndex) else { + continue + } + + // Extract text from page + guard let pageText = page.string, !pageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + // Skip empty pages + continue + } + + // Create locator for this page + // Position in PDF locators is 1-based (page number) + let pageNumber = pageIndex + 1 + let pageProgression = Double(pageIndex) / Double(pageCount) + + let pageLocator = locator.copy( + locations: { + $0.position = pageNumber + $0.progression = pageProgression + $0.otherLocations = [ + "pageNumber": pageNumber, + ] + }, + text: { + // Include context for locator text + let beforeText = String(wholeText.suffix(beforeMaxLength)) + $0 = Locator.Text( + after: nil, + before: beforeText.isEmpty ? nil : beforeText, + highlight: pageText + ) + } + ) + + // Create TextContentElement for this page + // Split page text into paragraphs for better TTS granularity + // PDF text extraction doesn't always preserve paragraph separators, + // so we try multiple approaches: + var paragraphs = pageText.components(separatedBy: "\n\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + // If no double newlines found, try single newlines + // First, split by single newlines WITHOUT filtering empty strings + // so we can detect paragraph breaks (empty lines) + if paragraphs.count <= 1 { + let allLines = pageText.components(separatedBy: "\n") + + // Group consecutive non-empty lines into paragraphs + // A paragraph break occurs when we encounter an empty line + var grouped: [String] = [] + var currentGroup: [String] = [] + + for line in allLines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + // Empty line = paragraph break + if !currentGroup.isEmpty { + grouped.append(currentGroup.joined(separator: " ")) + currentGroup = [] + } + } else { + currentGroup.append(trimmed) + } + } + + // Add the last group if any + if !currentGroup.isEmpty { + grouped.append(currentGroup.joined(separator: " ")) + } + + if grouped.count > 1 { + paragraphs = grouped + } else { + // If no paragraph breaks found, use sentence-based splitting as fallback + // Split by sentence endings and group sentences + let sentences = pageText.components(separatedBy: CharacterSet(charactersIn: ".!?")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + if sentences.count > 3 { + // Group every 3-5 sentences into a paragraph for better granularity + let sentencesPerParagraph = max(3, min(5, max(3, sentences.count / 15 + 1))) + var sentenceGroups: [String] = [] + var currentSentenceGroup: [String] = [] + + for sentence in sentences { + currentSentenceGroup.append(sentence) + if currentSentenceGroup.count >= sentencesPerParagraph { + sentenceGroups.append(currentSentenceGroup.joined(separator: ". ") + ".") + currentSentenceGroup = [] + } + } + + if !currentSentenceGroup.isEmpty { + sentenceGroups.append(currentSentenceGroup.joined(separator: ". ") + ".") + } + + if sentenceGroups.count > 1 { + paragraphs = sentenceGroups + } + } + } + } + + if paragraphs.isEmpty { + // Single element for the entire page + elements.append(TextContentElement( + locator: pageLocator, + role: .body, + segments: [ + TextContentElement.Segment( + locator: pageLocator, + text: pageText + ), + ] + )) + } else { + // Create element per paragraph for better granularity + for (paraIndex, paragraph) in paragraphs.enumerated() { + let paraProgression = pageProgression + (Double(paraIndex) / Double(paragraphs.count)) / Double(pageCount) + let paraLocator = pageLocator.copy( + locations: { + $0.progression = paraProgression + $0.otherLocations = [ + "pageNumber": pageNumber, + "paragraphIndex": paraIndex, + ] + }, + text: { + let beforeText = String(wholeText.suffix(beforeMaxLength)) + $0 = Locator.Text( + after: nil, + before: beforeText.isEmpty ? nil : beforeText, + highlight: paragraph + ) + } + ) + + elements.append(TextContentElement( + locator: paraLocator, + role: .body, + segments: [ + TextContentElement.Segment( + locator: paraLocator, + text: paragraph + ), + ] + )) + } + } + + wholeText += pageText + "\n\n" + } + + // Calculate start index based on locator + let startIndex: Int = { + // Priority 1: Match by position AND paragraphIndex (most precise) + if let position = locator.locations.position, + let savedParaIndex = locator.locations.otherLocations["paragraphIndex"] as? Int + { + // First try to find exact match by paragraphIndex + if let index = elements.firstIndex(where: { element in + element.locator.locations.position == position && + element.locator.locations.otherLocations["paragraphIndex"] as? Int == savedParaIndex + }) { + return index + } + // Fallback: find first element on the same page + return elements.firstIndex { element in + element.locator.locations.position == position + } ?? 0 + } + + // Priority 2: Match by position only (page number) + if let position = locator.locations.position { + // Find all elements on this page + let pageElements = elements.enumerated().filter { _, element in + element.locator.locations.position == position + } + + // If we have a saved progression and multiple elements on the page, + // try to find the closest match by progression + if pageElements.count > 1, + let savedProgression = locator.locations.progression, + savedProgression > 0, savedProgression < 1 + { + if let closest = pageElements.min(by: { lhs, rhs in + let lhsProg = abs((lhs.element.locator.locations.progression ?? 0) - savedProgression) + let rhsProg = abs((rhs.element.locator.locations.progression ?? 0) - savedProgression) + return lhsProg < rhsProg + }) { + return closest.offset + } + } + + // Fallback: use first element on the page + return pageElements.first?.offset ?? elements.firstIndex { element in + element.locator.locations.position == position + } ?? 0 + } else if let progression = locator.locations.progression { + // Find element closest to the progression + if let closest = elements.enumerated().min(by: { lhs, rhs in + let lhsProg = abs((lhs.element.locator.locations.progression ?? 0) - progression) + let rhsProg = abs((rhs.element.locator.locations.progression ?? 0) - progression) + return lhsProg < rhsProg + }) { + return closest.offset + } + // Fallback to lastIndex method + return elements.lastIndex { element in + (element.locator.locations.progression ?? 0) < progression + } ?? 0 + } else if locator.locations.progression == 1.0 { + return elements.count - 1 + } else { + return 0 + } + }() + + return ParsedElements(elements: elements, startIndex: startIndex) + } catch { + return ParsedElements(elements: [], startIndex: 0) + } + } + + private func adjustProgressions(of elements: ParsedElements) async -> ParsedElements { + let count = Double(elements.elements.count) + guard count > 0 else { + return elements + } + + var elements = elements + elements.elements = await elements.elements.enumerated().asyncMap { index, element in + let progression = Double(index) / count + return await element.copy( + progression: progression, + totalProgression: totalProgressionRange.value.map { range in + range.lowerBound + progression * (range.upperBound - range.lowerBound) + } + ) + } + + // Update the `startIndex` if a particular progression was requested. + // But only if startIndex wasn't already calculated (i.e., it's 0 and we're using progression matching) + if + elements.startIndex == 0, + locator.locations.position == nil, + let progression = locator.locations.progression, + progression > 0, progression < 1 + { + elements.startIndex = elements.elements.lastIndex { element in + let elementProgression = element.locator.locations.progression ?? 0 + return elementProgression < progression + } ?? 0 + } + + return elements + } + + /// Holds the result of parsing the PDF resource into a list of `ContentElement`. + /// + /// The `startIndex` will be calculated from the element matched by the + /// base `locator`, if possible. Defaults to 0. + private struct ParsedElements { + var elements: [ContentElement] = [] + var startIndex: Int = 0 + } + #else + // Fallback for platforms without PDFKit + public func previous() async throws -> ContentElement? { nil } + public func next() async throws -> ContentElement? { nil } + #endif +} + +// MARK: - ContentElement Extension + +private extension ContentElement { + func copy(progression: Double?, totalProgression: Double?) async -> ContentElement { + func update(_ locator: Locator) -> Locator { + locator.copy(locations: { + $0.progression = progression + $0.totalProgression = totalProgression + }) + } + + switch self { + case var e as TextContentElement: + e.locator = update(e.locator) + e.segments = e.segments.map { segment in + var segment = segment + segment.locator = update(segment.locator) + return segment + } + return e + + case var e as AudioContentElement: + e.locator = update(e.locator) + return e + + case var e as ImageContentElement: + e.locator = update(e.locator) + return e + + case var e as VideoContentElement: + e.locator = update(e.locator) + return e + + default: + return self + } + } +} diff --git a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift index 0d74896ee..6e0daccf3 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift @@ -54,8 +54,12 @@ final class TTSViewModel: ObservableObject, Loggable { init?(navigator: Navigator, publication: Publication) { guard let synthesizer = PublicationSpeechSynthesizer(publication: publication) else { + print("⚠️ TTS: PublicationSpeechSynthesizer could not be created for publication: \(publication.metadata.title ?? "Unknown")") return nil } + print("🔊 TTS: Successfully initialized for publication: \(publication.metadata.title ?? "Unknown")") + print("🔊 TTS: Available voices: \(synthesizer.availableVoices.count)") + self.synthesizer = synthesizer settings = Settings(synthesizer: synthesizer) self.navigator = navigator diff --git a/TestApp/Sources/Reader/PDF/PDFViewController.swift b/TestApp/Sources/Reader/PDF/PDFViewController.swift index 1bffcd829..785a21817 100644 --- a/TestApp/Sources/Reader/PDF/PDFViewController.swift +++ b/TestApp/Sources/Reader/PDF/PDFViewController.swift @@ -26,11 +26,23 @@ final class PDFViewController: VisualReaderViewController [!NOTE] -> TTS is not yet implemented for all formats. +> TTS is currently supported for EPUB and PDF publications with extractable text. PDF support requires PDFKit (iOS, macOS, Mac Catalyst). Text-to-speech can be used to read aloud a publication using a synthetic voice. The Readium toolkit ships with a TTS implementation based on the native [Apple Speech Synthesis](https://developer.apple.com/documentation/avfoundation/speech_synthesis), but it is opened for extension if you want to use a different TTS engine. @@ -185,3 +185,53 @@ let synthesizer = PublicationSpeechSynthesizer( ) ``` +## PDF Text-to-Speech + +PDF publications with extractable text can be read aloud using TTS. The implementation uses PDFKit to extract text content from PDF pages. + +### Requirements + +- **PDFKit availability**: PDF TTS requires PDFKit and is only available on iOS, macOS, and Mac Catalyst. +- **Extractable text**: The PDF must contain actual text (not scanned images). OCR is not performed automatically. +- **Parser integration**: The `PDFParser` automatically registers the `PDFResourceContentIterator` to enable TTS support. + +### Text Extraction Strategy + +The `PDFResourceContentIterator` extracts text from PDFs with intelligent paragraph detection: + +1. **Double newlines** - Splits by `\n\n` for clear paragraph breaks +2. **Single newlines with empty lines** - Groups consecutive non-empty lines, treating empty lines as paragraph breaks +3. **Sentence grouping** - Falls back to grouping 3-5 sentences when paragraph breaks aren't detected + +This approach provides better TTS granularity, allowing users to skip between meaningful text chunks rather than entire pages. + +### Locator Precision + +The PDF TTS implementation provides precise navigation: + +- **Page-level positioning** - Each text element includes the page number (1-based) +- **Paragraph-level positioning** - When paragraphs are detected, each has a `paragraphIndex` in `otherLocations` +- **Progression values** - Accurate progression values within pages for smooth navigation + +### Limitations + +- **Scanned PDFs** - PDFs containing only images (scanned documents) cannot be read unless they have been OCR'd +- **Complex layouts** - Text extraction from PDFs with complex layouts (multi-column, tables) may not preserve the intended reading order +- **Memory usage** - The entire PDF is loaded into memory for text extraction, which may impact performance with very large files + +### Example + +```swift +// Parse a PDF publication +let asset = FileAsset(url: pdfURL) +let publication = try await Streamer().open(asset: asset, allowUserInteraction: false).get() + +// Create TTS synthesizer - works automatically if PDF has extractable text +if let synthesizer = PublicationSpeechSynthesizer(publication: publication) { + synthesizer.start() + // The synthesizer will read the PDF text aloud +} else { + // PDF has no extractable text or TTS is not supported for this publication +} +``` + From 80a4d836ff069361e8430884c78b3d4582e672c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dark=C3=B8=20Tasevski?= Date: Sun, 9 Nov 2025 10:45:22 +0100 Subject: [PATCH 2/3] refactor: fix test app build errors --- .../Reader/PDF/PDFViewController.swift | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/TestApp/Sources/Reader/PDF/PDFViewController.swift b/TestApp/Sources/Reader/PDF/PDFViewController.swift index 785a21817..96004d58a 100644 --- a/TestApp/Sources/Reader/PDF/PDFViewController.swift +++ b/TestApp/Sources/Reader/PDF/PDFViewController.swift @@ -26,23 +26,11 @@ final class PDFViewController: VisualReaderViewController Date: Sun, 9 Nov 2025 11:23:24 +0100 Subject: [PATCH 3/3] fix: add missing DecorableNavigator --- .../PDF/PDFNavigatorViewController.swift | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 93ac53efe..2faafde74 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -689,6 +689,227 @@ extension PDFNavigatorViewController: UIGestureRecognizerDelegate { } } +extension PDFNavigatorViewController: DecorableNavigator { + /// Storage for decorations by group name + private var decorations: [String: [DiffableDecoration]] { + get { objc_getAssociatedObject(self, &decorationsKey) as? [String: [DiffableDecoration]] ?? [:] } + set { objc_setAssociatedObject(self, &decorationsKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// Storage for PDF annotations mapped by decoration ID + private var annotationsByDecorationId: [Decoration.Id: [PDFKit.PDFAnnotation]] { + get { objc_getAssociatedObject(self, &annotationsKey) as? [Decoration.Id: [PDFKit.PDFAnnotation]] ?? [:] } + set { objc_setAssociatedObject(self, &annotationsKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// Storage for decoration interaction callbacks by group + private var decorationCallbacks: [String: [OnActivatedCallback]] { + get { objc_getAssociatedObject(self, &callbacksKey) as? [String: [OnActivatedCallback]] ?? [:] } + set { objc_setAssociatedObject(self, &callbacksKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + public func supports(decorationStyle style: Decoration.Style.Id) -> Bool { + // PDF supports highlight and underline decoration styles + return style == .highlight || style == .underline + } + + public func apply(decorations newDecorations: [Decoration], in group: String) { + guard let pdfView = pdfView, let document = pdfView.document else { + return + } + + // Normalize locators and convert to diffable decorations + let target = newDecorations.map { + var d = $0 + d.locator = publication.normalizeLocator(d.locator) + return DiffableDecoration(decoration: d) + } + + let source = decorations[group] ?? [] + decorations[group] = target + + // Calculate changes + let changes = target.changesByHREF(from: source) + + // Apply changes to PDF annotations + for (_, changeList) in changes { + for change in changeList { + switch change { + case let .add(decoration): + addAnnotation(for: decoration, in: document) + case let .remove(id): + removeAnnotation(withId: id, from: document) + case let .update(decoration): + removeAnnotation(withId: decoration.id, from: document) + addAnnotation(for: decoration, in: document) + } + } + } + } + + public func observeDecorationInteractions(inGroup group: String, onActivated: @escaping OnActivatedCallback) { + var callbacks = decorationCallbacks[group] ?? [] + callbacks.append(onActivated) + decorationCallbacks[group] = callbacks + } + + // MARK: - Private Helpers + + private func addAnnotation(for decoration: Decoration, in document: PDFKit.PDFDocument) { + guard let page = findPage(for: decoration.locator, in: document) else { + log(.warning, "Could not find page for decoration \(decoration.id)") + return + } + + let boundsArray = boundsForLines(for: decoration.locator, on: page) + guard !boundsArray.isEmpty else { + log(.warning, "Could not find bounds for decoration \(decoration.id)") + return + } + + var createdAnnotations: [PDFKit.PDFAnnotation] = [] + for bounds in boundsArray { + let annotation = createAnnotation(for: decoration.style, bounds: bounds, decorationId: decoration.id) + page.addAnnotation(annotation) + createdAnnotations.append(annotation) + } + + annotationsByDecorationId[decoration.id] = createdAnnotations + } + + private func removeAnnotation(withId id: Decoration.Id, from document: PDFKit.PDFDocument) { + guard let annotations = annotationsByDecorationId[id] else { + return + } + + for annotation in annotations { + guard let page = annotation.page else { continue } + page.removeAnnotation(annotation) + } + + annotationsByDecorationId[id] = nil + } + + private func createAnnotation(for style: Decoration.Style, bounds: CGRect, decorationId: Decoration.Id) -> PDFKit.PDFAnnotation { + let annotation: PDFKit.PDFAnnotation + + // Extract highlight config if available + let config = style.config as? Decoration.Style.HighlightConfig + let tint = config?.tint ?? .yellow + let isActive = config?.isActive ?? false + + switch style.id { + case .highlight: + annotation = PDFKit.PDFAnnotation(bounds: bounds, forType: .highlight, withProperties: nil) + annotation.color = tint.withAlphaComponent(isActive ? 0.5 : 0.3) + + case .underline: + annotation = PDFKit.PDFAnnotation(bounds: bounds, forType: .underline, withProperties: nil) + annotation.color = tint + + default: + // Fallback to highlight for unknown styles + annotation = PDFKit.PDFAnnotation(bounds: bounds, forType: .highlight, withProperties: nil) + annotation.color = tint.withAlphaComponent(0.3) + } + + // Store decoration ID for later lookup + annotation.setValue(decorationId, forAnnotationKey: .name) + + return annotation + } + + private func findPage(for locator: Locator, in document: PDFKit.PDFDocument) -> PDFKit.PDFPage? { + guard let pageNumber = pageNumber(for: locator) else { + return nil + } + + // PDFKit uses 0-based indexing + let pageIndex = pageNumber - 1 + return document.page(at: pageIndex) + } + + /// Returns an array of CGRect bounds, one for each line of the highlighted text. + /// This ensures precise highlighting that follows text flow across multiple lines. + private func boundsForLines(for locator: Locator, on page: PDFKit.PDFPage) -> [CGRect] { + // Get the highlighted text from the locator + guard let highlightedText = locator.text.highlight, !highlightedText.isEmpty else { + // No text specified - return a default highlight area + let pageBounds = page.bounds(for: .mediaBox) + let defaultRect = CGRect( + x: pageBounds.minX + pageBounds.width * 0.1, + y: pageBounds.maxY - pageBounds.height * 0.15, + width: pageBounds.width * 0.8, + height: 20 + ) + return [defaultRect] + } + + // Get the page's full text to search within it + guard let pageText = page.string else { + return [] + } + + // Find the text in the page content + guard let range = pageText.range(of: highlightedText, options: .caseInsensitive) else { + // Text not found - return default + let pageBounds = page.bounds(for: .mediaBox) + let defaultRect = CGRect( + x: pageBounds.minX + pageBounds.width * 0.1, + y: pageBounds.maxY - pageBounds.height * 0.15, + width: pageBounds.width * 0.8, + height: 20 + ) + return [defaultRect] + } + + // Convert the String range to NSRange for PDFPage API + let nsRange = NSRange(range, in: pageText) + + // Create a selection from the character range + guard let selection = page.selection(for: nsRange) else { + return [] + } + + // Split selection into individual lines for precise highlighting + let lineSelections = selection.selectionsByLine() + + var bounds: [CGRect] = [] + for lineSelection in lineSelections { + let lineBounds = lineSelection.bounds(for: page) + // Only add valid, non-empty bounds + guard !lineBounds.isNull, !lineBounds.isEmpty else { continue } + bounds.append(lineBounds) + } + + // Fallback: if we couldn't get line-by-line bounds, use the full selection bounds + if bounds.isEmpty { + let fullBounds = selection.bounds(for: page) + if !fullBounds.isNull, !fullBounds.isEmpty { + bounds.append(fullBounds) + } + } + + return bounds + } + + /// Notifies all registered callbacks for the decoration's group that the decoration was activated. + private func notifyDecorationActivated(_ event: OnDecorationActivatedEvent) { + guard let callbacks = decorationCallbacks[event.group] else { + return + } + + for callback in callbacks { + callback(event) + } + } +} + +// Associated object keys for decoration storage +private var decorationsKey: UInt8 = 0 +private var annotationsKey: UInt8 = 0 +private var callbacksKey: UInt8 = 0 + private extension Axis { var displayDirection: PDFDisplayDirection { switch self {