更新阅读器功能与示例
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import Foundation
|
||||
import RDAIReaderView
|
||||
import UIKit
|
||||
|
||||
/// EPUB adapter using normalized hrefs and the existing CFI index table for
|
||||
/// stable citation navigation across font and pagination changes.
|
||||
@MainActor
|
||||
public final class RDEPUBAIContentProvider: RDAIContentProvider {
|
||||
private weak var controller: RDEPUBReaderController?
|
||||
|
||||
init(controller: RDEPUBReaderController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
public func aiDocumentDescriptor() -> RDAIDocumentDescriptor {
|
||||
let identifier = controller?.currentBookIdentifier ?? "rd-epub-reader"
|
||||
let chapters = readableChapters()
|
||||
let revision = chapters.map { "\($0.href)|\($0.text.utf16.count)" }.joined(separator: "|")
|
||||
return RDAIDocumentDescriptor(
|
||||
identifier: RDAIDocumentIdentifier(rawValue: identifier),
|
||||
title: controller?.title ?? "",
|
||||
format: .epub,
|
||||
contentRevision: RDAIContentHasher.hash(revision)
|
||||
)
|
||||
}
|
||||
|
||||
public func aiResources() async throws -> [RDAIResourceDescriptor] {
|
||||
readableChapters().enumerated().map { index, chapter in
|
||||
RDAIResourceDescriptor(
|
||||
identifier: RDAIResourceIdentifier(rawValue: chapter.href),
|
||||
title: chapter.title,
|
||||
order: index,
|
||||
estimatedUTF16Length: chapter.text.utf16.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func aiResourceSnapshot(for identifier: RDAIResourceIdentifier) async throws -> RDAIResourceSnapshot {
|
||||
guard let chapter = readableChapters().first(where: { $0.href == identifier.rawValue }) else {
|
||||
throw RDAIError.resourceUnavailable(identifier)
|
||||
}
|
||||
let resource = RDAIResourceDescriptor(
|
||||
identifier: identifier,
|
||||
title: chapter.title,
|
||||
order: chapter.order,
|
||||
estimatedUTF16Length: chapter.text.utf16.count
|
||||
)
|
||||
let anchor = RDAIEPUBAnchor(href: chapter.href, progression: 0)
|
||||
return RDAIResourceSnapshot(
|
||||
descriptor: resource,
|
||||
sourceText: chapter.text,
|
||||
sourceHash: RDAIContentHasher.hash(chapter.text),
|
||||
locatorRuns: [RDAILocatorRun(
|
||||
textRange: RDAITextRange(location: 0, length: chapter.text.utf16.count),
|
||||
anchor: .epub(anchor)
|
||||
)]
|
||||
)
|
||||
}
|
||||
|
||||
public func aiNavigate(to locator: RDAILocator, animated: Bool) async throws {
|
||||
guard let controller,
|
||||
case .epub(let anchor) = locator.anchor else {
|
||||
throw RDAIError.staleCitation
|
||||
}
|
||||
let resolved = resolveLocation(locator: locator, anchor: anchor, controller: controller)
|
||||
controller.go(to: resolved)
|
||||
}
|
||||
|
||||
public func aiShowCitationHighlight(_ citation: RDAICitation) async throws {
|
||||
guard let controller,
|
||||
case .epub(let anchor) = citation.locator.anchor else {
|
||||
throw RDAIError.staleCitation
|
||||
}
|
||||
let location = resolveLocation(locator: citation.locator, anchor: anchor, controller: controller)
|
||||
let range = citation.locator.textRange
|
||||
let decoration = RDEPUBHighlight(
|
||||
id: "rdai-citation-\(citation.id)",
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: location,
|
||||
text: citation.quote,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: anchor.href, start: range.location, end: range.upperBound).jsonString(),
|
||||
style: .highlight,
|
||||
color: "#86D7FF"
|
||||
)
|
||||
controller.setTransientHighlights([decoration])
|
||||
controller.go(to: location)
|
||||
}
|
||||
|
||||
public func aiClearCitationHighlight() { controller?.clearTransientHighlights() }
|
||||
|
||||
private func resolveLocation(
|
||||
locator: RDAILocator,
|
||||
anchor: RDAIEPUBAnchor,
|
||||
controller: RDEPUBReaderController
|
||||
) -> RDEPUBLocation {
|
||||
let sourceLength = readableChapters().first { $0.href == anchor.href }?.text.utf16.count ?? 1
|
||||
let progression = Double(locator.textRange.location) / Double(max(sourceLength - 1, 1))
|
||||
guard let chapterData = controller.textChapterData(forNormalizedHref: anchor.href) else {
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: anchor.href,
|
||||
progression: anchor.progression ?? progression,
|
||||
cfi: anchor.cfi,
|
||||
rangeCFI: anchor.rangeCFI
|
||||
)
|
||||
}
|
||||
let range = NSRange(location: locator.textRange.location, length: max(locator.textRange.length, 1))
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: range)
|
||||
let cfiRange = chapterData.indexTable.cfiRange(for: rangeAnchor)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: anchor.href,
|
||||
progression: progression,
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: cfiRange?.start.rawValue,
|
||||
rangeCFI: cfiRange?.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
private func readableChapters() -> [(href: String, title: String?, text: String, order: Int)] {
|
||||
guard let controller else { return [] }
|
||||
if let textBook = controller.textBook {
|
||||
return textBook.chapters.enumerated().map {
|
||||
(href: $0.element.href, title: $0.element.title, text: $0.element.attributedContent.string, order: $0.offset)
|
||||
}
|
||||
}
|
||||
guard let publication = controller.publication,
|
||||
let parser = controller.parser else {
|
||||
return []
|
||||
}
|
||||
return publication.spine.enumerated().compactMap { index, 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, title: item.href, text: plainText(fromHTML: html), order: index)
|
||||
}
|
||||
}
|
||||
|
||||
private func plainText(fromHTML html: String) -> String {
|
||||
guard let data = html.data(using: .utf8),
|
||||
let attributed = try? NSAttributedString(
|
||||
data: data,
|
||||
options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
],
|
||||
documentAttributes: nil
|
||||
) else {
|
||||
return html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
|
||||
}
|
||||
return attributed.string
|
||||
}
|
||||
}
|
||||
|
||||
public extension RDEPUBReaderController {
|
||||
func aiCurrentReadScope() -> RDAIReadScope {
|
||||
guard let location = currentLocation else { return .init() }
|
||||
let locator = RDAILocator(
|
||||
documentIdentifier: RDAIDocumentIdentifier(rawValue: currentBookIdentifier ?? "rd-epub-reader"),
|
||||
resourceIdentifier: RDAIResourceIdentifier(rawValue: location.href),
|
||||
textRange: RDAITextRange(location: 0, length: Int.max),
|
||||
anchor: .epub(.init(href: location.href, cfi: location.cfi, rangeCFI: location.rangeCFI, progression: location.progression)),
|
||||
sourceHash: ""
|
||||
)
|
||||
return RDAIReadScope(upperBound: locator)
|
||||
}
|
||||
|
||||
func makeAIContentProvider() -> RDEPUBAIContentProvider {
|
||||
RDEPUBAIContentProvider(controller: self)
|
||||
}
|
||||
|
||||
func makeAIReaderService(
|
||||
generativeProvider: (any RDAIGenerativeProvider)? = nil,
|
||||
configuration: RDAIReaderConfiguration = .default
|
||||
) -> RDAIReaderService {
|
||||
RDAIReaderService(
|
||||
contentProvider: makeAIContentProvider(),
|
||||
analyzer: RDAINaturalLanguageAnalyzer(),
|
||||
semanticScorer: RDAINaturalLanguageSemanticScorer(),
|
||||
persistentStore: try? RDAISQLiteIndexStore(),
|
||||
generativeProvider: generativeProvider,
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
|
||||
func makeAIReaderAssistant(scope: RDAIReadScope) -> UIViewController {
|
||||
UINavigationController(rootViewController: RDAIReaderAssistantViewController(service: makeAIReaderService(), scope: scope))
|
||||
}
|
||||
|
||||
func makeAIReaderAssistant(scope: RDAIReadScope, generativeProvider: (any RDAIGenerativeProvider)?) -> UIViewController {
|
||||
UINavigationController(rootViewController: RDAIReaderAssistantViewController(service: makeAIReaderService(generativeProvider: generativeProvider), scope: scope))
|
||||
}
|
||||
|
||||
func makeAIReaderAssistant() -> UIViewController { makeAIReaderAssistant(scope: aiCurrentReadScope()) }
|
||||
}
|
||||
@@ -164,14 +164,14 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
|
||||
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
return chapterData.highlights(on: page, from: activeHighlights)
|
||||
return chapterData.highlights(on: page, from: activeHighlights + transientHighlights)
|
||||
}
|
||||
|
||||
guard let publication else {
|
||||
return activeHighlights.filter { $0.location.href == page.href }
|
||||
return (activeHighlights + transientHighlights).filter { $0.location.href == page.href }
|
||||
}
|
||||
let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href
|
||||
return activeHighlights.filter {
|
||||
return (activeHighlights + transientHighlights).filter {
|
||||
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,18 @@ extension RDEPUBReaderController {
|
||||
runtime.clearSelection()
|
||||
}
|
||||
|
||||
/// Displays temporary decorations without mutating annotation persistence.
|
||||
public func setTransientHighlights(_ highlights: [RDEPUBHighlight]) {
|
||||
transientHighlights = highlights
|
||||
refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
public func clearTransientHighlights() {
|
||||
guard !transientHighlights.isEmpty else { return }
|
||||
transientHighlights.removeAll()
|
||||
refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
public func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
runtime.bookmark(withID: id)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ extension RDEPUBReaderController {
|
||||
guard let publication else { return [] }
|
||||
if let spread = page.fixedSpread {
|
||||
let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) })
|
||||
return activeHighlights.filter { highlight in
|
||||
return (activeHighlights + transientHighlights).filter { highlight in
|
||||
guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
|
||||
return false
|
||||
}
|
||||
@@ -80,7 +80,7 @@ extension RDEPUBReaderController {
|
||||
guard publication.spine.indices.contains(page.spineIndex) else { return [] }
|
||||
let href = publication.spine[page.spineIndex].href
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(href)
|
||||
return activeHighlights.filter { highlight in
|
||||
return (activeHighlights + transientHighlights).filter { highlight in
|
||||
publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,11 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
set { readerContext.activeHighlights = newValue }
|
||||
}
|
||||
|
||||
var transientHighlights: [RDEPUBHighlight] {
|
||||
get { readerContext.transientHighlights }
|
||||
set { readerContext.transientHighlights = newValue }
|
||||
}
|
||||
|
||||
lazy var topToolView = runtime.makeTopToolView()
|
||||
|
||||
lazy var bottomToolView = runtime.makeBottomToolView()
|
||||
|
||||
@@ -72,6 +72,11 @@ final class RDEPUBReaderContext {
|
||||
set { state.activeHighlights = newValue }
|
||||
}
|
||||
|
||||
var transientHighlights: [RDEPUBHighlight] {
|
||||
get { state.transientHighlights }
|
||||
set { state.transientHighlights = newValue }
|
||||
}
|
||||
|
||||
var currentBookIdentifier: String? {
|
||||
get { state.currentBookIdentifier }
|
||||
set { state.currentBookIdentifier = newValue }
|
||||
|
||||
@@ -20,6 +20,9 @@ final class RDEPUBReaderState {
|
||||
|
||||
var activeHighlights: [RDEPUBHighlight] = []
|
||||
|
||||
// Session-only decorations (for search/AI focus) are never persisted.
|
||||
var transientHighlights: [RDEPUBHighlight] = []
|
||||
|
||||
var currentBookIdentifier: String?
|
||||
|
||||
var paginationToken = UUID()
|
||||
|
||||
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
|
||||
s.license = "MIT"
|
||||
# This podspec lives inside Sources/RDEpubReaderView, so paths must be
|
||||
# relative to this directory when it is consumed as a local pod.
|
||||
s.source_files = "**/*.swift"
|
||||
s.source_files = "{DocumentFormats,EPUBCore,EPUBTextRendering,EPUBUI,ReaderView}/**/*.swift"
|
||||
s.resource_bundles = {
|
||||
"RDEpubReaderViewAssets" => ["EPUBCore/Resources/**/*"]
|
||||
}
|
||||
@@ -19,4 +19,15 @@ Pod::Spec.new do |s|
|
||||
s.dependency "DTCoreText", "~> 1.6"
|
||||
s.dependency "SnapKit", "~> 5.7"
|
||||
s.requires_arc = true
|
||||
|
||||
s.subspec "Speech" do |speech|
|
||||
speech.source_files = "Speech/**/*.swift"
|
||||
speech.dependency "RDSpeechReaderView", "~> 0.1"
|
||||
end
|
||||
|
||||
s.subspec "AI" do |ai|
|
||||
ai.source_files = "AI/**/*.swift"
|
||||
ai.dependency "RDAIReaderView/NaturalLanguage", "~> 0.1"
|
||||
ai.dependency "RDAIReaderView/UI", "~> 0.1"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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