Files
ReadViewSDK/Sources/RDEpubReaderView/Speech/RDEPUBSpeechContentProvider.swift
T
2026-07-27 21:43:13 +08:00

109 lines
4.6 KiB
Swift

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: "&nbsp;", with: " ")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&#39;", with: "'")
.replacingOccurrences(of: "&quot;", with: "\"")
}
}
public extension RDEPUBReaderController {
func makeSpeechContentProvider() -> RDEPUBSpeechContentProvider {
RDEPUBSpeechContentProvider(controller: self)
}
}