// 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 = RDEPUBWebViewDebug.isInspectableEnabled } 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") } /// 各轮测量记录值(用于稳定性日志) private var measurementPassValues: [Int] = [] /// 调度多轮测量: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 } let finalValue = max(1, pendingMeasurementValue) // 测量稳定性日志:三轮差异过大时告警 if measurementPassValues.count >= 2 { let minVal = measurementPassValues.min() ?? 1 let maxVal = measurementPassValues.max() ?? 1 if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 { RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)") } } pageCounts[currentSpineIndex] = finalValue currentMeasurementOffset += 1 measurementPassValues = [] 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) self.measurementPassValues.append(number.intValue) } else if let intValue = value as? Int { self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue) self.measurementPassValues.append(intValue) } RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)") self.scheduleMeasurementPass() } } } extension RDEPUBPaginator: WKNavigationDelegate { public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = navigationAction.request.url else { decisionHandler(.cancel) return } let scheme = url.scheme?.lowercased() ?? "" if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme { decisionHandler(.allow) } else { RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)") decisionHandler(.cancel) } } 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 } }