From e01cbc169da6db9f10ce4002bab46ed22271b42e Mon Sep 17 00:00:00 2001 From: shenlei Date: Thu, 30 Jul 2026 18:33:04 +0900 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20EPUB=20=E4=B8=8E=20PDF=20?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E5=99=A8=E6=9C=97=E8=AF=BB=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF 朗读的逐词回调会在同一页内持续触发高亮,原实现每次都调用翻页入口,使用户在朗读过程中反复看到页面转场。现仅在目标页变化时定位,并对朗读产生的跨页定位禁用动画,保留高亮刷新而不重复翻页。\n\nEPUB/TXT 此前只提供内容源,缺少将朗读进度回写到阅读器的会话层,无法从当前阅读位置开始朗读、随内容跨页及显示临时高亮。新增 EPUB 朗读会话,使用 href 与 UTF-16 偏移恢复位置;文本重排 EPUB 与 TXT 按实际页面定位并高亮,Web EPUB 则按章节安全定位以避免词级回调导致跳页。URL 阅读器同时提供可选会话入口,图片型阅读器保持不可朗读。\n\n已执行 ReadViewDemo 的 Debug Simulator 构建,结果为 BUILD SUCCEEDED。 --- .../EPUBUI/RDEpubURLReaderController.swift | 3 +- .../Speech/RDEPUBSpeechSession.swift | 130 ++++++++++++++++++ .../Sources/RDPDFReaderViewController.swift | 6 +- .../Speech/RDPDFSpeechSession.swift | 4 +- Sources/RDSpeechReaderView/README.md | 10 +- 5 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 Sources/RDEpubReaderView/Speech/RDEPUBSpeechSession.swift diff --git a/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift b/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift index f491afe..c101459 100644 --- a/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift +++ b/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift @@ -225,7 +225,8 @@ public final class RDEpubURLReaderController: UIViewController { emitDemoState() } - private var readerController: RDEPUBReaderController? { + /// 供 `Speech` 子模块桥接已嵌入的文本阅读器;不作为基础 Pod 的公开 API。 + var readerController: RDEPUBReaderController? { embeddedController as? RDEPUBReaderController } diff --git a/Sources/RDEpubReaderView/Speech/RDEPUBSpeechSession.swift b/Sources/RDEpubReaderView/Speech/RDEPUBSpeechSession.swift new file mode 100644 index 0000000..c6451cb --- /dev/null +++ b/Sources/RDEpubReaderView/Speech/RDEPUBSpeechSession.swift @@ -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) } + } +} diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift index 825cdc4..41b9cdd 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift @@ -269,7 +269,11 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) { guard pageIndex >= 0, pageIndex < book.totalPages else { return } speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects) - goToPage(pageIndex, animated: animated) + // `willSpeakRangeOfSpeechString` 会在同一句内多次回调。相同页面只需刷新 + // 临时高亮,重复调用翻页入口会让仿真翻页在朗读过程中不断播放转场动画。 + if currentPageIndex != pageIndex { + goToPage(pageIndex, animated: animated) + } refreshPageViews(at: pageIndex) } diff --git a/Sources/RDPDFReaderView/Speech/RDPDFSpeechSession.swift b/Sources/RDPDFReaderView/Speech/RDPDFSpeechSession.swift index 2a03393..ad70843 100644 --- a/Sources/RDPDFReaderView/Speech/RDPDFSpeechSession.swift +++ b/Sources/RDPDFReaderView/Speech/RDPDFSpeechSession.swift @@ -45,7 +45,9 @@ public final class RDPDFSpeechSession: NSObject, RDSpeechReaderControllerDelegat guard let pageIndex = Int(range.unit.location.resourceIdentifier) else { return } reader?.showSpeechHighlight( pageIndex: pageIndex, - normalizedRects: contentProvider.normalizedRects(for: range) + normalizedRects: contentProvider.normalizedRects(for: range), + // 朗读跨页时只需同步定位,不能把词级高亮变成一次页面转场。 + animated: false ) } } diff --git a/Sources/RDSpeechReaderView/README.md b/Sources/RDSpeechReaderView/README.md index 184d4f4..cdfd3c1 100644 --- a/Sources/RDSpeechReaderView/README.md +++ b/Sources/RDSpeechReaderView/README.md @@ -37,10 +37,10 @@ try await session.start(from: 0) ## Use with EPUB ```swift -let provider = reader.makeSpeechContentProvider() -let speech = RDSpeechReaderController(contentProvider: provider) -try await speech.start(from: nil) +let session = reader.makeSpeechSession() +try await session.start() ``` -Speech locations use EPUB `href` plus a UTF-16 text offset. They remain stable -when the user changes fonts or page size, unlike screen page numbers. +EPUB/TXT 会话默认从当前阅读位置开始。文本重排 EPUB 与 TXT 会临时高亮当前朗读范围, +并且只在朗读跨页时定位;朗读位置使用 EPUB `href` 与 UTF-16 文本偏移,因此用户修改 +字体或页面大小后仍可稳定恢复。