import UIKit import WebKit public final class RDEPUBPaginator: NSObject { private var parser: RDEPUBParser? private weak var hostingView: UIView? private var presentation = RDEPUBPresentationStyle( viewportSize: .zero, contentInsets: .zero, fontSize: 16, lineHeightMultiple: 1.5 ) private var completion: (([Int]) -> Void)? private var singlePageCountCompletion: ((Int) -> Void)? private var pageCounts: [Int] = [] private var measurementIndices: [Int] = [] private var currentMeasurementOffset = 0 private var pendingMeasurementValue = 1 private var measurementPass = 0 private var activeSessionID = UUID() private let debugScope = "PaginatorWebView" 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() } 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() } 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 ) } 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() } 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 ) } 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 ) ) } 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) } private func currentSpineIndexForMeasurement() -> Int? { measurementIndices[safe: currentMeasurementOffset] } 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() } 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() } 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] = [] 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) } } 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) } } private extension Array { subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil } }