import Foundation import RDSpeechReaderView /// EPUB/text-book adapter. Locations use an EPUB resource href plus a UTF-16 /// offset, keeping saved speech progress independent of screen pagination. @MainActor public final class RDEPUBSpeechContentProvider: RDSpeechContentProvider { private weak var controller: RDEPUBReaderController? public init(controller: RDEPUBReaderController) { self.controller = controller } public func speechBookDescriptor() -> RDSpeechBookDescriptor { let identifier = controller?.currentLocation?.bookIdentifier ?? controller?.currentBookIdentifier ?? "rd-epub-reader" return RDSpeechBookDescriptor(identifier: identifier, title: controller?.title ?? "") } public func speechContentBatch( startingAt location: RDSpeechLocation?, limit: Int ) async throws -> RDSpeechContentBatch { guard let controller else { throw RDSpeechReaderError.invalidContentLocation } let descriptor = speechBookDescriptor() let chapters = readableChapters(from: controller) guard !chapters.isEmpty else { throw RDSpeechReaderError.noReadableContent } let startHref = location?.resourceIdentifier let startOffset = location?.textOffset ?? 0 let startIndex = startHref.flatMap { href in chapters.firstIndex { $0.href == href } } ?? 0 var units: [RDSpeechTextUnit] = [] for chapter in chapters.dropFirst(startIndex) { let chapterLocation = RDSpeechLocation( bookIdentifier: descriptor.identifier, resourceIdentifier: chapter.href ) let chapterUnits = RDSpeechTextPreprocessor.makeUnits( text: chapter.text, location: chapterLocation ).filter { chapter.href != startHref || NSMaxRange($0.textRange) > startOffset } for unit in chapterUnits { guard units.count < limit else { return RDSpeechContentBatch(units: units, nextLocation: unit.location) } units.append(unit) } } return RDSpeechContentBatch(units: units, nextLocation: nil) } private func readableChapters(from controller: RDEPUBReaderController) -> [(href: String, text: String)] { if let textBook = controller.textBook { return textBook.chapters.map { (href: $0.href, text: $0.attributedContent.string) } } guard let publication = controller.publication, let parser = controller.parser else { return [] } return publication.spine.compactMap { item in guard item.linear, item.mediaType.contains("html") || item.mediaType.contains("xhtml"), let html = parser.htmlString(forRelativePath: item.href) else { return nil } let href = publication.resourceResolver.normalizedHref(item.href) ?? item.href return (href: href, text: plainText(fromHTML: html, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent())) } } private func plainText(fromHTML html: String, baseURL: URL?) -> String { guard let data = html.data(using: .utf8) else { return fallbackPlainText(fromHTML: html) } var options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ .documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue ] if let baseURL { options[NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption")] = baseURL } if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) { return attributed.string } return fallbackPlainText(fromHTML: html) } private func fallbackPlainText(fromHTML html: String) -> String { html .replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) .replacingOccurrences(of: " ", with: " ") .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "'", with: "'") .replacingOccurrences(of: """, with: "\"") } } public extension RDEPUBReaderController { func makeSpeechContentProvider() -> RDEPUBSpeechContentProvider { RDEPUBSpeechContentProvider(controller: self) } }