Files
ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift
T
shenandshen 54798ba578 refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
2026-05-25 10:19:14 +08:00

384 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// RDEPUBPaginator.swift
// EPUB 离屏分页计算器
// 使用隐藏的 WKWebView 加载每个 spine 项,通过 JS 测量 scrollWidth
// 计算每章的页数。采用多轮延迟测量(0ms/80ms/180ms)确保布局稳定。
// 固定版式(Fixed Layout)直接返回 1 页。
import UIKit
import WebKit
/// EPUB 分页计算器,通过离屏 WebView 测量每个 spine 项的分页数
public final class RDEPUBPaginator: NSObject {
/// 当前解析器引用
private var parser: RDEPUBParser?
/// 承载 WebView 的父视图(弱引用)
private weak var hostingView: UIView?
/// 当前展示样式(视口尺寸、内边距、字号、行高)
private var presentation = RDEPUBPresentationStyle(
viewportSize: .zero,
contentInsets: .zero,
fontSize: 16,
lineHeightMultiple: 1.5
)
/// 全量分页完成回调(返回每个 spine 项的页数数组)
private var completion: (([Int]) -> Void)?
/// 单个 spine 项分页完成回调
private var singlePageCountCompletion: ((Int) -> Void)?
/// 各 spine 项的页数结果
private var pageCounts: [Int] = []
/// 待测量的 spine 项索引列表
private var measurementIndices: [Int] = []
/// 当前测量偏移量(在 measurementIndices 中的位置)
private var currentMeasurementOffset = 0
/// 当前 spine 项的待定页数(多轮测量取最大值)
private var pendingMeasurementValue = 1
/// 当前测量轮次(0/1/2 对应延迟 0ms/80ms/180ms
private var measurementPass = 0
/// 当前测量会话 ID,用于防并发和取消过期任务
private var activeSessionID = UUID()
/// 调试日志作用域标识
private let debugScope = "PaginatorWebView"
/// 用于分页测量的隐藏 WebView(非持久化数据存储,禁止用户交互)
private lazy var webView: WKWebView = {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = self
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = false
webView.scrollView.bounces = false
webView.scrollView.showsHorizontalScrollIndicator = false
webView.scrollView.showsVerticalScrollIndicator = false
webView.isUserInteractionEnabled = false
if #available(iOS 16.4, *) {
webView.isInspectable = true
} else {
// Fallback on earlier versions
}
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
return webView
}()
public override init() {
super.init()
}
deinit {
cleanupMeasurementState()
webView.navigationDelegate = nil
webView.stopLoading()
webView.removeFromSuperview()
}
/// 计算所有 spine 项的分页数
/// - Parameters:
/// - parser: EPUB 解析器
/// - hostingView: 承载 WebView 的父视图
/// - presentation: 展示样式(视口、内边距、字号、行高)
/// - completion: 完成回调,返回每个 spine 项的页数数组
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
presentation: RDEPUBPresentationStyle,
completion: @escaping ([Int]) -> Void
) {
self.parser = parser
self.hostingView = hostingView
self.presentation = presentation
self.completion = completion
self.singlePageCountCompletion = nil
self.pageCounts = Array(repeating: 1, count: parser.spine.count)
self.measurementIndices = Array(parser.spine.indices)
self.currentMeasurementOffset = 0
self.activeSessionID = UUID()
RDEPUBWebViewDebug.log(debugScope, message: "calculate session=\(activeSessionID) spineCount=\(parser.spine.count)")
guard parser.metadata.layout != .fixed else {
completion(self.pageCounts)
return
}
webView.navigationDelegate = self
if webView.superview == nil {
hostingView.addSubview(webView)
}
webView.frame = CGRect(origin: .zero, size: presentation.viewportSize)
measureNextSpineItem()
}
/// 便捷方法:使用独立参数计算所有 spine 项的分页数
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
viewportSize: CGSize,
padding: UIEdgeInsets,
fontSize: CGFloat,
lineHeightMultiple: CGFloat,
completion: @escaping ([Int]) -> Void
) {
calculate(
parser: parser,
hostingView: hostingView,
presentation: RDEPUBPresentationStyle(
viewportSize: viewportSize,
contentInsets: padding,
fontSize: fontSize,
lineHeightMultiple: lineHeightMultiple
),
completion: completion
)
}
/// 仅计算单个 spine 项的分页数
/// - Parameters:
/// - parser: EPUB 解析器
/// - spineIndex: 要测量的 spine 项索引
/// - hostingView: 承载 WebView 的父视图
/// - presentation: 展示样式
/// - completion: 完成回调,返回该 spine 项的页数
public func calculateSingleSpinePageCount(
parser: RDEPUBParser,
spineIndex: Int,
hostingView: UIView,
presentation: RDEPUBPresentationStyle,
completion: @escaping (Int) -> Void
) {
self.parser = parser
self.hostingView = hostingView
self.presentation = presentation
self.completion = nil
self.singlePageCountCompletion = completion
self.pageCounts = Array(repeating: 1, count: parser.spine.count)
self.measurementIndices = parser.spine.indices.contains(spineIndex) ? [spineIndex] : []
self.currentMeasurementOffset = 0
self.activeSessionID = UUID()
RDEPUBWebViewDebug.log(debugScope, message: "calculateSingle session=\(activeSessionID) spineIndex=\(spineIndex)")
guard parser.metadata.layout != .fixed, !measurementIndices.isEmpty else {
completion(1)
return
}
webView.navigationDelegate = self
if webView.superview == nil {
hostingView.addSubview(webView)
}
webView.frame = CGRect(origin: .zero, size: presentation.viewportSize)
measureNextSpineItem()
}
/// 便捷方法:使用独立参数计算单个 spine 项的分页数
public func calculateSingleSpinePageCount(
parser: RDEPUBParser,
spineIndex: Int,
hostingView: UIView,
viewportSize: CGSize,
padding: UIEdgeInsets,
fontSize: CGFloat,
lineHeightMultiple: CGFloat,
completion: @escaping (Int) -> Void
) {
calculateSingleSpinePageCount(
parser: parser,
spineIndex: spineIndex,
hostingView: hostingView,
presentation: RDEPUBPresentationStyle(
viewportSize: viewportSize,
contentInsets: padding,
fontSize: fontSize,
lineHeightMultiple: lineHeightMultiple
),
completion: completion
)
}
/// 静态方法:将分页 CSS 注入到 HTML 字符串中(用于预渲染场景)
public static func injectPaginationCSS(
into html: String,
viewportSize: CGSize,
padding: UIEdgeInsets,
fontSize: CGFloat,
lineHeightMultiple: CGFloat,
themeBackgroundColor: String? = nil,
themeTextColor: String? = nil
) -> String {
RDEPUBStyleSheetBuilder.injectPaginationCSS(
into: html,
presentation: RDEPUBPresentationStyle(
viewportSize: viewportSize,
contentInsets: padding,
fontSize: fontSize,
lineHeightMultiple: lineHeightMultiple,
themeBackgroundColor: themeBackgroundColor,
themeTextColor: themeTextColor
)
)
}
/// 测量下一个 spine 项:加载文件到 WebView,跳过不可渲染或文件缺失的项
private func measureNextSpineItem() {
guard let parser else {
finishIfNeeded()
return
}
guard currentMeasurementOffset < measurementIndices.count else {
finishIfNeeded()
return
}
let currentSpineIndex = measurementIndices[currentMeasurementOffset]
let item = parser.spine[currentSpineIndex]
guard isRenderablePage(item: item),
let opfDirectoryURL = parser.opfDirectoryURL else {
pageCounts[currentSpineIndex] = 1
currentMeasurementOffset += 1
measureNextSpineItem()
return
}
let fileURL = opfDirectoryURL.appendingPathComponent(item.href)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
pageCounts[currentSpineIndex] = 1
currentMeasurementOffset += 1
measureNextSpineItem()
return
}
pendingMeasurementValue = 1
measurementPass = 0
let readAccessURL = parser.extractionRootURL ?? opfDirectoryURL
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "measure-load", url: fileURL)
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
}
/// 获取当前正在测量的 spine 项索引
private func currentSpineIndexForMeasurement() -> Int? {
measurementIndices[safe: currentMeasurementOffset]
}
/// 检查是否所有 spine 项都已测量完毕,触发对应的完成回调
private func finishIfNeeded() {
if let singlePageCountCompletion {
let measuredSpineIndex = measurementIndices.first ?? 0
singlePageCountCompletion(max(1, pageCounts[safe: measuredSpineIndex] ?? 1))
self.singlePageCountCompletion = nil
cleanupMeasurementState()
return
}
guard let completion else { return }
completion(pageCounts)
self.completion = nil
cleanupMeasurementState()
}
/// 清理测量状态,释放 WebView 和所有引用
private func cleanupMeasurementState() {
parser = nil
hostingView = nil
completion = nil
singlePageCountCompletion = nil
measurementIndices = []
currentMeasurementOffset = 0
pendingMeasurementValue = 1
measurementPass = 0
activeSessionID = UUID()
webView.stopLoading()
webView.navigationDelegate = nil
webView.removeFromSuperview()
}
/// 判断 spine 项是否可渲染(仅 HTML/XHTML/XML 类型可分页)
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
let mediaType = item.mediaType.lowercased()
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
}
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
private func scheduleMeasurementPass() {
let sessionID = activeSessionID
let delays: [TimeInterval] = [0.0, 0.08, 0.18]
guard measurementPass < delays.count else {
guard sessionID == activeSessionID,
let currentSpineIndex = currentSpineIndexForMeasurement() else {
return
}
pageCounts[currentSpineIndex] = max(1, pendingMeasurementValue)
currentMeasurementOffset += 1
measureNextSpineItem()
return
}
let delay = delays[measurementPass]
measurementPass += 1
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self, self.activeSessionID == sessionID else { return }
self.measureCurrentDocument(sessionID: sessionID)
}
}
/// 执行当前文档的测量:通过 JS 获取 scrollWidth 并计算页数
private func measureCurrentDocument(sessionID: UUID) {
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
webView.evaluateJavaScript(script) { [weak self] value, _ in
guard let self else { return }
guard self.activeSessionID == sessionID,
self.currentSpineIndexForMeasurement() != nil else {
return
}
if let number = value as? NSNumber {
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
} else if let intValue = value as? Int {
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
}
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
self.scheduleMeasurementPass()
}
}
}
extension RDEPUBPaginator: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
}
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
guard currentSpineIndexForMeasurement() != nil else { return }
scheduleMeasurementPass()
}
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0
pageCounts[currentSpineIndex] = 1
currentMeasurementOffset += 1
measureNextSpineItem()
}
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0
pageCounts[currentSpineIndex] = 1
currentMeasurementOffset += 1
measureNextSpineItem()
}
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
}
}
/// Array 安全下标扩展,越界时返回 nil 而非崩溃
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}