更新阅读器功能与示例
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user