修复 EPUB 与 PDF 阅读器朗读交互
PDF 朗读的逐词回调会在同一页内持续触发高亮,原实现每次都调用翻页入口,使用户在朗读过程中反复看到页面转场。现仅在目标页变化时定位,并对朗读产生的跨页定位禁用动画,保留高亮刷新而不重复翻页。\n\nEPUB/TXT 此前只提供内容源,缺少将朗读进度回写到阅读器的会话层,无法从当前阅读位置开始朗读、随内容跨页及显示临时高亮。新增 EPUB 朗读会话,使用 href 与 UTF-16 偏移恢复位置;文本重排 EPUB 与 TXT 按实际页面定位并高亮,Web EPUB 则按章节安全定位以避免词级回调导致跳页。URL 阅读器同时提供可选会话入口,图片型阅读器保持不可朗读。\n\n已执行 ReadViewDemo 的 Debug Simulator 构建,结果为 BUILD SUCCEEDED。
This commit is contained in:
@@ -225,7 +225,8 @@ public final class RDEpubURLReaderController: UIViewController {
|
|||||||
emitDemoState()
|
emitDemoState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var readerController: RDEPUBReaderController? {
|
/// 供 `Speech` 子模块桥接已嵌入的文本阅读器;不作为基础 Pod 的公开 API。
|
||||||
|
var readerController: RDEPUBReaderController? {
|
||||||
embeddedController as? RDEPUBReaderController
|
embeddedController as? RDEPUBReaderController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import Foundation
|
||||||
|
import RDSpeechReaderView
|
||||||
|
|
||||||
|
/// EPUB/TXT 朗读会话。它负责把通用朗读进度映射回阅读器的页码和临时高亮,
|
||||||
|
/// 但不会写入用户的高亮或批注持久化数据。
|
||||||
|
@MainActor
|
||||||
|
public final class RDEPUBSpeechSession: NSObject, RDSpeechReaderControllerDelegate {
|
||||||
|
public let controller: RDSpeechReaderController
|
||||||
|
public let contentProvider: RDEPUBSpeechContentProvider
|
||||||
|
public var onStateChange: ((RDSpeechReaderState) -> Void)?
|
||||||
|
|
||||||
|
private weak var reader: RDEPUBReaderController?
|
||||||
|
|
||||||
|
init(reader: RDEPUBReaderController, configuration: RDSpeechReaderConfiguration) {
|
||||||
|
self.reader = reader
|
||||||
|
contentProvider = reader.makeSpeechContentProvider()
|
||||||
|
controller = RDSpeechReaderController(contentProvider: contentProvider, configuration: configuration)
|
||||||
|
super.init()
|
||||||
|
controller.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认从当前阅读位置所在章节开始;可传入已保存的朗读位置继续播放。
|
||||||
|
public func start(from location: RDSpeechLocation? = nil) async throws {
|
||||||
|
try await controller.start(from: location ?? currentSpeechLocation())
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() { controller.pause() }
|
||||||
|
public func resume() { controller.resume() }
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
controller.stop()
|
||||||
|
reader?.clearTransientHighlights()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func speechReaderController(_ controller: RDSpeechReaderController, didChange state: RDSpeechReaderState) {
|
||||||
|
switch state {
|
||||||
|
case .idle, .finished, .failed:
|
||||||
|
reader?.clearTransientHighlights()
|
||||||
|
case .preparing, .speaking, .paused:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
onStateChange?(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func speechReaderController(_ controller: RDSpeechReaderController, willSpeak range: RDSpeechSpokenRange) {
|
||||||
|
guard let reader else { return }
|
||||||
|
let href = range.unit.location.resourceIdentifier
|
||||||
|
let absoluteRange = NSRange(
|
||||||
|
location: range.unit.textRange.location + range.range.location,
|
||||||
|
length: max(range.range.length, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
if let chapterData = reader.textChapterData(forNormalizedHref: href),
|
||||||
|
chapterData.attributedContent.length > 0 {
|
||||||
|
let boundedLocation = min(max(absoluteRange.location, 0), max(chapterData.attributedContent.length - 1, 0))
|
||||||
|
let boundedLength = min(max(absoluteRange.length, 1), max(chapterData.attributedContent.length - boundedLocation, 1))
|
||||||
|
let boundedRange = NSRange(location: boundedLocation, length: boundedLength)
|
||||||
|
|
||||||
|
if let page = chapterData.page(containing: boundedRange.location),
|
||||||
|
reader.currentPageNumber != page.absolutePageIndex + 1 {
|
||||||
|
_ = reader.go(toPageNumber: page.absolutePageIndex + 1, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
let highlight = RDEPUBHighlight(
|
||||||
|
id: "rd-speech-highlight",
|
||||||
|
bookIdentifier: reader.currentBookIdentifier,
|
||||||
|
location: chapterData.location(for: boundedRange, bookIdentifier: reader.currentBookIdentifier),
|
||||||
|
text: chapterData.attributedContent.attributedSubstring(from: boundedRange).string,
|
||||||
|
rangeInfo: RDEPUBTextOffsetRangeInfo(
|
||||||
|
href: chapterData.href,
|
||||||
|
start: boundedRange.location,
|
||||||
|
end: NSMaxRange(boundedRange)
|
||||||
|
).jsonString(),
|
||||||
|
style: .highlight,
|
||||||
|
color: "#86D7FF"
|
||||||
|
)
|
||||||
|
reader.setTransientHighlights([highlight])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web EPUB 不具备与原始 HTML 文本严格一致的原生文本索引。按章节定位,
|
||||||
|
// 并禁用临时文字高亮,避免把一个词级回调误判为多次页面跳转。
|
||||||
|
if reader.currentLocation?.href != href {
|
||||||
|
reader.go(to: RDEPUBLocation(
|
||||||
|
bookIdentifier: reader.currentBookIdentifier,
|
||||||
|
href: href,
|
||||||
|
progression: progression(for: range)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
reader.clearTransientHighlights()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentSpeechLocation() -> RDSpeechLocation? {
|
||||||
|
guard let reader,
|
||||||
|
let location = reader.currentLocation else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let offset = reader.textChapterData(forNormalizedHref: location.href)
|
||||||
|
.flatMap { $0.absoluteRange(for: location)?.location }
|
||||||
|
?? 0
|
||||||
|
return RDSpeechLocation(
|
||||||
|
bookIdentifier: contentProvider.speechBookDescriptor().identifier,
|
||||||
|
resourceIdentifier: location.href,
|
||||||
|
textOffset: offset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func progression(for range: RDSpeechSpokenRange) -> Double {
|
||||||
|
let totalLength = max(range.unit.textRange.location + range.unit.textRange.length, 1)
|
||||||
|
let offset = range.unit.textRange.location + range.range.location
|
||||||
|
return min(max(Double(offset) / Double(totalLength), 0), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension RDEPUBReaderController {
|
||||||
|
func makeSpeechSession(
|
||||||
|
configuration: RDSpeechReaderConfiguration = .default
|
||||||
|
) -> RDEPUBSpeechSession {
|
||||||
|
RDEPUBSpeechSession(reader: self, configuration: configuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension RDEpubURLReaderController {
|
||||||
|
/// CBZ 等图片阅读器没有可朗读的文字阅读器时返回 `nil`。
|
||||||
|
func makeSpeechSession(
|
||||||
|
configuration: RDSpeechReaderConfiguration = .default
|
||||||
|
) -> RDEPUBSpeechSession? {
|
||||||
|
readerController.map { $0.makeSpeechSession(configuration: configuration) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,7 +269,11 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
|||||||
public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) {
|
public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) {
|
||||||
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
|
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
|
||||||
speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects)
|
speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects)
|
||||||
goToPage(pageIndex, animated: animated)
|
// `willSpeakRangeOfSpeechString` 会在同一句内多次回调。相同页面只需刷新
|
||||||
|
// 临时高亮,重复调用翻页入口会让仿真翻页在朗读过程中不断播放转场动画。
|
||||||
|
if currentPageIndex != pageIndex {
|
||||||
|
goToPage(pageIndex, animated: animated)
|
||||||
|
}
|
||||||
refreshPageViews(at: pageIndex)
|
refreshPageViews(at: pageIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ public final class RDPDFSpeechSession: NSObject, RDSpeechReaderControllerDelegat
|
|||||||
guard let pageIndex = Int(range.unit.location.resourceIdentifier) else { return }
|
guard let pageIndex = Int(range.unit.location.resourceIdentifier) else { return }
|
||||||
reader?.showSpeechHighlight(
|
reader?.showSpeechHighlight(
|
||||||
pageIndex: pageIndex,
|
pageIndex: pageIndex,
|
||||||
normalizedRects: contentProvider.normalizedRects(for: range)
|
normalizedRects: contentProvider.normalizedRects(for: range),
|
||||||
|
// 朗读跨页时只需同步定位,不能把词级高亮变成一次页面转场。
|
||||||
|
animated: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ try await session.start(from: 0)
|
|||||||
## Use with EPUB
|
## Use with EPUB
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
let provider = reader.makeSpeechContentProvider()
|
let session = reader.makeSpeechSession()
|
||||||
let speech = RDSpeechReaderController(contentProvider: provider)
|
try await session.start()
|
||||||
try await speech.start(from: nil)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Speech locations use EPUB `href` plus a UTF-16 text offset. They remain stable
|
EPUB/TXT 会话默认从当前阅读位置开始。文本重排 EPUB 与 TXT 会临时高亮当前朗读范围,
|
||||||
when the user changes fonts or page size, unlike screen page numbers.
|
并且只在朗读跨页时定位;朗读位置使用 EPUB `href` 与 UTF-16 文本偏移,因此用户修改
|
||||||
|
字体或页面大小后仍可稳定恢复。
|
||||||
|
|||||||
Reference in New Issue
Block a user