feat: chapter runtime refactoring and related updates
- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator - Update EPUB core: parser, reading session, JS bridge, navigator layout - Update reader controller: data source, location resolution, persistence - Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator - Remove deprecated navigation state machine and pagination state - Update text rendering: book cache, HTML normalizer - Update UI: text content view, dark image adjuster, text selection controller - Update settings and reader configuration - Add CODE_REVIEW.md and AUDIT_FINAL.md documentation - Update pod dependencies (remove SSAlertSwift, SnapKit) - Update podspec and pod configuration files Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -166,10 +166,23 @@ enum RDEPUBJavaScriptBridge {
|
||||
return string
|
||||
}
|
||||
|
||||
/// Encodes a string value as a safe JavaScript string literal.
|
||||
///
|
||||
/// Uses JSON serialization to handle escaping of quotes, backslashes, control
|
||||
/// characters, and Unicode, then strips the array wrapper to produce a standalone
|
||||
/// JS string literal (including surrounding quotes).
|
||||
///
|
||||
/// - Parameter value: The string to encode, or `nil` which produces the JS keyword `null`.
|
||||
/// - Returns: A JavaScript string literal safe for inline embedding in JS source.
|
||||
private static func javaScriptStringLiteral(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
return jsonString(from: [value], fallback: "[null]")
|
||||
.replacingOccurrences(of: "[", with: "")
|
||||
.replacingOccurrences(of: "]", with: "")
|
||||
guard JSONSerialization.isValidJSONObject([value]),
|
||||
let data = try? JSONSerialization.data(withJSONObject: [value], options: []),
|
||||
let arrayString = String(data: data, encoding: .utf8) else {
|
||||
return "null"
|
||||
}
|
||||
// JSON encodes ["value"] as `["value"]`. Drop the leading `[` and trailing `]`
|
||||
// to yield `"value"` — a valid JS string literal with all special characters escaped.
|
||||
return String(arrayString.dropFirst().dropLast())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,20 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
return CGSize(width: width, height: containerSize.height)
|
||||
}
|
||||
|
||||
/// Content insets that account for safe area (Dynamic Island / notch) on iPhone.
|
||||
/// Uses the larger of safeAreaInsets and reflowableContentInsets for top/bottom
|
||||
/// to ensure content is never hidden under the Dynamic Island or home indicator.
|
||||
public var safeReflowableContentInsets: UIEdgeInsets {
|
||||
let top = max(safeAreaInsets.top, reflowableContentInsets.top)
|
||||
let bottom = max(safeAreaInsets.bottom, reflowableContentInsets.bottom)
|
||||
let left = max(safeAreaInsets.left, reflowableContentInsets.left)
|
||||
let right = max(safeAreaInsets.right, reflowableContentInsets.right)
|
||||
return UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)
|
||||
}
|
||||
|
||||
/// Fixed layout content insets that respect safe areas on all devices including iPhone.
|
||||
public var fixedContentInset: UIEdgeInsets {
|
||||
var insets = safeAreaInsets
|
||||
if userInterfaceIdiom != .phone {
|
||||
insets = .zero
|
||||
}
|
||||
|
||||
let horizontalInsets = max(insets.left, insets.right)
|
||||
insets.left = horizontalInsets
|
||||
insets.right = horizontalInsets
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBNavigatorState: String, Codable {
|
||||
|
||||
case initializing
|
||||
|
||||
case loading
|
||||
|
||||
case idle
|
||||
|
||||
case jumping
|
||||
|
||||
case moving
|
||||
|
||||
case repaginating
|
||||
|
||||
public var isStableForSnapshotApplication: Bool {
|
||||
self == .idle
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,11 @@ extension RDEPUBParser {
|
||||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
||||
|
||||
if fileManager.fileExists(atPath: extractionURL.path) {
|
||||
return extractionURL
|
||||
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
|
||||
if fileManager.fileExists(atPath: containerURL.path) {
|
||||
return extractionURL
|
||||
}
|
||||
try? fileManager.removeItem(at: extractionURL)
|
||||
}
|
||||
|
||||
guard let archive = Archive(url: epubURL, accessMode: .read) else {
|
||||
@@ -38,24 +42,50 @@ extension RDEPUBParser {
|
||||
|
||||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
||||
|
||||
for entry in archive {
|
||||
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
|
||||
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
|
||||
}
|
||||
switch entry.type {
|
||||
case .directory:
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
case .file:
|
||||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
_ = try archive.extract(entry, to: destinationURL)
|
||||
case .symlink:
|
||||
continue
|
||||
do {
|
||||
for entry in archive {
|
||||
if shouldSkipEntry(entry.path) { continue }
|
||||
|
||||
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
|
||||
if isCriticalEntry(entry.path) {
|
||||
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch entry.type {
|
||||
case .directory:
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
case .file:
|
||||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
_ = try archive.extract(entry, to: destinationURL)
|
||||
case .symlink:
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: extractionURL)
|
||||
throw error
|
||||
}
|
||||
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
private func isCriticalEntry(_ entryPath: String) -> Bool {
|
||||
let lowercased = entryPath.lowercased()
|
||||
return lowercased == "mimetype"
|
||||
|| lowercased.hasPrefix("meta-inf/")
|
||||
|| lowercased.hasSuffix(".opf")
|
||||
}
|
||||
|
||||
private func shouldSkipEntry(_ entryPath: String) -> Bool {
|
||||
let lowercased = entryPath.lowercased()
|
||||
if lowercased.hasPrefix("__macosx/") { return true }
|
||||
if lowercased.hasPrefix(".ds_store") { return true }
|
||||
if lowercased.contains("/.ds_store") { return true }
|
||||
if lowercased.hasSuffix("/thumbs.db") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
|
||||
|
||||
if entryPath.hasPrefix("/") {
|
||||
|
||||
@@ -21,6 +21,64 @@ public final class RDEPUBParser {
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Cache Management
|
||||
|
||||
/// The base directory used for EPUB extraction caches.
|
||||
public static var extractionCacheBaseDirectory: URL {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
}
|
||||
|
||||
/// Removes all EPUB extraction caches from disk.
|
||||
/// Call this when the host app wants to free disk space, e.g. on didReceiveMemoryWarning
|
||||
/// or during cleanup when the reader is no longer needed.
|
||||
public static func cleanExtractionCache() throws {
|
||||
let fileManager = FileManager.default
|
||||
let baseURL = extractionCacheBaseDirectory
|
||||
guard fileManager.fileExists(atPath: baseURL.path) else { return }
|
||||
try fileManager.removeItem(at: baseURL)
|
||||
}
|
||||
|
||||
/// Evicts extraction caches until total disk usage is below the given threshold,
|
||||
/// removing least-recently-accessed directories first.
|
||||
/// - Parameter maxBytes: Maximum total bytes for all extraction caches. Defaults to 500 MB.
|
||||
public static func evictExtractionCache(maxBytes: UInt64 = 500 * 1024 * 1024) throws {
|
||||
let fileManager = FileManager.default
|
||||
let baseURL = extractionCacheBaseDirectory
|
||||
guard fileManager.fileExists(atPath: baseURL.path) else { return }
|
||||
|
||||
let contents = try fileManager.contentsOfDirectory(
|
||||
at: baseURL,
|
||||
includingPropertiesForKeys: [.contentAccessDateKey, .isDirectoryKey],
|
||||
options: .skipsHiddenFiles
|
||||
)
|
||||
|
||||
var entries: [(url: URL, accessDate: Date, size: UInt64)] = []
|
||||
var totalSize: UInt64 = 0
|
||||
|
||||
for directoryURL in contents {
|
||||
guard let resourceValues = try? directoryURL.resourceValues(forKeys: [.isDirectoryKey, .contentAccessDateKey]),
|
||||
resourceValues.isDirectory == true else {
|
||||
continue
|
||||
}
|
||||
let directorySize = directoryURL.directorySize
|
||||
let accessDate = resourceValues.contentAccessDate ?? Date.distantPast
|
||||
entries.append((url: directoryURL, accessDate: accessDate, size: directorySize))
|
||||
totalSize += directorySize
|
||||
}
|
||||
|
||||
guard totalSize > maxBytes else { return }
|
||||
|
||||
entries.sort { $0.accessDate < $1.accessDate }
|
||||
|
||||
for entry in entries {
|
||||
guard totalSize > maxBytes else { break }
|
||||
try? fileManager.removeItem(at: entry.url)
|
||||
totalSize -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
public func makePublication() -> RDEPUBPublication {
|
||||
RDEPUBPublication(parser: self)
|
||||
}
|
||||
@@ -75,4 +133,24 @@ public final class RDEPUBParser {
|
||||
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
|
||||
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Directory Size Extension
|
||||
|
||||
private extension URL {
|
||||
var directorySize: UInt64 {
|
||||
let fileManager = FileManager.default
|
||||
guard let enumerator = fileManager.enumerator(at: self, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles], errorHandler: nil) else {
|
||||
return 0
|
||||
}
|
||||
var totalSize: UInt64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let fileSize = resourceValues.fileSize else {
|
||||
continue
|
||||
}
|
||||
totalSize += UInt64(fileSize)
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,10 @@ public final class RDEPUBReadingSession {
|
||||
|
||||
public let publication: RDEPUBPublication
|
||||
|
||||
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
|
||||
|
||||
public private(set) var activePages: [EPUBPage] = []
|
||||
|
||||
public private(set) var activeChapters: [EPUBChapterInfo] = []
|
||||
|
||||
public private(set) var stagedPages: [EPUBPage]?
|
||||
|
||||
public private(set) var stagedChapters: [EPUBChapterInfo]?
|
||||
|
||||
public private(set) var stagedRestoreLocation: RDEPUBLocation?
|
||||
|
||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||
|
||||
public private(set) var pendingNavigationPageNum: Int?
|
||||
@@ -37,16 +29,10 @@ public final class RDEPUBReadingSession {
|
||||
publication.resourceResolver
|
||||
}
|
||||
|
||||
public func transition(to state: RDEPUBNavigatorState) {
|
||||
navigatorState = state
|
||||
}
|
||||
|
||||
public func resetRuntimeState() {
|
||||
navigatorState = .initializing
|
||||
activePages = []
|
||||
activeChapters = []
|
||||
clearPendingNavigation()
|
||||
clearStagedSnapshot()
|
||||
currentViewport = nil
|
||||
currentReadingContext = nil
|
||||
}
|
||||
@@ -56,36 +42,6 @@ public final class RDEPUBReadingSession {
|
||||
activeChapters = snapshot.chapters
|
||||
}
|
||||
|
||||
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
|
||||
stagedPages = snapshot.pages
|
||||
stagedChapters = snapshot.chapters
|
||||
stagedRestoreLocation = restoreLocation
|
||||
}
|
||||
|
||||
public func stagedSnapshot() -> PaginationSnapshot? {
|
||||
guard let stagedPages, let stagedChapters else {
|
||||
return nil
|
||||
}
|
||||
return (stagedPages, stagedChapters)
|
||||
}
|
||||
|
||||
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
|
||||
guard navigatorState.isStableForSnapshotApplication,
|
||||
let stagedPages,
|
||||
let stagedChapters else {
|
||||
return nil
|
||||
}
|
||||
let restoreLocation = stagedRestoreLocation
|
||||
clearStagedSnapshot()
|
||||
return ((stagedPages, stagedChapters), restoreLocation)
|
||||
}
|
||||
|
||||
public func clearStagedSnapshot() {
|
||||
stagedPages = nil
|
||||
stagedChapters = nil
|
||||
stagedRestoreLocation = nil
|
||||
}
|
||||
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
@@ -241,7 +197,6 @@ public final class RDEPUBReadingSession {
|
||||
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
|
||||
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
|
||||
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
|
||||
transition(to: .jumping)
|
||||
return pageIndex + 1
|
||||
}
|
||||
|
||||
@@ -292,9 +247,6 @@ public final class RDEPUBReadingSession {
|
||||
if pendingNavigationPageNum == pageNumber {
|
||||
clearPendingNavigation()
|
||||
}
|
||||
if navigatorState == .jumping || navigatorState == .moving {
|
||||
transition(to: .idle)
|
||||
}
|
||||
}
|
||||
|
||||
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
@@ -19,6 +18,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
|
||||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
||||
|
||||
private let ioQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.io", qos: .utility)
|
||||
|
||||
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
||||
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
|
||||
private static var streamedResponseCount = 0
|
||||
@@ -69,6 +70,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
expectedContentLength: 0,
|
||||
textEncodingName: Self.textEncodingName(for: requestURL.pathExtension)
|
||||
)
|
||||
// H-08 fix: These calls happen synchronously in webView(_:start:) which
|
||||
// runs on the same queue WebKit calls start on, so no thread violation here.
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(Data())
|
||||
urlSchemeTask.didFinish()
|
||||
@@ -116,61 +119,153 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
return size
|
||||
}
|
||||
|
||||
/// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue,
|
||||
/// ensuring they run on the same serial queue that WebKit calls start() on.
|
||||
/// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead
|
||||
/// of silently returning, satisfying the protocol requirement that every started
|
||||
/// task must receive a completion callback.
|
||||
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard isTaskActive(taskID) else { return }
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: data.count,
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
Self.recordInMemoryResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
||||
} catch {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
Self.recordFailure()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
||||
urlSchemeTask.didFailWithError(error)
|
||||
ioQueue.async { [weak self] in
|
||||
guard let self else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard self.isTaskActive(taskID) else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: data.count,
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
self.dispatchTaskSuccessCallback(
|
||||
taskID: taskID,
|
||||
urlSchemeTask: urlSchemeTask
|
||||
) {
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
}
|
||||
Self.recordInMemoryResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
||||
} catch {
|
||||
guard self.isTaskActive(taskID) else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
Self.recordFailure()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(error)
|
||||
}
|
||||
}
|
||||
self.clearTask(taskID)
|
||||
}
|
||||
clearTask(taskID)
|
||||
}
|
||||
|
||||
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: Int(fileSize),
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
urlSchemeTask.didReceive(response)
|
||||
ioQueue.async { [weak self] in
|
||||
guard let self else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: Int(fileSize),
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
self.dispatchTaskCallbackIfActive(taskID: taskID) {
|
||||
urlSchemeTask.didReceive(response)
|
||||
}
|
||||
|
||||
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
Self.recordFailure()
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
clearTask(taskID)
|
||||
return
|
||||
}
|
||||
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
|
||||
guard self.isTaskActive(taskID) else {
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
Self.recordFailure()
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
}
|
||||
self.clearTask(taskID)
|
||||
return
|
||||
}
|
||||
|
||||
let chunkSize = 65_536
|
||||
defer {
|
||||
fileHandle.closeFile()
|
||||
clearTask(taskID)
|
||||
let chunkSize = 65_536
|
||||
var streamingCompleted = false
|
||||
defer {
|
||||
fileHandle.closeFile()
|
||||
if !streamingCompleted {
|
||||
// Task was cancelled during streaming; didFailWithError already sent above
|
||||
// or will be sent by the guard check below. No need to send again.
|
||||
}
|
||||
self.clearTask(taskID)
|
||||
}
|
||||
while true {
|
||||
guard self.isTaskActive(taskID) else {
|
||||
// H-08 fix: Task was cancelled. Send didFailWithError on main queue.
|
||||
DispatchQueue.main.async {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
}
|
||||
return
|
||||
}
|
||||
let data = fileHandle.readData(ofLength: chunkSize)
|
||||
if data.isEmpty { break }
|
||||
self.dispatchTaskCallbackIfActive(taskID: taskID) {
|
||||
urlSchemeTask.didReceive(data)
|
||||
}
|
||||
}
|
||||
streamingCompleted = true
|
||||
self.dispatchTaskSuccessCallback(
|
||||
taskID: taskID,
|
||||
urlSchemeTask: urlSchemeTask
|
||||
) {
|
||||
urlSchemeTask.didFinish()
|
||||
}
|
||||
Self.recordStreamedResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
||||
}
|
||||
while true {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
let data = fileHandle.readData(ofLength: chunkSize)
|
||||
if data.isEmpty { break }
|
||||
urlSchemeTask.didReceive(data)
|
||||
}
|
||||
|
||||
private func dispatchTaskCallbackIfActive(taskID: ObjectIdentifier, _ callback: @escaping () -> Void) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.isTaskActive(taskID) else { return }
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
private func dispatchTaskSuccessCallback(
|
||||
taskID: ObjectIdentifier,
|
||||
urlSchemeTask: any WKURLSchemeTask,
|
||||
_ callback: @escaping () -> Void
|
||||
) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
return
|
||||
}
|
||||
guard self.isTaskActive(taskID) else {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
||||
self.clearTask(taskID)
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
urlSchemeTask.didFinish()
|
||||
Self.recordStreamedResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
||||
}
|
||||
|
||||
private static func recordStreamedResponse() {
|
||||
|
||||
@@ -36,11 +36,18 @@ extension RDEPUBWebView {
|
||||
guard let self else { return }
|
||||
self.handleSelectionMenuAction(action)
|
||||
}
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||
]
|
||||
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
|
||||
if #available(iOS 16.0, *) {
|
||||
let interaction = UIEditMenuInteraction(delegate: webView)
|
||||
webView.addInteraction(interaction)
|
||||
webView._editMenuInteraction = interaction
|
||||
} else {
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||
]
|
||||
}
|
||||
webView.navigationDelegate = self
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
webView.scrollView.showsHorizontalScrollIndicator = false
|
||||
@@ -82,6 +89,10 @@ extension RDEPUBWebView {
|
||||
if let webView {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
}
|
||||
// M-15: Clean up UIEditMenuInteraction on iOS 16+
|
||||
if #available(iOS 16.0, *), let interaction = (webView as? RDEPUBAnnotationWebView)?._editMenuInteraction as? UIEditMenuInteraction {
|
||||
webView?.removeInteraction(interaction)
|
||||
}
|
||||
webView?.navigationDelegate = nil
|
||||
if let userContentController = webView?.configuration.userContentController {
|
||||
RDEPUBJavaScriptBridge.messageNames.forEach {
|
||||
|
||||
@@ -33,6 +33,10 @@ final class RDEPUBAnnotationWebView: WKWebView {
|
||||
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
||||
// Stored as Any? to avoid @available on stored property restriction.
|
||||
var _editMenuInteraction: Any?
|
||||
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
true
|
||||
}
|
||||
@@ -61,6 +65,29 @@ final class RDEPUBAnnotationWebView: WKWebView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate {
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
menuFor configuration: UIEditMenuConfiguration
|
||||
) -> UIMenu {
|
||||
UIMenu(children: [
|
||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
])
|
||||
}
|
||||
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
targetRectFor configuration: UIEditMenuConfiguration
|
||||
) -> CGRect {
|
||||
bounds
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBWebView: UIView {
|
||||
|
||||
public weak var delegate: RDEPUBWebViewDelegate?
|
||||
@@ -126,6 +153,11 @@ public final class RDEPUBWebView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// M-08: Retain cycle note — webView → configuration.userContentController → self (as WKScriptMessageHandler)
|
||||
// forms a retain cycle. The cycle is broken by calling teardownWebView(), which removes
|
||||
// the message handlers. teardownWebView() is called in reset() and deinit. IMPORTANT:
|
||||
// deinit may never trigger if the cycle is not broken first. Every code path that disposes
|
||||
// of this view MUST call reset() before releasing the last reference.
|
||||
deinit {
|
||||
teardownWebView()
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
|
||||
public final class RDEPUBTextBookCache {
|
||||
|
||||
public var schemaVersion: Int = 13
|
||||
public var schemaVersion: Int = 14
|
||||
|
||||
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
}
|
||||
|
||||
cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML)
|
||||
cleanedHTML = normalizeBodyLeadingSpacing(in: cleanedHTML)
|
||||
return cleanedHTML
|
||||
}
|
||||
|
||||
@@ -129,6 +130,64 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
return normalized
|
||||
}
|
||||
|
||||
private static func normalizeBodyLeadingSpacing(in html: String) -> String {
|
||||
var normalized = html
|
||||
|
||||
if let bodyStartRegex = try? NSRegularExpression(
|
||||
pattern: #"(<body\b[^>]*>)\s+"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = bodyStartRegex.stringByReplacingMatches(
|
||||
in: normalized,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: (normalized as NSString).length),
|
||||
withTemplate: "$1"
|
||||
)
|
||||
}
|
||||
|
||||
if let bodyEndRegex = try? NSRegularExpression(
|
||||
pattern: #"\s+(</body>)"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = bodyEndRegex.stringByReplacingMatches(
|
||||
in: normalized,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: (normalized as NSString).length),
|
||||
withTemplate: "$1"
|
||||
)
|
||||
}
|
||||
|
||||
guard let firstBlockRegex = try? NSRegularExpression(
|
||||
pattern: #"(<body\b[^>]*>)(\s*)(<(?<tag>h[1-6]|p|div|blockquote|section|article|ul|ol)\b[^>]*>)"#,
|
||||
options: [.caseInsensitive]
|
||||
) else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
let nsNormalized = normalized as NSString
|
||||
let fullRange = NSRange(location: 0, length: nsNormalized.length)
|
||||
guard let match = firstBlockRegex.firstMatch(in: normalized, options: [], range: fullRange),
|
||||
match.numberOfRanges >= 4,
|
||||
let bodyRange = Range(match.range(at: 1), in: normalized),
|
||||
let blockRange = Range(match.range(at: 3), in: normalized) else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
let prefix = String(normalized[..<bodyRange.lowerBound])
|
||||
let bodyTag = String(normalized[bodyRange])
|
||||
let blockTag = String(normalized[blockRange])
|
||||
let normalizedBlockTag = mergeHTMLAttributes(
|
||||
into: blockTag,
|
||||
requiredClass: nil,
|
||||
styleFragments: [
|
||||
"margin-top:0 !important",
|
||||
"-webkit-margin-before:0 !important",
|
||||
"padding-top:0 !important"
|
||||
]
|
||||
)
|
||||
return prefix + bodyTag + normalizedBlockTag + String(normalized[blockRange.upperBound...])
|
||||
}
|
||||
|
||||
static func replaceMatches(
|
||||
using regex: NSRegularExpression,
|
||||
in source: String,
|
||||
|
||||
@@ -390,9 +390,6 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
if let location = fallbackLocation(for: effectivePageNum) {
|
||||
persist(location: location)
|
||||
}
|
||||
if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving {
|
||||
readingSession?.transition(to: .idle)
|
||||
}
|
||||
|
||||
runtime.locationCoordinator.recordPageChangeIfNeeded()
|
||||
}
|
||||
@@ -432,8 +429,9 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
|
||||
let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage)
|
||||
guard currentSize.width > 0, currentSize.height > 0 else { return }
|
||||
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|
||||
|| abs(currentSize.height - self.lastTextPaginationPageSize!.height) > 0.5
|
||||
guard let previousPageSize = self.lastTextPaginationPageSize else { return }
|
||||
let stillChanged = abs(currentSize.width - previousPageSize.width) > 0.5
|
||||
|| abs(currentSize.height - previousPageSize.height) > 0.5
|
||||
guard stillChanged else { return }
|
||||
self.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
@@ -137,7 +137,6 @@ extension RDEPUBReaderController {
|
||||
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: pageNumber) else {
|
||||
readingSession?.transition(to: .idle)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
public var configuration: RDEPUBReaderConfiguration {
|
||||
didSet {
|
||||
// M-09: Apply all configuration side effects even before view is loaded,
|
||||
// but UI-dependent actions only after view is loaded.
|
||||
readerContext.configuration = configuration
|
||||
guard isViewLoaded else { return }
|
||||
applyWebViewDebugPolicy()
|
||||
persistReaderSettingsIfNeeded()
|
||||
guard isViewLoaded else { return }
|
||||
let oldConfiguration = oldValue
|
||||
applyReaderViewConfiguration()
|
||||
|
||||
@@ -176,11 +178,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
set { readerContext.paginationToken = newValue }
|
||||
}
|
||||
|
||||
var paginator: RDEPUBPaginator? {
|
||||
get { readerContext.paginator }
|
||||
set { readerContext.paginator = newValue }
|
||||
}
|
||||
|
||||
var searchState: RDEPUBSearchState? {
|
||||
get { readerContext.searchState }
|
||||
set { readerContext.searchState = newValue }
|
||||
@@ -291,6 +288,11 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
runtime.viewportMonitor.viewDidLayoutSubviews()
|
||||
}
|
||||
|
||||
public override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
runtime.handleMemoryWarning()
|
||||
}
|
||||
|
||||
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
runtime.viewportMonitor.viewWillTransition(with: coordinator)
|
||||
|
||||
@@ -77,60 +77,104 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
guard let data = defaults.data(forKey: locationPrefix + bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONDecoder().decode(RDEPUBLocation.self, from: data)
|
||||
do {
|
||||
return try JSONDecoder().decode(RDEPUBLocation.self, from: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode location for '\(bookIdentifier)': \(error)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(location) else {
|
||||
return
|
||||
do {
|
||||
let data = try JSONEncoder().encode(location)
|
||||
defaults.set(data, forKey: locationPrefix + bookIdentifier)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode location for '\(bookIdentifier)': \(error)")
|
||||
#endif
|
||||
}
|
||||
defaults.set(data, forKey: locationPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
return (try? JSONDecoder().decode([RDEPUBBookmark].self, from: data)) ?? []
|
||||
do {
|
||||
return try JSONDecoder().decode([RDEPUBBookmark].self, from: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode bookmarks for '\(bookIdentifier)': \(error)")
|
||||
#endif
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(bookmarks) else {
|
||||
return
|
||||
do {
|
||||
let data = try JSONEncoder().encode(bookmarks)
|
||||
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)")
|
||||
#endif
|
||||
}
|
||||
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
|
||||
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
return (try? JSONDecoder().decode([RDEPUBHighlight].self, from: data)) ?? []
|
||||
do {
|
||||
return try JSONDecoder().decode([RDEPUBHighlight].self, from: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode highlights for '\(bookIdentifier)': \(error)")
|
||||
#endif
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(highlights) else {
|
||||
return
|
||||
}
|
||||
if data.count > 1_048_576 {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(highlights)
|
||||
if data.count > 1_048_576 {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
|
||||
#endif
|
||||
}
|
||||
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)")
|
||||
#endif
|
||||
}
|
||||
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
public func loadReaderSettings() -> RDEPUBReaderSettings? {
|
||||
guard let data = defaults.data(forKey: settingsKey) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONDecoder().decode(RDEPUBReaderSettings.self, from: data)
|
||||
do {
|
||||
return try JSONDecoder().decode(RDEPUBReaderSettings.self, from: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode reader settings: \(error)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
|
||||
guard let data = try? JSONEncoder().encode(settings) else {
|
||||
return
|
||||
do {
|
||||
let data = try JSONEncoder().encode(settings)
|
||||
defaults.set(data, forKey: settingsKey)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode reader settings: \(error)")
|
||||
#endif
|
||||
}
|
||||
defaults.set(data, forKey: settingsKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,11 +154,18 @@ public final class RDURLReaderController: UIViewController {
|
||||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||||
let pageSize = currentTextPageSize()
|
||||
let renderStyle = currentTextRenderStyle()
|
||||
let safeInsets = view.safeAreaInsets
|
||||
let edgeInsets = UIEdgeInsets(
|
||||
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
|
||||
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
|
||||
bottom: max(epubConfiguration.reflowableContentInsets.bottom, safeInsets.bottom),
|
||||
right: max(epubConfiguration.reflowableContentInsets.right, safeInsets.right)
|
||||
)
|
||||
let builder = RDPlainTextBookBuilder(
|
||||
layoutConfig: RDEPUBTextLayoutConfig(
|
||||
frameWidth: pageSize.width,
|
||||
frameHeight: pageSize.height,
|
||||
edgeInsets: epubConfiguration.reflowableContentInsets,
|
||||
edgeInsets: edgeInsets,
|
||||
numberOfColumns: 1,
|
||||
columnGap: 20,
|
||||
avoidOrphans: false,
|
||||
|
||||
+39
-3
@@ -3,19 +3,37 @@ import Foundation
|
||||
final class RDEPUBChapterDataCache {
|
||||
|
||||
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
|
||||
private var accessOrder: [Int] = [] // H-05: LRU tracking for eviction
|
||||
private let maxEntryCount: Int
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
init(maxEntryCount: Int = 30) {
|
||||
self.maxEntryCount = maxEntryCount
|
||||
}
|
||||
|
||||
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
get {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage[spineIndex]
|
||||
guard let chapter = storage[spineIndex] else {
|
||||
return nil
|
||||
}
|
||||
touchLocked(spineIndex)
|
||||
return chapter
|
||||
}
|
||||
set {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage[spineIndex] = newValue
|
||||
if let newValue {
|
||||
storage[spineIndex] = newValue
|
||||
touchLocked(spineIndex)
|
||||
// Evict oldest entries if over limit
|
||||
evictIfNeededLocked()
|
||||
} else {
|
||||
storage.removeValue(forKey: spineIndex)
|
||||
accessOrder.removeAll { $0 == spineIndex }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +47,29 @@ final class RDEPUBChapterDataCache {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage.removeValue(forKey: spineIndex)
|
||||
accessOrder.removeAll { $0 == spineIndex }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage.removeAll()
|
||||
accessOrder.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// H-05: Evict least recently used entries when cache exceeds maxEntryCount.
|
||||
/// Must be called while holding lock.
|
||||
private func evictIfNeededLocked() {
|
||||
while storage.count > maxEntryCount, let oldest = accessOrder.first {
|
||||
storage.removeValue(forKey: oldest)
|
||||
accessOrder.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks an entry as most recently used.
|
||||
/// Must be called while holding lock.
|
||||
private func touchLocked(_ spineIndex: Int) {
|
||||
accessOrder.removeAll { $0 == spineIndex }
|
||||
accessOrder.append(spineIndex)
|
||||
}
|
||||
}
|
||||
|
||||
+68
-22
@@ -46,12 +46,29 @@ final class RDEPUBChapterLoader {
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority = .navigation,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
let layoutSnapshot = context?.makeLayoutSnapshot()
|
||||
loadChapterWithSnapshot(
|
||||
spineIndex: spineIndex,
|
||||
store: store,
|
||||
priority: priority,
|
||||
layoutSnapshot: layoutSnapshot,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
private func loadChapterWithSnapshot(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot?,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
if let context {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
@@ -83,15 +100,15 @@ final class RDEPUBChapterLoader {
|
||||
store.markBuilding(true)
|
||||
_ = store.beginPendingChapterLoad(for: spineIndex)
|
||||
|
||||
store.chapterLoadQueue.async { [self] in
|
||||
guard let context = self.context else {
|
||||
store.chapterLoadQueue.async { [weak self] in
|
||||
guard let self, let context = self.context else {
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
|
||||
self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
|
||||
return
|
||||
}
|
||||
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
|
||||
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
@@ -109,7 +126,8 @@ final class RDEPUBChapterLoader {
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: availablePageRanges,
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
context: context,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
|
||||
store.insertChapter(chapter)
|
||||
@@ -137,7 +155,7 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: { _ in })
|
||||
self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in })
|
||||
return
|
||||
}
|
||||
store.markBuilding(false)
|
||||
@@ -163,7 +181,8 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
func loadChapterSynchronouslyForMigration(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore?
|
||||
store: RDEPUBChapterRuntimeStore?,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let context else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
@@ -173,7 +192,7 @@ final class RDEPUBChapterLoader {
|
||||
if let store {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
@@ -202,12 +221,13 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
store.assertNotOnChapterLoadQueue()
|
||||
|
||||
let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot()
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
store.chapterLoadQueue.async {
|
||||
do {
|
||||
let chapter: RDEPUBRuntimeChapter = try autoreleasepool {
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot)
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
@@ -219,7 +239,8 @@ final class RDEPUBChapterLoader {
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
context: context,
|
||||
layoutSnapshot: snapshot
|
||||
)
|
||||
store.insertChapter(chapter)
|
||||
let pageCount = RDEPUBRuntimePageCount(
|
||||
@@ -288,16 +309,28 @@ final class RDEPUBChapterLoader {
|
||||
spineIndex: Int,
|
||||
availablePageRanges: [NSRange]?,
|
||||
diskSummary: RDEPUBChapterSummary? = nil,
|
||||
context: RDEPUBReaderContext
|
||||
context: RDEPUBReaderContext,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
let pageSize = context.currentTextPageSize()
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let pageSize: CGSize
|
||||
let style: RDEPUBTextRenderStyle
|
||||
let layoutConfig: RDEPUBTextLayoutConfig
|
||||
|
||||
if let snapshot = layoutSnapshot {
|
||||
pageSize = snapshot.pageSize
|
||||
style = snapshot.style
|
||||
layoutConfig = snapshot.layoutConfig
|
||||
} else {
|
||||
assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
|
||||
pageSize = context.currentTextPageSize()
|
||||
style = context.currentTextRenderStyle()
|
||||
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
}
|
||||
|
||||
if let pageRanges = availablePageRanges {
|
||||
|
||||
@@ -330,7 +363,8 @@ final class RDEPUBChapterLoader {
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig,
|
||||
context: context
|
||||
context: context,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
@@ -542,7 +576,8 @@ final class RDEPUBChapterLoader {
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
context: RDEPUBReaderContext
|
||||
context: RDEPUBReaderContext,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: chapter.attributedContent,
|
||||
@@ -559,7 +594,7 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context)
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
|
||||
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
@@ -575,11 +610,22 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext) -> RDEPUBChapterCacheKey {
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
||||
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey {
|
||||
let style: RDEPUBTextRenderStyle
|
||||
let layoutConfig: RDEPUBTextLayoutConfig
|
||||
let lineHeightMultiple: CGFloat
|
||||
|
||||
let lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
if let snapshot = layoutSnapshot {
|
||||
style = snapshot.style
|
||||
layoutConfig = snapshot.layoutConfig
|
||||
lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
} else {
|
||||
assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
|
||||
let pageSize = context.currentTextPageSize()
|
||||
style = context.currentTextRenderStyle()
|
||||
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
}
|
||||
|
||||
let renderSignature = [
|
||||
style.font.fontName,
|
||||
|
||||
+5
@@ -12,6 +12,10 @@ final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
// M-02: currentSpineIndex and windowSpineIndices are accessed only from the main thread
|
||||
// (verified by audit of all 7 access points). They are not protected by locks unlike
|
||||
// navigationLock/prefetchLock/buildingLock/cfiMapLock, but this is safe as long as
|
||||
// access remains main-thread-only. Do NOT access from chapterLoadQueue.
|
||||
private(set) var currentSpineIndex: Int?
|
||||
|
||||
private(set) var windowSpineIndices: [Int] = []
|
||||
@@ -39,6 +43,7 @@ final class RDEPUBChapterRuntimeStore {
|
||||
init() {
|
||||
|
||||
imageCache.countLimit = 50
|
||||
imageCache.totalCostLimit = 104_857_600 // 100 MB
|
||||
|
||||
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
|
||||
}
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ struct RDEPUBChapterSummary: Codable {
|
||||
|
||||
let pageMetadataList: [PageMetadataSummary]
|
||||
|
||||
static let currentSchemaVersion = 16
|
||||
static let currentSchemaVersion = 17
|
||||
|
||||
struct RangeData: Codable {
|
||||
|
||||
|
||||
-4
@@ -82,8 +82,6 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
presentationRuntime.navigationStateMachine.transition(to: .preparingChapter(spineIndex: spineIndex))
|
||||
|
||||
if !chapterReady {
|
||||
guard allowSynchronousLoad else {
|
||||
scheduleAsynchronousChapterPreparation(
|
||||
@@ -105,7 +103,6 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
}
|
||||
|
||||
markPrepareResolved(pageNumber)
|
||||
presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
|
||||
scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
|
||||
@@ -378,7 +375,6 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
switch result {
|
||||
case .success:
|
||||
self.markPrepareResolved(triggerPageNumber)
|
||||
self.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
|
||||
|
||||
-246
@@ -1,246 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterWindowCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
private let loader: RDEPUBChapterLoader
|
||||
|
||||
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
|
||||
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
|
||||
self.context = context
|
||||
self.store = store
|
||||
self.loader = loader
|
||||
}
|
||||
|
||||
private var restoreChapterOffset: Int?
|
||||
|
||||
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
store.setCurrentChapter(
|
||||
spineIndex: targetSpineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
self.restoreChapterOffset = restoreChapterOffset
|
||||
|
||||
isSwitchingChapter = true
|
||||
|
||||
store.setNavigationTarget(spineIndex: targetSpineIndex)
|
||||
|
||||
store.clearPrefetchTargets()
|
||||
|
||||
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
|
||||
}
|
||||
|
||||
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
|
||||
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
self.isSwitchingChapter = false
|
||||
self.buildSnapshotAroundCurrent(chapter: chapter)
|
||||
case .failure(let error):
|
||||
let nextIndex = initialSpineIndex + 1
|
||||
if nextIndex < totalSpineCount {
|
||||
self.store.setCurrentChapter(
|
||||
spineIndex: nextIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: self.context.configuration.chapterWindowRadius
|
||||
)
|
||||
self.store.setNavigationTarget(spineIndex: nextIndex)
|
||||
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
|
||||
} else {
|
||||
|
||||
self.isSwitchingChapter = false
|
||||
self.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
||||
guard let current = store.currentSpineIndex else {
|
||||
return
|
||||
}
|
||||
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
|
||||
if spineIndex == chapter.spineIndex {
|
||||
return chapter
|
||||
}
|
||||
return store.chapterData(for: spineIndex)
|
||||
}
|
||||
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
|
||||
currentSnapshot = snapshot
|
||||
isApplyingSnapshot = true
|
||||
onSnapshotChanged?(snapshot)
|
||||
isApplyingSnapshot = false
|
||||
|
||||
if let offset = restoreChapterOffset,
|
||||
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
|
||||
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
|
||||
let targetPage = snapshot.anchorPageOffset + pageIndex
|
||||
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
|
||||
} else if snapshot.pageCount > 0 {
|
||||
|
||||
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
|
||||
}
|
||||
restoreChapterOffset = nil
|
||||
|
||||
prefetchAdjacent(current: current)
|
||||
}
|
||||
|
||||
private func prefetchAdjacent(current: Int) {
|
||||
for spineIndex in store.windowSpineIndices where spineIndex != current {
|
||||
guard store.chapterData(for: spineIndex) == nil else { continue }
|
||||
store.addPrefetchTarget(spineIndex)
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self = self, case .success = result else { return }
|
||||
self.refreshSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex else { return }
|
||||
let next = current + 1
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
guard next < totalSpineCount else { return }
|
||||
|
||||
flipToChapter(spineIndex: next, completion: completion)
|
||||
}
|
||||
|
||||
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex, current > 0 else { return }
|
||||
flipToChapter(spineIndex: current - 1, completion: completion)
|
||||
}
|
||||
|
||||
func flipToChapter(
|
||||
spineIndex: Int,
|
||||
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
|
||||
) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
store.setNavigationTarget(spineIndex: spineIndex)
|
||||
|
||||
store.clearPrefetchTargets()
|
||||
|
||||
isSwitchingChapter = true
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let evictable = store.evictableSpineIndices()
|
||||
for idx in evictable {
|
||||
store.evict(spineIndex: idx)
|
||||
}
|
||||
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
buildSnapshotAroundCurrent(chapter: cached)
|
||||
isSwitchingChapter = false
|
||||
if let snap = currentSnapshot {
|
||||
completion(.success(snap))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
self.isSwitchingChapter = false
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
self.buildSnapshotAroundCurrent(chapter: chapter)
|
||||
if let snap = self.currentSnapshot {
|
||||
completion(.success(snap))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshSnapshot() {
|
||||
guard let current = store.currentSpineIndex,
|
||||
let currentChapter = store.chapterData(for: current) else { return }
|
||||
|
||||
guard isReaderIdle() else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||
self?.refreshSnapshot()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let chapters = store.windowSpineIndices.compactMap { store.chapterData(for: $0) }
|
||||
let newSnapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
|
||||
|
||||
if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) {
|
||||
currentSnapshot = newSnapshot
|
||||
isApplyingSnapshot = true
|
||||
onSnapshotChanged?(newSnapshot)
|
||||
isApplyingSnapshot = false
|
||||
}
|
||||
}
|
||||
|
||||
func maintainWindow(afterMovingTo spineIndex: Int) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
for idx in store.evictableSpineIndices() {
|
||||
store.evict(spineIndex: idx)
|
||||
}
|
||||
|
||||
prefetchAdjacent(current: spineIndex)
|
||||
}
|
||||
|
||||
private func handle(error: Error) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.context.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private func snapshotContentChanged(
|
||||
old: RDEPUBChapterWindowSnapshot?,
|
||||
new: RDEPUBChapterWindowSnapshot
|
||||
) -> Bool {
|
||||
guard let old = old else { return true }
|
||||
|
||||
if old.chapters.count != new.chapters.count { return true }
|
||||
|
||||
let oldSpines = old.chapters.map { $0.spineIndex }
|
||||
let newSpines = new.chapters.map { $0.spineIndex }
|
||||
if oldSpines != newSpines { return true }
|
||||
|
||||
if old.pageCount != new.pageCount { return true }
|
||||
|
||||
for (oldCh, newCh) in zip(old.chapters, new.chapters) {
|
||||
if oldCh.pages.count != newCh.pages.count { return true }
|
||||
}
|
||||
|
||||
if old.anchorChapterIndex != new.anchorChapterIndex
|
||||
|| old.anchorPageOffset != new.anchorPageOffset { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func isReaderIdle() -> Bool {
|
||||
guard !store.isBuilding else { return false }
|
||||
guard !isSwitchingChapter else { return false }
|
||||
guard !isApplyingSnapshot else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private var isSwitchingChapter: Bool = false
|
||||
|
||||
private var isApplyingSnapshot: Bool = false
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterWindowSnapshot {
|
||||
|
||||
let chapters: [RDEPUBRuntimeChapter]
|
||||
|
||||
let flattenedPages: [RDEPUBTextPage]
|
||||
|
||||
let anchorChapterIndex: Int
|
||||
|
||||
let anchorPageOffset: Int
|
||||
|
||||
let windowStartSpineIndex: Int
|
||||
|
||||
static func from(
|
||||
chapters: [RDEPUBRuntimeChapter],
|
||||
anchorSpineIndex: Int
|
||||
) -> RDEPUBChapterWindowSnapshot {
|
||||
let sortedChapters = chapters.sorted { $0.spineIndex < $1.spineIndex }
|
||||
let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
|
||||
let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
|
||||
|
||||
var allPages: [RDEPUBTextPage] = []
|
||||
for (chIdx, ch) in sortedChapters.enumerated() {
|
||||
for var page in ch.pages {
|
||||
page.chapterIndex = chIdx
|
||||
allPages.append(page)
|
||||
}
|
||||
}
|
||||
|
||||
let windowStartSpineIndex = sortedChapters.first?.spineIndex ?? anchorSpineIndex
|
||||
|
||||
return RDEPUBChapterWindowSnapshot(
|
||||
chapters: sortedChapters,
|
||||
flattenedPages: allPages,
|
||||
anchorChapterIndex: anchorIndex,
|
||||
anchorPageOffset: pageOffset,
|
||||
windowStartSpineIndex: windowStartSpineIndex
|
||||
)
|
||||
}
|
||||
|
||||
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
var offset = 0
|
||||
for ch in chapters {
|
||||
if flattenedPageIndex < offset + ch.pages.count {
|
||||
return ch
|
||||
}
|
||||
offset += ch.pages.count
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
|
||||
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
|
||||
}
|
||||
|
||||
var pageCount: Int { flattenedPages.count }
|
||||
}
|
||||
@@ -63,6 +63,7 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
private(set) var policy: RDEPUBBackgroundPriorityPolicy
|
||||
|
||||
private var warmAnchors: [RDEPUBWarmJumpAnchor] = []
|
||||
private let warmAnchorsLock = NSLock()
|
||||
|
||||
private(set) var currentGeneration: Int = 0
|
||||
|
||||
@@ -84,11 +85,13 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
sequenceNumber: currentGeneration
|
||||
)
|
||||
|
||||
warmAnchorsLock.lock()
|
||||
warmAnchors.insert(anchor, at: 0)
|
||||
|
||||
if warmAnchors.count > policy.maxWarmJumpAnchors {
|
||||
warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors))
|
||||
}
|
||||
warmAnchorsLock.unlock()
|
||||
|
||||
currentGeneration += 1
|
||||
coldCursor = 0
|
||||
@@ -103,10 +106,17 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
||||
guard !uncachedIndices.isEmpty else { return [] }
|
||||
|
||||
// M-03: Snapshot warmAnchors under lock since it's written on main thread
|
||||
// and read on background thread.
|
||||
warmAnchorsLock.lock()
|
||||
let warmAnchorsSnapshot = warmAnchors
|
||||
warmAnchorsLock.unlock()
|
||||
|
||||
let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in
|
||||
let band = classifySpineIndex(
|
||||
spineIndex: spineIndex,
|
||||
currentSpineIndex: currentSpineIndex
|
||||
currentSpineIndex: currentSpineIndex,
|
||||
warmAnchors: warmAnchorsSnapshot
|
||||
)
|
||||
return (spineIndex, band)
|
||||
}
|
||||
@@ -131,7 +141,8 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
|
||||
private func classifySpineIndex(
|
||||
spineIndex: Int,
|
||||
currentSpineIndex: Int?
|
||||
currentSpineIndex: Int?,
|
||||
warmAnchors: [RDEPUBWarmJumpAnchor]
|
||||
) -> RDEPUBPriorityBand {
|
||||
|
||||
if let current = currentSpineIndex {
|
||||
@@ -152,11 +163,15 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
}
|
||||
|
||||
func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
|
||||
warmAnchors
|
||||
warmAnchorsLock.lock()
|
||||
defer { warmAnchorsLock.unlock() }
|
||||
return warmAnchors
|
||||
}
|
||||
|
||||
func reset() {
|
||||
warmAnchorsLock.lock()
|
||||
warmAnchors.removeAll()
|
||||
warmAnchorsLock.unlock()
|
||||
currentGeneration = 0
|
||||
coldCursor = 0
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ final class RDEPUBMetadataParseWorker {
|
||||
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
|
||||
unowned let context: RDEPUBReaderContext
|
||||
weak var context: RDEPUBReaderContext?
|
||||
|
||||
let cancellationController: RDEPUBMetadataParseCancellationController
|
||||
|
||||
@@ -114,12 +114,16 @@ final class RDEPUBMetadataParseWorker {
|
||||
}
|
||||
|
||||
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||||
let context = self.context
|
||||
let cancellationController = self.cancellationController
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
|
||||
let context = self.context
|
||||
guard let context,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled,
|
||||
context.paginationToken == token else { return }
|
||||
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
|
||||
guard context.controller != nil,
|
||||
!cancellationController.isCancelled,
|
||||
context.paginationToken == token else { return }
|
||||
@@ -382,10 +386,16 @@ final class RDEPUBMetadataParseWorker {
|
||||
private func waitForReadingInteractionToSettle(
|
||||
cancellationController: RDEPUBMetadataParseCancellationController? = nil
|
||||
) {
|
||||
while context.controller != nil,
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let maxWaitIterations = 100 // ~8 seconds max wait
|
||||
var iteration = 0
|
||||
while context?.controller != nil,
|
||||
cancellationController?.isCancelled != true,
|
||||
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
iteration < maxWaitIterations {
|
||||
let elapsed = context?.secondsSinceLastUserNavigation() ?? 0
|
||||
if elapsed >= backgroundInteractionCooldown { break }
|
||||
semaphore.wait(timeout: .now() + 0.08)
|
||||
iteration += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,8 +413,7 @@ final class RDEPUBMetadataParseWorker {
|
||||
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
||||
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
let context = self.context
|
||||
guard let self, let context = self.context else { return }
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == self.token,
|
||||
!cancellationController.isCancelled else {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBNavigationState: Equatable {
|
||||
case idle
|
||||
case initialLoading
|
||||
case restoringLocation
|
||||
case preparingChapter(spineIndex: Int)
|
||||
case presentingWindow
|
||||
case reconcilingFullMap
|
||||
case repaginating
|
||||
}
|
||||
|
||||
final class RDEPUBNavigationStateMachine {
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
private(set) var state: RDEPUBNavigationState = .idle
|
||||
|
||||
func transition(to newState: RDEPUBNavigationState) {
|
||||
lock.lock()
|
||||
let oldState = state
|
||||
state = newState
|
||||
lock.unlock()
|
||||
#if DEBUG
|
||||
validateTransition(from: oldState, to: newState)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func validateTransition(from oldState: RDEPUBNavigationState, to newState: RDEPUBNavigationState) {
|
||||
let isValid: Bool
|
||||
switch (oldState, newState) {
|
||||
case (.idle, .initialLoading),
|
||||
(.idle, .preparingChapter),
|
||||
(.idle, .restoringLocation),
|
||||
(.idle, .idle):
|
||||
isValid = true
|
||||
case (.initialLoading, .preparingChapter),
|
||||
(.initialLoading, .presentingWindow),
|
||||
(.initialLoading, .idle),
|
||||
(.initialLoading, .initialLoading):
|
||||
isValid = true
|
||||
case (.restoringLocation, .preparingChapter),
|
||||
(.restoringLocation, .presentingWindow),
|
||||
(.restoringLocation, .idle),
|
||||
(.restoringLocation, .restoringLocation):
|
||||
isValid = true
|
||||
case (.preparingChapter, .presentingWindow),
|
||||
(.preparingChapter, .preparingChapter),
|
||||
(.preparingChapter, .idle):
|
||||
isValid = true
|
||||
case (.presentingWindow, .idle),
|
||||
(.presentingWindow, .reconcilingFullMap),
|
||||
(.presentingWindow, .preparingChapter),
|
||||
(.presentingWindow, .presentingWindow),
|
||||
(.presentingWindow, .repaginating):
|
||||
isValid = true
|
||||
case (.reconcilingFullMap, .presentingWindow),
|
||||
(.reconcilingFullMap, .idle),
|
||||
(.reconcilingFullMap, .reconcilingFullMap):
|
||||
isValid = true
|
||||
case (.repaginating, .presentingWindow),
|
||||
(.repaginating, .idle),
|
||||
(.repaginating, .repaginating):
|
||||
isValid = true
|
||||
default:
|
||||
isValid = false
|
||||
}
|
||||
if !isValid {
|
||||
#if DEBUG
|
||||
assertionFailure("Unexpected navigation state transition: \(oldState) → \(newState)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBPaginationStateSource: String {
|
||||
case initialPartial
|
||||
case asyncExtension
|
||||
case pendingFullMap
|
||||
case fullReplacement
|
||||
case settingsPreview
|
||||
case cacheRestore
|
||||
case repagination
|
||||
}
|
||||
|
||||
struct RDEPUBPaginationState {
|
||||
|
||||
var activePageMap: RDEPUBBookPageMap?
|
||||
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
|
||||
|
||||
var chapterWindowSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
|
||||
var source: RDEPUBPaginationStateSource = .initialPartial
|
||||
}
|
||||
@@ -8,7 +8,6 @@ enum RDEPUBPendingPageMapUpdateKind {
|
||||
|
||||
struct RDEPUBPendingPageMapUpdate {
|
||||
let pageMap: RDEPUBBookPageMap
|
||||
let source: RDEPUBPaginationStateSource
|
||||
let kind: RDEPUBPendingPageMapUpdateKind
|
||||
}
|
||||
|
||||
@@ -22,10 +21,6 @@ final class RDEPUBPresentationRuntime {
|
||||
|
||||
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
||||
|
||||
let navigationStateMachine = RDEPUBNavigationStateMachine()
|
||||
|
||||
private(set) var paginationState = RDEPUBPaginationState()
|
||||
|
||||
init(
|
||||
context: RDEPUBReaderContext,
|
||||
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
||||
@@ -43,19 +38,14 @@ final class RDEPUBPresentationRuntime {
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
finishPagination: (RDEPUBLocation?) -> Void
|
||||
) {
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationState.activePageMap = bookPageMap
|
||||
paginationState.pendingPageMapUpdates.removeAll()
|
||||
paginationState.source = .initialPartial
|
||||
finishPagination(restoreLocation)
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
navigationStateMachine.transition(to: .reconcilingFullMap)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)"
|
||||
@@ -63,7 +53,6 @@ final class RDEPUBPresentationRuntime {
|
||||
enqueuePendingPageMapUpdate(
|
||||
RDEPUBPendingPageMapUpdate(
|
||||
pageMap: bookPageMap,
|
||||
source: .pendingFullMap,
|
||||
kind: .reconcileFullMap
|
||||
)
|
||||
)
|
||||
@@ -99,7 +88,6 @@ final class RDEPUBPresentationRuntime {
|
||||
enqueuePendingPageMapUpdate(
|
||||
RDEPUBPendingPageMapUpdate(
|
||||
pageMap: bookPageMap,
|
||||
source: .asyncExtension,
|
||||
kind: .extendPartial(
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation
|
||||
@@ -109,28 +97,19 @@ final class RDEPUBPresentationRuntime {
|
||||
}
|
||||
|
||||
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationState.activePageMap = bookPageMap
|
||||
paginationState.source = .settingsPreview
|
||||
}
|
||||
|
||||
func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
enqueuePendingPageMapUpdate(
|
||||
RDEPUBPendingPageMapUpdate(
|
||||
pageMap: bookPageMap,
|
||||
source: .asyncExtension,
|
||||
kind: .appendForward
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
paginationState = RDEPUBPaginationState()
|
||||
navigationStateMachine.transition(to: .idle)
|
||||
}
|
||||
|
||||
func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
let pages = bookPageMap.entries.flatMap { entry in
|
||||
(0..<entry.pageCount).map { localPageIndex in
|
||||
@@ -162,7 +141,7 @@ final class RDEPUBPresentationRuntime {
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
|
||||
context.textBook = nil
|
||||
applyPageMapToLiveModel(newPageMap, source: .fullReplacement)
|
||||
applyPageMapToLiveModel(newPageMap)
|
||||
|
||||
if let currentLocation {
|
||||
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false {
|
||||
@@ -201,7 +180,6 @@ final class RDEPUBPresentationRuntime {
|
||||
updates.append(update)
|
||||
}
|
||||
context.pendingPageMapUpdates = updates
|
||||
paginationState.pendingPageMapUpdates = updates
|
||||
commitPendingPageMapUpdateIfNeeded()
|
||||
}
|
||||
|
||||
@@ -219,7 +197,6 @@ final class RDEPUBPresentationRuntime {
|
||||
) -> Bool {
|
||||
switch update.kind {
|
||||
case .reconcileFullMap:
|
||||
navigationStateMachine.transition(to: .reconcilingFullMap)
|
||||
let decision = reconciliationCoordinator.evaluateTakeover(
|
||||
candidatePageMap: update.pageMap,
|
||||
candidateSegment: nil,
|
||||
@@ -248,7 +225,7 @@ final class RDEPUBPresentationRuntime {
|
||||
|
||||
case .extendPartial(_, let currentLocation):
|
||||
removePendingPageMapUpdate(at: index)
|
||||
applyPageMapToLiveModel(update.pageMap, source: update.source)
|
||||
applyPageMapToLiveModel(update.pageMap)
|
||||
if let currentLocation,
|
||||
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
||||
return true
|
||||
@@ -266,7 +243,7 @@ final class RDEPUBPresentationRuntime {
|
||||
|
||||
case .appendForward:
|
||||
removePendingPageMapUpdate(at: index)
|
||||
applyPageMapToLiveModel(update.pageMap, source: update.source)
|
||||
applyPageMapToLiveModel(update.pageMap)
|
||||
readerView.reloadPageCountOnly()
|
||||
return true
|
||||
}
|
||||
@@ -305,7 +282,7 @@ final class RDEPUBPresentationRuntime {
|
||||
if context.bookPageMap != nil,
|
||||
context.runtime?.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: targetPageNumber,
|
||||
allowSynchronousLoad: true
|
||||
allowSynchronousLoad: false
|
||||
) == false {
|
||||
return false
|
||||
}
|
||||
@@ -317,16 +294,10 @@ final class RDEPUBPresentationRuntime {
|
||||
return true
|
||||
}
|
||||
|
||||
private func applyPageMapToLiveModel(
|
||||
_ pageMap: RDEPUBBookPageMap,
|
||||
source: RDEPUBPaginationStateSource
|
||||
) {
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
private func applyPageMapToLiveModel(_ pageMap: RDEPUBBookPageMap) {
|
||||
context.bookPageMap = pageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
|
||||
discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
|
||||
paginationState.activePageMap = pageMap
|
||||
paginationState.source = source
|
||||
}
|
||||
|
||||
private func removePendingPageMapUpdate(at index: Int) {
|
||||
@@ -334,7 +305,6 @@ final class RDEPUBPresentationRuntime {
|
||||
guard updates.indices.contains(index) else { return }
|
||||
updates.remove(at: index)
|
||||
context.pendingPageMapUpdates = updates
|
||||
paginationState.pendingPageMapUpdates = updates
|
||||
}
|
||||
|
||||
private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) {
|
||||
@@ -346,7 +316,6 @@ final class RDEPUBPresentationRuntime {
|
||||
)
|
||||
}
|
||||
context.pendingPageMapUpdates = updates
|
||||
paginationState.pendingPageMapUpdates = updates
|
||||
}
|
||||
|
||||
private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int {
|
||||
@@ -384,4 +353,4 @@ final class RDEPUBPresentationRuntime {
|
||||
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
/// Captures layout parameters on the main thread for safe use on background queues.
|
||||
/// Create via `RDEPUBReaderContext.makeLayoutSnapshot()` before dispatching work off the main thread.
|
||||
struct RDEPUBLayoutSnapshot {
|
||||
let pageSize: CGSize
|
||||
let style: RDEPUBTextRenderStyle
|
||||
let layoutConfig: RDEPUBTextLayoutConfig
|
||||
let renderSignature: String
|
||||
}
|
||||
|
||||
final class RDEPUBReaderContext {
|
||||
|
||||
private let activityLock = NSLock()
|
||||
@@ -73,11 +82,6 @@ final class RDEPUBReaderContext {
|
||||
set { state.paginationToken = newValue }
|
||||
}
|
||||
|
||||
var paginator: RDEPUBPaginator? {
|
||||
get { state.paginator }
|
||||
set { state.paginator = newValue }
|
||||
}
|
||||
|
||||
var searchState: RDEPUBSearchState? {
|
||||
get { state.searchState }
|
||||
set { state.searchState = newValue }
|
||||
@@ -159,43 +163,57 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
configuration.makePreferences()
|
||||
let safeInsets = controller?.view.safeAreaInsets ?? .zero
|
||||
return configuration.makePreferences(safeAreaInsets: safeInsets)
|
||||
}
|
||||
|
||||
/// Captures all layout parameters needed for background chapter loading.
|
||||
/// Must be called on the main thread. The returned snapshot is safe to use on any thread.
|
||||
func makeLayoutSnapshot() -> RDEPUBLayoutSnapshot {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
let pageSize = currentTextPageSize()
|
||||
let style = currentTextRenderStyle()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
let renderSignature = renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
|
||||
return RDEPUBLayoutSnapshot(
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
layoutConfig: layoutConfig,
|
||||
renderSignature: renderSignature
|
||||
)
|
||||
}
|
||||
|
||||
func currentTextPageSize() -> CGSize {
|
||||
if Thread.isMainThread {
|
||||
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
|
||||
if let readerView, let pageNum {
|
||||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
||||
if resolvedSize.width > 0, resolvedSize.height > 0 {
|
||||
return resolvedSize
|
||||
}
|
||||
// M-04: Use dispatchPrecondition instead of assert so it's enforced in Release builds too.
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
|
||||
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
|
||||
if let readerView, let pageNum {
|
||||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
||||
if resolvedSize.width > 0, resolvedSize.height > 0 {
|
||||
return resolvedSize
|
||||
}
|
||||
let viewportSize = currentLayoutContext().viewportSize
|
||||
if viewportSize.width > 0, viewportSize.height > 0 {
|
||||
return viewportSize
|
||||
}
|
||||
} else if let lastTextPaginationPageSize,
|
||||
lastTextPaginationPageSize.width > 0,
|
||||
lastTextPaginationPageSize.height > 0 {
|
||||
}
|
||||
let viewportSize = currentLayoutContext().viewportSize
|
||||
if viewportSize.width > 0, viewportSize.height > 0 {
|
||||
return viewportSize
|
||||
}
|
||||
if let lastTextPaginationPageSize,
|
||||
lastTextPaginationPageSize.width > 0,
|
||||
lastTextPaginationPageSize.height > 0 {
|
||||
return lastTextPaginationPageSize
|
||||
} else {
|
||||
let mainThreadSize = DispatchQueue.main.sync { [weak self] in
|
||||
self?.currentTextPageSize() ?? .zero
|
||||
}
|
||||
if mainThreadSize.width > 0, mainThreadSize.height > 0 {
|
||||
return mainThreadSize
|
||||
}
|
||||
}
|
||||
return environment.fallbackViewportSize
|
||||
}
|
||||
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
environment.currentTextRenderStyle(configuration: configuration)
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
return environment.currentTextRenderStyle(configuration: configuration)
|
||||
}
|
||||
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
return environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
|
||||
}
|
||||
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
@@ -282,10 +300,19 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
|
||||
func currentRenderSignature() -> String {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
return [
|
||||
return renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
|
||||
}
|
||||
|
||||
private func renderSignature(
|
||||
style: RDEPUBTextRenderStyle,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
) -> String {
|
||||
[
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(configuration.lineHeightMultiple)",
|
||||
|
||||
@@ -46,10 +46,11 @@ final class RDEPUBReaderEnvironment {
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
pageSize: CGSize
|
||||
) -> RDEPUBTextLayoutConfig {
|
||||
RDEPUBTextLayoutConfig(
|
||||
let layoutContext = currentLayoutContext(configuration: configuration)
|
||||
return RDEPUBTextLayoutConfig(
|
||||
frameWidth: max(pageSize.width, 1),
|
||||
frameHeight: max(pageSize.height, 1),
|
||||
edgeInsets: configuration.reflowableContentInsets,
|
||||
edgeInsets: layoutContext.safeReflowableContentInsets,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
avoidOrphans: false,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderLoadCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
private weak var context: RDEPUBReaderContext?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func startInitialLoadIfNeeded() {
|
||||
guard let controller = context.controller,
|
||||
guard let context, let controller = context.controller,
|
||||
let readerView = context.readerView,
|
||||
!controller.didStartInitialLoad,
|
||||
readerView.bounds.width > 0,
|
||||
@@ -20,14 +20,14 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
}
|
||||
|
||||
func loadPublication() {
|
||||
guard let controller = context.controller else { return }
|
||||
guard let context, let controller = context.controller else { return }
|
||||
context.showLoading()
|
||||
let loadToken = UUID()
|
||||
context.paginationToken = loadToken
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
|
||||
guard let controller else { return }
|
||||
let parser = self.context.makeParser()
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
guard let self, let context = self.context, let controller = context.controller else { return }
|
||||
let parser = context.makeParser()
|
||||
|
||||
do {
|
||||
try parser.parse(epubURL: controller.epubURL)
|
||||
@@ -37,9 +37,10 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
let bookmarks = controller.persistence?.loadBookmarks(for: bookIdentifier) ?? []
|
||||
let highlights = controller.persistence?.loadHighlights(for: bookIdentifier) ?? []
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == loadToken else { return }
|
||||
self.context.runtime?.applyParsedPublication(
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let context = self.context else { return }
|
||||
guard context.paginationToken == loadToken else { return }
|
||||
context.runtime?.applyParsedPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
@@ -49,9 +50,10 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == loadToken else { return }
|
||||
self.context.handle(error: error)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let context = self.context else { return }
|
||||
guard context.paginationToken == loadToken else { return }
|
||||
context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,14 +67,13 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
bookmarks: [RDEPUBBookmark],
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
guard let context, let controller = context.controller else { return }
|
||||
context.parser = parser
|
||||
context.publication = publication
|
||||
context.currentBookIdentifier = bookIdentifier
|
||||
context.activeBookmarks = bookmarks
|
||||
context.activeHighlights = highlights
|
||||
context.readingSession = RDEPUBReadingSession(publication: publication)
|
||||
context.readingSession?.transition(to: .loading)
|
||||
controller.title = parser.metadata.title.isEmpty
|
||||
? controller.epubURL.deletingPathExtension().lastPathComponent
|
||||
: parser.metadata.title
|
||||
|
||||
@@ -22,7 +22,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
}
|
||||
guard let targetPageNumber = controller.pageNumber(for: location, rangeInfo: targetHighlightRangeInfo) else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -44,7 +43,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
} else {
|
||||
context.readingSession?.transition(to: .jumping)
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
|
||||
|
||||
|
||||
+18
-15
@@ -23,7 +23,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
controller.isRepaginating = true
|
||||
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .repaginating)
|
||||
controller.errorLabel.isHidden = true
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
@@ -51,7 +50,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
let paginator = context.makePaginator()
|
||||
context.paginator = paginator
|
||||
paginator.calculate(
|
||||
parser: parser,
|
||||
hostingView: controller.ensurePaginationHostView(),
|
||||
@@ -63,7 +61,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
preferences: controller.currentPreferences(),
|
||||
layoutContext: controller.currentLayoutContext()
|
||||
)
|
||||
self.context.paginator = nil
|
||||
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
@@ -106,15 +103,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
controller.isRepaginating = false
|
||||
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
controller.hideLoading()
|
||||
readerView.reloadData()
|
||||
if let targetLocation = restoreLocation {
|
||||
controller.restoreReadingLocation(targetLocation)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
} else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
}
|
||||
|
||||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||||
@@ -171,10 +165,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutSnapshot = context.makeLayoutSnapshot()
|
||||
let runtime = context.runtime
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||||
guard controller != nil else { return }
|
||||
guard context.controller != nil else { return }
|
||||
guard let runtime else { return }
|
||||
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
@@ -190,16 +186,16 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
do {
|
||||
guard context.controller != nil,
|
||||
let runtime = context.runtime else { return }
|
||||
let runtimeChapter = try self.loadFirstRenderableRuntimeChapter(
|
||||
prioritizedSpineIndices: prioritizedCandidates,
|
||||
runtime: runtime
|
||||
runtime: runtime,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
|
||||
anchorChapter: runtimeChapter,
|
||||
publication: publication,
|
||||
runtime: runtime
|
||||
runtime: runtime,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
@@ -238,14 +234,16 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
|
||||
private func loadFirstRenderableRuntimeChapter(
|
||||
prioritizedSpineIndices: [Int],
|
||||
runtime: RDEPUBReaderRuntime
|
||||
runtime: RDEPUBReaderRuntime,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
var lastError: Error?
|
||||
for spineIndex in prioritizedSpineIndices {
|
||||
do {
|
||||
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: runtime.chapterRuntimeStore
|
||||
store: runtime.chapterRuntimeStore,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
} catch {
|
||||
lastError = error
|
||||
@@ -257,7 +255,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
private func loadInitialInteractiveRuntimeChapters(
|
||||
anchorChapter: RDEPUBRuntimeChapter,
|
||||
publication: RDEPUBPublication,
|
||||
runtime: RDEPUBReaderRuntime
|
||||
runtime: RDEPUBReaderRuntime,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot
|
||||
) -> [RDEPUBRuntimeChapter] {
|
||||
let minimumInteractivePageCount = 2
|
||||
let maximumAdditionalChapters = 1
|
||||
@@ -283,10 +282,14 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
do {
|
||||
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: runtime.chapterRuntimeStore
|
||||
store: runtime.chapterRuntimeStore,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
selectedChapters.append(chapter)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBReaderPaginationCoordinator] ⚠️ Failed to load chapter at spineIndex \(spineIndex): \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -601,7 +601,6 @@ final class RDEPUBReaderRuntime {
|
||||
backgroundPriorityManager.reset()
|
||||
backgroundCoverageStore.clearAll()
|
||||
chapterWarmupOrchestrator.clear()
|
||||
presentationRuntime.clear()
|
||||
}
|
||||
|
||||
func handleMemoryWarning() {
|
||||
@@ -618,6 +617,7 @@ final class RDEPUBReaderRuntime {
|
||||
activeWindowSpineIndices: activeWindowIndices,
|
||||
protectedSpineIndices: protectedIndices
|
||||
)
|
||||
chapterRuntimeStore.handleMemoryWarning()
|
||||
}
|
||||
|
||||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||||
|
||||
@@ -4,6 +4,24 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let searchQueue = DispatchQueue(label: "com.ssreaderview.epub.search", qos: .userInitiated)
|
||||
|
||||
private let tokenLock = NSLock()
|
||||
private var _currentSearchToken: UUID = UUID()
|
||||
|
||||
private var currentSearchToken: UUID {
|
||||
get {
|
||||
tokenLock.lock()
|
||||
defer { tokenLock.unlock() }
|
||||
return _currentSearchToken
|
||||
}
|
||||
set {
|
||||
tokenLock.lock()
|
||||
_currentSearchToken = newValue
|
||||
tokenLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
@@ -20,18 +38,31 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
let matches = resolvedSearchMatches(for: normalizedKeyword)
|
||||
controller.searchState = RDEPUBSearchState(
|
||||
keyword: normalizedKeyword,
|
||||
matches: matches,
|
||||
currentMatchIndex: matches.isEmpty ? nil : 0
|
||||
)
|
||||
notifySearchStateChanged()
|
||||
let token = UUID()
|
||||
currentSearchToken = token
|
||||
|
||||
if matches.isEmpty {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
} else {
|
||||
_ = navigateToCurrentSearchMatch(animated: false)
|
||||
let searchEngine = makeSearchEngine()
|
||||
let layoutSnapshot = context.makeLayoutSnapshot()
|
||||
|
||||
searchQueue.async { [weak self] in
|
||||
guard let self, self.currentSearchToken == token else { return }
|
||||
let matches = self.performSearch(using: searchEngine, keyword: normalizedKeyword, layoutSnapshot: layoutSnapshot)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.currentSearchToken == token else { return }
|
||||
guard let controller = self.context.controller else { return }
|
||||
controller.searchState = RDEPUBSearchState(
|
||||
keyword: normalizedKeyword,
|
||||
matches: matches,
|
||||
currentMatchIndex: matches.isEmpty ? nil : 0
|
||||
)
|
||||
self.notifySearchStateChanged()
|
||||
if matches.isEmpty {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
} else {
|
||||
_ = self.navigateToCurrentSearchMatch(animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +83,6 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
searchState.matches.indices.contains(index) else {
|
||||
return false
|
||||
}
|
||||
|
||||
searchState.currentMatchIndex = index
|
||||
controller.searchState = searchState
|
||||
notifySearchStateChanged()
|
||||
@@ -60,6 +90,7 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
currentSearchToken = UUID()
|
||||
guard let controller else { return }
|
||||
controller.searchState = nil
|
||||
notifySearchStateChanged()
|
||||
@@ -105,29 +136,43 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
|
||||
}
|
||||
|
||||
private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
|
||||
guard let controller else { return [] }
|
||||
if let textBook = controller.textBook {
|
||||
if let publication = controller.publication {
|
||||
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
|
||||
}
|
||||
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
|
||||
// MARK: - Private
|
||||
|
||||
private func makeSearchEngine() -> SearchEngineSnapshot? {
|
||||
guard let controller else { return nil }
|
||||
if let textBook = controller.textBook, let publication = controller.publication {
|
||||
return .textBook(textBook, publication)
|
||||
}
|
||||
if controller.readerContext.bookPageMap != nil, controller.publication != nil {
|
||||
return resolvedOnDemandSearchMatches(for: keyword)
|
||||
if controller.readerContext.bookPageMap != nil, let publication = controller.publication {
|
||||
return .onDemand(publication)
|
||||
}
|
||||
if let parser = controller.parser, let publication = controller.publication {
|
||||
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
||||
return .html(parser, publication)
|
||||
}
|
||||
return []
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolvedOnDemandSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
|
||||
guard let controller,
|
||||
let publication = controller.publication else {
|
||||
return []
|
||||
private func performSearch(using engine: SearchEngineSnapshot?, keyword: String, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
|
||||
guard let engine else { return [] }
|
||||
switch engine {
|
||||
case .textBook(let textBook, let publication):
|
||||
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
|
||||
case .onDemand(let publication):
|
||||
return resolvedOnDemandSearchMatches(for: keyword, publication: publication, layoutSnapshot: layoutSnapshot)
|
||||
case .html(let parser, let publication):
|
||||
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
||||
}
|
||||
}
|
||||
|
||||
private enum SearchEngineSnapshot {
|
||||
case textBook(RDEPUBTextBook, RDEPUBPublication)
|
||||
case onDemand(RDEPUBPublication)
|
||||
case html(RDEPUBParser, RDEPUBPublication)
|
||||
}
|
||||
|
||||
// H-09: Each chapter iteration is wrapped in autoreleasepool to release
|
||||
// the chapter's typesetAttributedString memory between iterations.
|
||||
private func resolvedOnDemandSearchMatches(for keyword: String, publication: RDEPUBPublication, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return []
|
||||
@@ -140,52 +185,58 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
for spineIndex in buildableSpineIndices {
|
||||
guard let chapter = try? controller.runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: controller.runtime.chapterRuntimeStore
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
|
||||
let source = chapter.typesetAttributedString.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { continue }
|
||||
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else {
|
||||
break
|
||||
let chapterMatches: [RDEPUBSearchMatch] = autoreleasepool {
|
||||
guard let chapter = try? context.runtime?.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: context.runtime?.chapterRuntimeStore ?? RDEPUBChapterRuntimeStore(),
|
||||
layoutSnapshot: layoutSnapshot
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length,
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
|
||||
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
|
||||
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
|
||||
let source = chapter.typesetAttributedString.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { return [] }
|
||||
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
|
||||
var localMatches: [RDEPUBSearchMatch] = []
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else {
|
||||
break
|
||||
}
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
||||
localMatches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: max(foundRange.length, 1),
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
|
||||
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
return localMatches
|
||||
} // end autoreleasepool
|
||||
matches.append(contentsOf: chapterMatches)
|
||||
}
|
||||
|
||||
return matches
|
||||
@@ -236,8 +287,9 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
private func notifySearchStateChanged() {
|
||||
guard let controller else { return }
|
||||
controller.delegate?.epubReader(controller, didUpdateSearchResult: controller.searchState?.result)
|
||||
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: controller.searchState?.currentMatch)
|
||||
let state = controller.searchState
|
||||
controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
|
||||
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
|
||||
}
|
||||
|
||||
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
|
||||
@@ -346,4 +398,4 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import UIKit
|
||||
|
||||
/// M-01: All properties must be accessed exclusively from the main thread.
|
||||
/// This is currently enforced by convention — all verified access paths are main-thread-only.
|
||||
/// Adding @MainActor would formalize this but requires iOS 15+ and Swift concurrency throughout.
|
||||
/// For now, rely on the audit-verified access patterns and consider @MainActor in a future refactor.
|
||||
final class RDEPUBReaderState {
|
||||
|
||||
var parser: RDEPUBParser?
|
||||
@@ -20,8 +24,6 @@ final class RDEPUBReaderState {
|
||||
|
||||
var paginationToken = UUID()
|
||||
|
||||
var paginator: RDEPUBPaginator?
|
||||
|
||||
var searchState: RDEPUBSearchState?
|
||||
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
|
||||
|
||||
@@ -169,11 +169,19 @@ extension RDEPUBReaderConfiguration {
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
|
||||
func makePreferences() -> RDEPUBPreferences {
|
||||
RDEPUBPreferences(
|
||||
func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
|
||||
// Use the larger of reflowableContentInsets and safeAreaInsets for each edge
|
||||
// to prevent content from being hidden under Dynamic Island / home indicator.
|
||||
let safeInsets = UIEdgeInsets(
|
||||
top: max(reflowableContentInsets.top, safeAreaInsets.top),
|
||||
left: max(reflowableContentInsets.left, safeAreaInsets.left),
|
||||
bottom: max(reflowableContentInsets.bottom, safeAreaInsets.bottom),
|
||||
right: max(reflowableContentInsets.right, safeAreaInsets.right)
|
||||
)
|
||||
return RDEPUBPreferences(
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
reflowableContentInsets: reflowableContentInsets,
|
||||
reflowableContentInsets: safeInsets,
|
||||
fixedContentInset: fixedContentInset,
|
||||
numberOfColumns: numberOfColumns,
|
||||
columnGap: columnGap,
|
||||
|
||||
@@ -6,7 +6,20 @@ import DTCoreText
|
||||
|
||||
enum RDEPUBDarkImageAdjuster {
|
||||
|
||||
private static let imageCache = NSCache<NSString, UIImage>()
|
||||
private static let imageCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 100
|
||||
cache.totalCostLimit = 52_428_800 // 50 MB
|
||||
return cache
|
||||
}()
|
||||
|
||||
private static func imageCost(of image: UIImage) -> Int {
|
||||
let scale = image.scale
|
||||
let width = Int(image.size.width * scale)
|
||||
let height = Int(image.size.height * scale)
|
||||
// 4 bytes per pixel (RGBA)
|
||||
return width * height * 4
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
@@ -84,7 +97,7 @@ enum RDEPUBDarkImageAdjuster {
|
||||
context.cgContext.setBlendMode(.sourceAtop)
|
||||
context.fill(CGRect(origin: .zero, size: image.size))
|
||||
}
|
||||
imageCache.setObject(adjusted, forKey: cacheKey)
|
||||
imageCache.setObject(adjusted, forKey: cacheKey, cost: imageCost(of: adjusted))
|
||||
return adjusted
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ extension RDEPUBTextContentViewDelegate {
|
||||
|
||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
|
||||
|
||||
private static let pageNumberTrailingPadding: CGFloat = 4
|
||||
private static let pageNumberFooterPadding: CGFloat = 8
|
||||
private static let pageNumberReservedHeight = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
|
||||
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
@@ -56,6 +60,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
|
||||
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
||||
// Stored as Any? to avoid @available on stored property restriction.
|
||||
private var _editMenuInteraction: Any?
|
||||
|
||||
private let selectionLoupeView = RDEPUBSelectionLoupeView()
|
||||
|
||||
private var currentHighlights: [RDEPUBHighlight] = []
|
||||
@@ -241,6 +249,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
||||
selectionLoupeView.isHidden = true
|
||||
|
||||
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
|
||||
if #available(iOS 16.0, *) {
|
||||
let interaction = UIEditMenuInteraction(delegate: self)
|
||||
addInteraction(interaction)
|
||||
self._editMenuInteraction = interaction
|
||||
}
|
||||
|
||||
selectionController.onSelectionChanged = { [weak self] selection in
|
||||
guard let self else { return }
|
||||
self.currentSelection = selection
|
||||
@@ -322,10 +337,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
overlayView.frame = bounds.inset(by: contentInsets)
|
||||
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||
|
||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||
let contentRect = bounds.inset(by: contentInsets)
|
||||
let labelSize = pageNumberLabel.sizeThatFits(
|
||||
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
|
||||
)
|
||||
let footerOriginY = bounds.maxY - contentInsets.bottom
|
||||
let footerVerticalInset = max((contentInsets.bottom - labelSize.height) / 2, 0)
|
||||
pageNumberLabel.frame = CGRect(
|
||||
x: bounds.width - labelSize.width - 24,
|
||||
y: bounds.height - labelSize.height - 20,
|
||||
x: bounds.maxX - contentInsets.right - labelSize.width - Self.pageNumberTrailingPadding,
|
||||
y: footerOriginY + footerVerticalInset,
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
@@ -352,7 +372,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
currentHighlights = highlights
|
||||
currentSearchState = searchState
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
contentInsets = Self.safeContentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: safeAreaInsets
|
||||
)
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
@@ -434,7 +457,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
currentHighlights = []
|
||||
currentSearchState = nil
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
contentInsets = Self.safeContentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: safeAreaInsets
|
||||
)
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
@@ -471,7 +497,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
updateSelectionPanAvailability()
|
||||
updateViewInteractionAvailability()
|
||||
updateAccessibilityDecorationSummary()
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
hideSelectionMenu()
|
||||
}
|
||||
|
||||
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
|
||||
@@ -562,25 +588,47 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
|
||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||
let content = NSMutableAttributedString(attributedString: page.content)
|
||||
guard shouldNormalizeContinuationParagraph(for: page) else { return content }
|
||||
|
||||
guard content.length > 0 else { return content }
|
||||
let text = content.string as NSString
|
||||
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
|
||||
guard firstParagraphRange.length > 0 else { return content }
|
||||
guard !firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
|
||||
|
||||
if shouldNormalizeLeadingParagraphSpacing(for: page) {
|
||||
let leadingRange = firstNonWhitespaceParagraphRange(in: content) ?? firstParagraphRange
|
||||
updateParagraphStyle(in: content, range: leadingRange) { style in
|
||||
style.paragraphSpacingBefore = 0
|
||||
}
|
||||
}
|
||||
|
||||
guard shouldNormalizeContinuationParagraph(for: page),
|
||||
!firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
|
||||
return content
|
||||
}
|
||||
|
||||
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
|
||||
guard let style = value as? NSParagraphStyle else { return }
|
||||
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
||||
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
|
||||
mutableStyle.paragraphSpacingBefore = 0
|
||||
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
|
||||
updateParagraphStyle(in: content, range: firstParagraphRange) { style in
|
||||
style.firstLineHeadIndent = style.headIndent
|
||||
style.paragraphSpacingBefore = 0
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
private func shouldNormalizeLeadingParagraphSpacing(for page: RDEPUBTextPage) -> Bool {
|
||||
page.pageStartOffset == 0
|
||||
}
|
||||
|
||||
private func firstNonWhitespaceParagraphRange(in content: NSAttributedString) -> NSRange? {
|
||||
let text = content.string as NSString
|
||||
var index = 0
|
||||
while index < text.length {
|
||||
guard let scalar = UnicodeScalar(text.character(at: index)) else { break }
|
||||
if !CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
return text.paragraphRange(for: NSRange(location: index, length: 0))
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
|
||||
let pageStart = page.pageStartOffset
|
||||
guard pageStart > 0, pageStart < page.chapterContent.length else { return false }
|
||||
@@ -589,6 +637,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
return !CharacterSet.newlines.contains(previousScalar)
|
||||
}
|
||||
|
||||
private func updateParagraphStyle(
|
||||
in content: NSMutableAttributedString,
|
||||
range: NSRange,
|
||||
transform: (NSMutableParagraphStyle) -> Void
|
||||
) {
|
||||
content.enumerateAttribute(.paragraphStyle, in: range) { value, attributeRange, _ in
|
||||
guard let style = value as? NSParagraphStyle else { return }
|
||||
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
||||
transform(mutableStyle)
|
||||
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: attributeRange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func firstParagraphUsesNonLeadingAlignment(
|
||||
in content: NSAttributedString,
|
||||
range: NSRange
|
||||
@@ -743,19 +805,29 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
!targetRect.isEmpty else {
|
||||
return
|
||||
}
|
||||
becomeFirstResponder()
|
||||
let menuController = UIMenuController.shared
|
||||
menuController.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
]
|
||||
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||||
let anchor = CGPoint(x: targetRect.midX, y: targetRect.midY)
|
||||
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
|
||||
interaction.presentEditMenu(with: config)
|
||||
} else {
|
||||
becomeFirstResponder()
|
||||
let menuController = UIMenuController.shared
|
||||
menuController.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
]
|
||||
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func hideSelectionMenu() {
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||||
interaction.dismissMenu()
|
||||
} else {
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectionLoupe(for point: CGPoint) {
|
||||
@@ -954,3 +1026,51 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
menuFor configuration: UIEditMenuConfiguration
|
||||
) -> UIMenu {
|
||||
UIMenu(children: [
|
||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
])
|
||||
}
|
||||
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
targetRectFor configuration: UIEditMenuConfiguration
|
||||
) -> CGRect {
|
||||
if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
||||
!targetRect.isEmpty {
|
||||
return targetRect
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Safe Content Insets
|
||||
|
||||
extension RDEPUBTextContentView {
|
||||
|
||||
/// Computes content insets that account for safe areas (Dynamic Island, home indicator).
|
||||
/// Uses the larger of safeAreaInsets and reflowableContentInsets for each edge,
|
||||
/// ensuring content is never hidden under the Dynamic Island or home indicator area.
|
||||
private static func safeContentInsets(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
safeAreaInsets: UIEdgeInsets
|
||||
) -> UIEdgeInsets {
|
||||
let configInsets = configuration.reflowableContentInsets
|
||||
return UIEdgeInsets(
|
||||
top: max(configInsets.top, safeAreaInsets.top),
|
||||
left: max(configInsets.left, safeAreaInsets.left),
|
||||
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
|
||||
right: max(configInsets.right, safeAreaInsets.right)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,6 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
selectionEndIndex = NSNotFound
|
||||
setInteractionState(.idle)
|
||||
renderView?.selectionRects = []
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
onSelectionChanged?(nil)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user