10 Commits
Author SHA1 Message Date
shenleiandClaude Fable 5 d0efe3f2cc 安全区域改为从 key window 动态获取
- 新增 RDEPUBSafeArea:参考 GKNavigationBarSwift 的 keyWindow 三级查找与临时 window 兜底
- 移除 reflowableContentInsets 中 top/bottom 40 的固定默认值,改按设备真实安全区生成
- 各取安全区处的 ?? .zero 兜底改为回退到 key window 值
- DEBUG 构建下为文本可读区域绘制红色边框,方便查看排版范围

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:04:22 +09:00
shenleiandClaude Fable 5 ca408c20ab 收紧脚注图的 alt 兜底判定
原判定 alt 非空即视为脚注图,导致带 alt 的正常插图(如凡人修仙传的
logo)被缩到一个字号大小。改为 alt 长度达到脚注正文量级(≥8 字符)
且原图为小图标(≤50pt)双条件同时成立才走脚注缩放,与 HTML 归一化
层只认 qqreader-footnote class 的口径对齐。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:16:53 +09:00
shenleiandClaude Fable 5 5ae7823ef8 全量换表前用旧页表回环验证当前位置
初始 partial 窗口被全书页表接管时,live 页码(窗口坐标)与新表解析
页码(全书坐标)不可比,±1 启发式误判后 preserveLivePage 会把模型
位置钉在全书第 1 页,导致位置错乱并级联阻塞后续全表提交。改为换表
前先用旧表解析 currentLocation:回环等于 live 页说明位置忠实反映
屏幕内容,换表后无条件信任新表解析结果;不等时才退回 ±1 门槛,保留
对 stale 持久化位置的防护。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:56:22 +09:00
shenleiandClaude Fable 5 bd6e915fbd 邻接窗口淘汰不再清除前瞻预取章节
scheduleAdjacentChapterPrefetches 的窗口外淘汰会把
maybePrefetchUpcomingChapters 刚预取的后续章节清掉,下一次
prepare 又触发重建,形成"淘汰-重建"循环。淘汰时保留当前章之后
lookahead 范围内的章节,lookahead 数量提为共享常量。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:56:22 +09:00
shenleiandClaude Fable 5 7132e4952b 章节加载器补充三级缓存命中日志
loadChapterWithSnapshot 打印内存章节 HIT、并发加载合流、构建来源
(memoryPageCount/diskSummary/fullRender)与构建耗时,便于判断章节
跳转时预加载与缓存的命中情况。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:34:02 +09:00
shenleiandClaude Fable 5 f796823db8 预加载控制器补充页面视图缓存命中日志
pageViewForDisplay / prime / takePreloadedView 三条取视图路径打印
HIT/MISS 及视图地址,invalidate 打印被清空的缓存页码,便于排查
翻页时页面视图是否命中缓存。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:22:09 +09:00
shenleiandClaude Fable 5 47fe2dc450 页表全量接管改为按子集覆盖判定
fullReplace 从"候选章节数 >= 当前章节数"改为"候选页表必须覆盖当前窗口
的全部章节",避免跳章后 partial map 数量达标但缺少远端已映射章节时,
全量替换丢弃这些条目导致总页数抖动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:14:48 +09:00
shenandshenlei 9e91207011 优化 EPUB 翻页与后台分页刷新 2026-07-06 11:08:28 +09:00
shenlei 71f4feb12f Ignore local build caches 2026-07-03 16:13:50 +09:00
shenlei 99bdc98895 Fix reader interaction and web resource handling 2026-07-03 16:12:11 +09:00
23 changed files with 2170 additions and 1562 deletions
+2
View File
@@ -4,3 +4,5 @@ xcuserdata/
# Build artifacts
.artifacts/
.deriveddata/
.swift-module-cache/
File diff suppressed because it is too large Load Diff
@@ -18,7 +18,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
pagesPerScreen: Int = 1,
safeAreaInsets: UIEdgeInsets = .zero,
userInterfaceIdiom: UIUserInterfaceIdiom = .phone,
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16)
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets()
) {
self.containerSize = containerSize
self.pagesPerScreen = max(1, pagesPerScreen)
@@ -156,6 +156,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
return
} catch {
guard self.isTaskActive(taskID) else {
DispatchQueue.main.async {
@@ -207,14 +208,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
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 {
@@ -222,6 +217,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
DispatchQueue.main.async {
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
}
self.clearTask(taskID)
return
}
let data = fileHandle.readData(ofLength: chunkSize)
@@ -230,7 +226,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
urlSchemeTask.didReceive(data)
}
}
streamingCompleted = true
self.dispatchTaskSuccessCallback(
taskID: taskID,
urlSchemeTask: urlSchemeTask
@@ -265,6 +260,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
return
}
callback()
self.clearTask(taskID)
}
}
@@ -0,0 +1,67 @@
import UIKit
/// Device-level safe area lookup modeled after GKNavigationBarSwift.
/// Reads the real safe area from the key window so correct values are
/// available even before a view has been laid out in the hierarchy.
/// Must be called on the main thread.
public enum RDEPUBSafeArea {
/// Minimum text margins applied when the device safe area on an edge is
/// smaller (e.g. no notch / no home indicator). These are aesthetic
/// paddings, not approximations of the safe area itself.
public static let minimumVerticalTextMargin: CGFloat = 20
public static let minimumHorizontalTextMargin: CGFloat = 16
public static func keyWindow() -> UIWindow? {
let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
if let window = scenes
.filter({ $0.activationState == .foregroundActive })
.flatMap({ $0.windows })
.first(where: { $0.isKeyWindow }) {
return window
}
if let window = scenes
.flatMap({ $0.windows })
.first(where: { $0.isKeyWindow }) {
return window
}
return UIApplication.shared.delegate?.window ?? nil
}
public static func insets() -> UIEdgeInsets {
if let window = keyWindow() {
return window.safeAreaInsets
}
// No key window yet (early launch): create a detached window to read
// the device safe area, same fallback as GKNavigationBarSwift.
let window = UIWindow(frame: UIScreen.main.bounds)
if window.safeAreaInsets.bottom <= 0 {
window.rootViewController = UIViewController()
}
return window.safeAreaInsets
}
/// Prefers insets measured from a view already installed in the hierarchy;
/// falls back to the key-window insets when the view is not laid out yet
/// and reports .zero.
public static func resolve(_ measuredInsets: UIEdgeInsets?) -> UIEdgeInsets {
if let measuredInsets, measuredInsets != .zero {
return measuredInsets
}
return insets()
}
/// Default reflowable content insets derived from the live device safe
/// area instead of hard-coded heights.
public static func defaultReflowableContentInsets() -> UIEdgeInsets {
let safe = insets()
return UIEdgeInsets(
top: max(safe.top, minimumVerticalTextMargin),
left: max(safe.left, minimumHorizontalTextMargin),
bottom: max(safe.bottom, minimumVerticalTextMargin),
right: max(safe.right, minimumHorizontalTextMargin)
)
}
}
@@ -137,7 +137,7 @@ public final class RDEPUBTextBookCache {
let fileURL = cacheDirectory.appendingPathComponent(key)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
#if DEBUG
print("[Cache] load MISS key=\(key)")
// print("[Cache] load MISS key=\(key)")
#endif
return nil
}
@@ -148,7 +148,7 @@ public final class RDEPUBTextBookCache {
from: data
) else {
#if DEBUG
print("[Cache] load MISS key=\(key) (unarchive returned nil)")
// print("[Cache] load MISS key=\(key) (unarchive returned nil)")
#endif
return nil
}
@@ -157,12 +157,12 @@ public final class RDEPUBTextBookCache {
result[chapter.href] = chapter.toCache()
}
#if DEBUG
print("[Cache] load HIT key=\(key) chapters=\(result.count)")
// print("[Cache] load HIT key=\(key) chapters=\(result.count)")
#endif
return result
} catch {
#if DEBUG
print("[Cache] load MISS key=\(key) error=\(error)")
// print("[Cache] load MISS key=\(key) error=\(error)")
#endif
return nil
}
@@ -136,8 +136,11 @@ struct RDEPUBAttachmentNormalizer {
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
if lowercasedClasses.contains("qqreader-footnote") {
return true
}
let altText = attachment.attributes["alt"] as? String
return lowercasedClasses.contains("qqreader-footnote") || hasFootnoteAltText(altText)
return hasFootnoteAltText(altText) && isFootnoteSizedImage(attachment.originalSize)
}
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
@@ -224,10 +227,31 @@ struct RDEPUBAttachmentNormalizer {
}
let label = fileAttachment.accessibilityLabel
let lowercasedLabel = (label ?? "").lowercased()
return lowercasedLabel.contains("qqreader-footnote") || hasFootnoteAltText(label)
if lowercasedLabel.contains("qqreader-footnote") {
return true
}
let imageSize = fileAttachment.image?.size ?? fileAttachment.bounds.size
return hasFootnoteAltText(label) && isFootnoteSizedImage(imageSize)
}
// Footnote images without the qqreader-footnote class are recognized by their
// alt text carrying the note body. Short alts ("logo", "1") are ordinary
// accessibility descriptions, and note markers are small inline icons, so both
// conditions must hold before an image is shrunk to footnote size.
private static let minimumFootnoteAltTextLength = 8
private static let maximumFootnoteImageDimension: CGFloat = 50
private static func hasFootnoteAltText(_ text: String?) -> Bool {
text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return false
}
return trimmed.count >= minimumFootnoteAltTextLength
}
private static func isFootnoteSizedImage(_ size: CGSize) -> Bool {
guard size.width > 0, size.height > 0 else { return false }
return size.width <= maximumFootnoteImageDimension
&& size.height <= maximumFootnoteImageDimension
}
}
@@ -9,6 +9,10 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
return
}
guard readerView.pageContentView(pageNum: readerView.currentPage) === contentView else {
return
}
let currentPage = activePages[readerView.currentPage]
guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else {
return
@@ -89,6 +93,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
RDReaderTapDebug.log(
"ReaderController.textContentTap",
"delegate received reader tap from contentView=\(RDReaderTapDebug.describe(contentView)) point=\(RDReaderTapDebug.describe(point)) currentPage=\(readerView.currentPage)"
)
readerView.handleContentTap(at: point, in: contentView)
}
@@ -154,7 +154,7 @@ public final class RDURLReaderController: UIViewController {
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle()
let safeInsets = view.safeAreaInsets
let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
let edgeInsets = UIEdgeInsets(
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
@@ -65,6 +65,10 @@ final class RDEPUBChapterLoader {
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)"
)
if let context {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
@@ -92,6 +96,10 @@ final class RDEPUBChapterLoader {
switch registration {
case .joined(let existingPriority, let effectivePriority):
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)"
)
return
case .created:
break
@@ -120,6 +128,20 @@ final class RDEPUBChapterLoader {
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
let pageRangeSource: String
if precomputedPageRanges != nil {
pageRangeSource = "HIT(memoryPageCount)"
} else if diskPageRanges != nil {
pageRangeSource = "HIT(diskSummary)"
} else {
pageRangeSource = "MISS(fullRender)"
}
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)"
)
let buildStart = CFAbsoluteTimeGetCurrent()
do {
let chapter = try self.buildChapter(
@@ -130,6 +152,10 @@ final class RDEPUBChapterLoader {
layoutSnapshot: layoutSnapshot
)
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)"
)
store.insertChapter(chapter)
let pc = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
@@ -172,6 +198,10 @@ final class RDEPUBChapterLoader {
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
}
} catch {
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))"
)
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
@@ -32,6 +32,8 @@ final class RDEPUBChapterWarmupOrchestrator {
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
private static let upcomingChapterLookaheadCount = 2
init(
context: RDEPUBReaderContext,
store: RDEPUBChapterRuntimeStore,
@@ -392,7 +394,10 @@ final class RDEPUBChapterWarmupOrchestrator {
windowRadius: context.configuration.chapterWindowRadius
)
for evictable in store.evictableSpineIndices() {
// Keep chapters that maybePrefetchUpcomingChapters is responsible for,
// otherwise the two policies evict/rebuild the same chapter in a loop.
let retainedLookaheadIndices = upcomingLookaheadSpineIndices(after: spineIndex)
for evictable in store.evictableSpineIndices() where !retainedLookaheadIndices.contains(evictable) {
store.evict(spineIndex: evictable)
}
@@ -410,11 +415,18 @@ final class RDEPUBChapterWarmupOrchestrator {
}
}
private func upcomingLookaheadSpineIndices(after spineIndex: Int) -> Set<Int> {
guard let publication = context.publication else { return [] }
let buildableIndices = buildableSpineIndices(in: publication)
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return [] }
return Set(buildableIndices.dropFirst(currentPosition + 1).prefix(Self.upcomingChapterLookaheadCount))
}
private func maybePrefetchUpcomingChapters(
aroundAbsolutePageNumber pageNumber: Int,
in bookPageMap: RDEPUBBookPageMap,
threshold: Int = 3,
lookaheadChapterCount: Int = 2
lookaheadChapterCount: Int = RDEPUBChapterWarmupOrchestrator.upcomingChapterLookaheadCount
) {
guard let publication = context.publication else { return }
let absolutePageIndex = pageNumber - 1
@@ -56,6 +56,8 @@ final class RDEPUBMetadataParseWorker {
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
private let progressLogStride = 4
init(
context: RDEPUBReaderContext,
cancellationController: RDEPUBMetadataParseCancellationController,
@@ -116,19 +118,38 @@ final class RDEPUBMetadataParseWorker {
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
let cancellationController = self.cancellationController
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return }
DispatchQueue.global(qos: .utility).async { [self] in
RDEPUBBackgroundTrace.log(
"Metadata",
"workerDispatched token=\(token.uuidString)"
)
let context = self.context
guard let context,
context.controller != nil,
!cancellationController.isCancelled,
context.paginationToken == token else { return }
context.paginationToken == token else {
RDEPUBBackgroundTrace.log(
"Metadata",
"workerAbortedBeforeStart reason=contextUnavailableOrTokenMismatch"
)
return
}
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
guard context.controller != nil,
!cancellationController.isCancelled,
context.paginationToken == token else { return }
context.paginationToken == token else {
RDEPUBBackgroundTrace.log(
"Metadata",
"workerAbortedAfterStart reason=contextUnavailableOrTokenMismatch"
)
return
}
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
RDEPUBBackgroundTrace.log(
"Metadata",
"restoreBookPageMapIfPossible hit totalChapters=\(restoredPageMap.totalChapters) totalPages=\(restoredPageMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
@@ -175,13 +196,26 @@ final class RDEPUBMetadataParseWorker {
let uncachedSpineIndices = prioritizedSpineIndices
RDEPUBBackgroundTrace.log(
"Metadata",
"waitingForInteractionCooldown elapsedSinceNavigation=\(String(format: "%.2f", context.secondsSinceLastUserNavigation())) uncached=\(uncachedSpineIndices.count)"
)
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
guard !cancellationController.isCancelled,
context.controller != nil,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log(
"Metadata",
"workerAbortedDuringCooldown"
)
return
}
RDEPUBBackgroundTrace.log(
"Metadata",
"start totalBuildable=\(self.allBuildableIndices.count) cached=\(cachedSpineIndices.count) uncached=\(uncachedSpineIndices.count) concurrency=\(self.workerCount) refreshInterval=\(self.pageMapRefreshInterval)"
)
let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0
var totalWriteMs: Double = 0
@@ -283,6 +317,10 @@ final class RDEPUBMetadataParseWorker {
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = renderResult
parseState.totalResolvedCount += 1
let resolvedCount = parseState.totalResolvedCount
let shouldLogProgress = resolvedCount == self.allBuildableIndices.count
|| resolvedCount == cachedSpineIndices.count + 1
|| resolvedCount % self.progressLogStride == 0
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
parseState.lastAppliedCount = parseState.totalResolvedCount
@@ -290,6 +328,17 @@ final class RDEPUBMetadataParseWorker {
}
resultLock.unlock()
if shouldLogProgress {
let progressPercent = Self.progressPercent(
resolved: resolvedCount,
total: self.allBuildableIndices.count
)
RDEPUBBackgroundTrace.log(
"Metadata",
"chapterReady spine=\(spineIndex) resolved=\(resolvedCount)/\(self.allBuildableIndices.count) progress=\(progressPercent)% pageCount=\(renderResult.pageCount)"
)
}
if let snapshot {
let mergeStart = CFAbsoluteTimeGetCurrent()
let partialMap = self.buildPageMap(summaries: snapshot)
@@ -297,6 +346,10 @@ final class RDEPUBMetadataParseWorker {
timingLock.lock()
totalMergeMs += mergeElapsed
timingLock.unlock()
RDEPUBBackgroundTrace.log(
"Metadata",
"partialMap resolvedChapters=\(snapshot.count) totalPages=\(partialMap.totalPages) mergeMs=\(Int(mergeElapsed))"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
@@ -314,6 +367,10 @@ final class RDEPUBMetadataParseWorker {
timingLock.lock()
failedChapters += 1
timingLock.unlock()
RDEPUBBackgroundTrace.log(
"Metadata",
"chapterFailed spine=\(spineIndex) retryScheduled=true error=\(String(describing: error))"
)
self.scheduleRetry(
spineIndex: spineIndex,
@@ -354,6 +411,10 @@ final class RDEPUBMetadataParseWorker {
let finalMergeStart = CFAbsoluteTimeGetCurrent()
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
RDEPUBBackgroundTrace.log(
"Metadata",
"finish resolved=\(parseState.summariesBySpineIndex.count)/\(self.allBuildableIndices.count) totalPages=\(pageMap.totalPages) elapsedMs=\(wallClockMs) renderMs=\(renderTotal) writeMs=\(writeTotal) mergeMs=\(mergeTotal + finalMergeMs) failed=\(failed)"
)
if let coverageStore = context.runtime?.backgroundCoverageStore {
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
@@ -407,10 +468,18 @@ final class RDEPUBMetadataParseWorker {
cancellationController: RDEPUBMetadataParseCancellationController
) {
guard retryCount < Self.maxRetryCount else {
RDEPUBBackgroundTrace.log(
"Metadata",
"retryAborted spine=\(spineIndex) retryCount=\(retryCount)"
)
return
}
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
RDEPUBBackgroundTrace.log(
"Metadata",
"retryScheduled spine=\(spineIndex) retryCount=\(retryCount + 1) delayMs=\(Int(delay * 1000))"
)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self, let context = self.context else { return }
@@ -474,6 +543,10 @@ final class RDEPUBMetadataParseWorker {
if shouldRefresh {
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
RDEPUBBackgroundTrace.log(
"Metadata",
"retryPartialMap resolvedChapters=\(parseState.summariesBySpineIndex.count) totalPages=\(partialMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == self.token,
context.controller != nil,
@@ -495,6 +568,11 @@ final class RDEPUBMetadataParseWorker {
}
}
private static func progressPercent(resolved: Int, total: Int) -> Int {
guard total > 0 else { return 0 }
return Int((Double(resolved) / Double(total) * 100.0).rounded())
}
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
guard let summaryDiskCache else { return nil }
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
@@ -164,6 +164,7 @@ final class RDEPUBPageMapReconciliationCoordinator {
lastBuildableSpineIndex: Int
) -> RDEPUBPageMapTakeoverDecision {
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
let currentEntries = currentWindow.entries.count
if let currentSpineIndex {
@@ -189,8 +190,8 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
let isComplete = candidateIndices.count >= currentEntries
if isComplete {
let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices)
if coversCurrentWindow {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
@@ -198,6 +199,10 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .fullReplace(candidatePageMap)
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)"
)
return .keepCurrentWindow
}
@@ -21,6 +21,8 @@ final class RDEPUBPresentationRuntime {
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
private var pendingCommitRetryWorkItem: DispatchWorkItem?
init(
context: RDEPUBReaderContext,
locationCoordinator: RDEPUBReaderLocationCoordinator,
@@ -62,11 +64,22 @@ final class RDEPUBPresentationRuntime {
guard let readerView = context.readerView,
let controller = context.controller else { return }
guard !controller.isRepaginating else { return }
guard !readerView.isPageCurlTransitioning else {
guard !context.pendingPageMapUpdates.isEmpty else {
cancelPendingCommitRetry()
return
}
guard !controller.isRepaginating else {
schedulePendingCommitRetry()
return
}
guard !readerView.isPageCurlTransitioning else {
schedulePendingCommitRetry()
return
}
cancelPendingCommitRetry()
let rankedUpdates = rankedPendingPageMapUpdates()
for (index, update) in rankedUpdates {
if commitPendingPageMapUpdate(
@@ -75,9 +88,16 @@ final class RDEPUBPresentationRuntime {
readerView: readerView,
controller: controller
) {
if !context.pendingPageMapUpdates.isEmpty {
schedulePendingCommitRetry()
}
return
}
}
if !context.pendingPageMapUpdates.isEmpty {
schedulePendingCommitRetry()
}
}
func queueExtendedPartialPageMap(
@@ -139,19 +159,46 @@ final class RDEPUBPresentationRuntime {
controller: RDEPUBReaderController
) {
let currentLocation = locationCoordinator.currentVisibleLocation()
let livePageBeforeApply = readerView.currentPage + 1
// Resolved against the outgoing page map. When it round-trips to the live
// page, the location faithfully describes what is on screen, so whatever
// page it resolves to in the new map is authoritative even if the two maps
// number pages differently (partial-window -> full-book takeover).
let oldResolvedPage = currentLocation.flatMap { controller.pageNumber(for: $0) }
context.textBook = nil
applyPageMapToLiveModel(newPageMap)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)"
)
if let currentLocation {
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
rebindVisiblePage(
to: newPage,
readerView: readerView
)
let resolvedTargetPage = controller.pageNumber(for: currentLocation)
let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
resolvedTargetPage,
livePageBeforeApply: livePageBeforeApply,
locationMatchesLivePage: oldResolvedPage == livePageBeforeApply
)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)"
)
if shouldTrustResolvedLocation,
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
return
}
let fallbackPage = max(livePageBeforeApply, 1)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement preserveLivePage fallbackPage=\(fallbackPage) currentReaderPage=\(readerView.currentPage + 1)"
)
rebindVisiblePage(
to: fallbackPage - 1,
readerView: readerView
)
} else {
readerView.reloadPageCountOnly()
}
@@ -223,17 +270,23 @@ final class RDEPUBPresentationRuntime {
return false
}
case .extendPartial(_, let currentLocation):
case .extendPartial(let capturedPageNumber, let currentLocation):
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap)
if let currentLocation,
let livePageNumber = max(readerView.currentPage, 0) + 1
let shouldTrustCapturedLocation = livePageNumber == max(capturedPageNumber, 1)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"extendPartial commit capturedPage=\(capturedPageNumber) livePage=\(livePageNumber) trustCaptured=\(shouldTrustCapturedLocation) totalPages=\(update.pageMap.totalPages) totalChapters=\(update.pageMap.totalChapters)"
)
if shouldTrustCapturedLocation,
let currentLocation,
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
return true
}
// Fallback: prefer the live readerView.currentPage over the stale captured
// currentPageNumber, which may be outdated by the time this commit runs
// (especially in pageCurl mode where the user may have turned several pages
// since the extension was initiated).
// Prefer the live readerView page when the user has moved since the
// extension request was created; otherwise a stale captured location can
// snap pageCurl back to the previous page after the turn completes.
let livePageIndex = max(readerView.currentPage, 0)
rebindVisiblePage(
to: livePageIndex,
@@ -250,6 +303,19 @@ final class RDEPUBPresentationRuntime {
}
private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) {
if pageIndex == readerView.currentPage {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage pageUnchanged currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType)"
)
readerView.reloadPageCountOnly()
return
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage targetPage=\(pageIndex + 1) currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType) isPageCurlTransitioning=\(readerView.isPageCurlTransitioning)"
)
if readerView.currentDisplayType == .pageCurl {
if readerView.isPageCurlTransitioning {
// Defer the transition until the current page-curl animation completes,
@@ -257,6 +323,10 @@ final class RDEPUBPresentationRuntime {
DispatchQueue.main.async { [weak readerView] in
guard let readerView, !readerView.isPageCurlTransitioning else { return }
let livePageIndex = max(readerView.currentPage, 0)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)"
)
readerView.transitionToPage(pageNum: livePageIndex, animated: false)
}
} else {
@@ -276,6 +346,10 @@ final class RDEPUBPresentationRuntime {
controller: RDEPUBReaderController
) -> Bool {
guard let targetPageNumber = controller.pageNumber(for: location) else {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)"
)
return false
}
@@ -284,9 +358,18 @@ final class RDEPUBPresentationRuntime {
forAbsolutePageNumber: targetPageNumber,
allowSynchronousLoad: false
) == false {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)"
)
return false
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)"
)
rebindVisiblePage(
to: max(targetPageNumber - 1, 0),
readerView: readerView
@@ -353,4 +436,36 @@ final class RDEPUBPresentationRuntime {
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
)
}
private func shouldTrustFullReplaceResolvedPage(
_ resolvedTargetPage: Int?,
livePageBeforeApply: Int,
locationMatchesLivePage: Bool
) -> Bool {
guard let resolvedTargetPage else { return false }
if locationMatchesLivePage {
return true
}
// The location did not round-trip to the live page in the outgoing map
// (stale persisted location or mid-transition), so only follow it when it
// stays next to the page the user is actually looking at.
return abs(resolvedTargetPage - livePageBeforeApply) <= 1
}
private func schedulePendingCommitRetry() {
guard pendingCommitRetryWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingCommitRetryWorkItem = nil
self.commitPendingPageMapUpdateIfNeeded()
}
pendingCommitRetryWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem)
}
private func cancelPendingCommitRetry() {
pendingCommitRetryWorkItem?.cancel()
pendingCommitRetryWorkItem = nil
}
}
@@ -163,7 +163,7 @@ final class RDEPUBReaderContext {
}
func currentPreferences() -> RDEPUBPreferences {
let safeInsets = controller?.view.safeAreaInsets ?? .zero
let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
return configuration.makePreferences(safeAreaInsets: safeInsets)
}
@@ -1,5 +1,27 @@
import UIKit
enum RDEPUBTextPageLayoutMetrics {
static let pageNumberTrailingPadding: CGFloat = 4
static let pageNumberFooterPadding: CGFloat = 8
static let pageNumberReservedHeight: CGFloat = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
static func contentInsets(
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)
)
}
}
final class RDEPUBReaderEnvironment {
weak var controller: RDEPUBReaderController?
@@ -25,7 +47,7 @@ final class RDEPUBReaderEnvironment {
return RDEPUBNavigatorLayoutContext(
containerSize: resolvedSize,
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
safeAreaInsets: RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets),
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
reflowableContentInsets: configuration.reflowableContentInsets
)
@@ -46,11 +68,14 @@ final class RDEPUBReaderEnvironment {
configuration: RDEPUBReaderConfiguration,
pageSize: CGSize
) -> RDEPUBTextLayoutConfig {
let layoutContext = currentLayoutContext(configuration: configuration)
let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1),
edgeInsets: layoutContext.safeReflowableContentInsets,
edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
configuration: configuration,
safeAreaInsets: safeAreaInsets
),
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
avoidOrphans: false,
@@ -191,12 +191,21 @@ final class RDEPUBReaderPaginationCoordinator {
runtime: runtime,
layoutSnapshot: layoutSnapshot
)
RDEPUBBackgroundTrace.log(
"Pagination",
"anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
)
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
anchorChapter: runtimeChapter,
publication: publication,
runtime: runtime,
layoutSnapshot: layoutSnapshot
)
let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count }
RDEPUBBackgroundTrace.log(
"Pagination",
"initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
@@ -220,9 +229,17 @@ final class RDEPUBReaderPaginationCoordinator {
parser: parser,
publication: publication
)
RDEPUBBackgroundTrace.log(
"Pagination",
"startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)"
)
worker.start(token: token, restoreLocation: restoreLocation)
}
} catch {
RDEPUBBackgroundTrace.log(
"Pagination",
"initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
@@ -109,7 +109,7 @@ public struct RDEPUBReaderConfiguration: Equatable {
showsTableOfContents: Bool = true,
allowsHighlights: Bool = true,
showsSettingsPanel: Bool = true,
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets(),
fixedContentInset: UIEdgeInsets = .zero,
theme: RDEPUBReaderTheme = .light,
darkImageAdjustmentEnabled: Bool = true,
@@ -172,11 +172,13 @@ extension RDEPUBReaderConfiguration {
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.
// Falls back to the key-window safe area when the caller has no laid-out view.
let resolvedSafeAreaInsets = RDEPUBSafeArea.resolve(safeAreaInsets)
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)
top: max(reflowableContentInsets.top, resolvedSafeAreaInsets.top),
left: max(reflowableContentInsets.left, resolvedSafeAreaInsets.left),
bottom: max(reflowableContentInsets.bottom, resolvedSafeAreaInsets.bottom),
right: max(reflowableContentInsets.right, resolvedSafeAreaInsets.right)
)
return RDEPUBPreferences(
fontSize: fontSize,
@@ -219,9 +219,16 @@ final class RDEPUBTextContentInteractionCoordinator: NSObject {
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
let previousTapSuppressed = interactionState != .idle
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
let previousState = interactionState
interactionState = state
let currentTapSuppressed = interactionState != .idle
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
if previousState != state {
RDReaderTapDebug.log(
"TextContentInteraction.state",
"transition \(previousState) -> \(state) tapSuppressed=\(currentTapSuppressed) pagingSuppressed=\(currentPagingSuppressed)"
)
}
if previousTapSuppressed != currentTapSuppressed {
dependencies.selectionTapSuppressionDidChange(currentTapSuppressed)
}
@@ -126,6 +126,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
return spinner
}()
#if DEBUG
/// Debug-only outline of the readable content area (bounds inset by contentInsets).
private let debugContentAreaBorderView: UIView = {
let view = UIView()
view.isUserInteractionEnabled = false
view.backgroundColor = .clear
view.layer.borderColor = UIColor.systemRed.withAlphaComponent(0.6).cgColor
view.layer.borderWidth = 1
view.accessibilityIdentifier = "epub.reader.debug.contentAreaBorder"
return view
}()
#endif
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.minimumPressDuration = 0.5
@@ -243,6 +256,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
addSubview(pageNumberLabel)
addSubview(loadingSpinner)
addSubview(selectionLoupeView)
#if DEBUG
addSubview(debugContentAreaBorderView)
#endif
addGestureRecognizer(longPressGestureRecognizer)
addGestureRecognizer(panGestureRecognizer)
addGestureRecognizer(tapGestureRecognizer)
@@ -338,6 +354,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
coverImageView.frame = bounds.inset(by: contentInsets)
let contentRect = bounds.inset(by: contentInsets)
#if DEBUG
debugContentAreaBorderView.frame = contentRect
#endif
let labelSize = pageNumberLabel.sizeThatFits(
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
)
@@ -374,7 +393,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = Self.safeContentInsets(
configuration: configuration,
safeAreaInsets: safeAreaInsets
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
)
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
@@ -459,7 +478,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = Self.safeContentInsets(
configuration: configuration,
safeAreaInsets: safeAreaInsets
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
)
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
@@ -727,6 +746,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|| interactionCoordinator.interactionState != .idle
guard isUserInteractionEnabled != shouldEnableInteraction else { return }
isUserInteractionEnabled = shouldEnableInteraction
RDReaderTapDebug.log(
"TextContentView.interactionAvailability",
"updated isUserInteractionEnabled=\(shouldEnableInteraction) currentPage=\(currentPage?.absolutePageIndex ?? -1) loading=\(loadingSpinner.isAnimating) selecting=\(selectionController.isSelecting) hasSelection=\(selectionController.hasActiveSelection) state=\(interactionCoordinator.interactionState)"
)
}
private func updateStaticGestureAvailability() {
@@ -737,24 +760,36 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
guard longPressChanged || tapChanged else { return }
longPressGestureRecognizer.isEnabled = shouldEnableLongPress
tapGestureRecognizer.isEnabled = shouldEnableTap
RDReaderTapDebug.log(
"TextContentView.gestureAvailability",
"updated longPressEnabled=\(shouldEnableLongPress) tapEnabled=\(shouldEnableTap) hasInteractiveTextContent=\(hasInteractiveTextContent) loading=\(loadingSpinner.isAnimating) currentPage=\(currentPage?.absolutePageIndex ?? -1)"
)
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: overlayView)
RDReaderTapDebug.log(
"TextContentView.handleTap",
"received point=\(RDReaderTapDebug.describe(point)) currentPage=\(currentPage?.absolutePageIndex ?? -1) selectionState=\(interactionCoordinator.interactionState) hasSelection=\(currentSelection != nil) loading=\(loadingSpinner.isAnimating)"
)
if let renderView = coreTextRenderView {
let renderPoint = gesture.location(in: renderView)
if renderView.selectionHandle(at: renderPoint) != nil {
RDReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle")
return
}
if currentSelection != nil {
if renderView.selectionContains(renderPoint) {
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu")
showSelectionMenuIfNeeded()
return
}
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection")
clearSelection()
return
}
} else if currentSelection != nil {
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists without renderView; clearing selection")
clearSelection()
return
}
@@ -766,6 +801,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
// Footnote attachments carry their note text in alt/accessibility metadata.
// Prefer that semantic text over image preview even if attachment kind metadata is incomplete.
if let footnoteText = attachmentText(at: point), kind == .footnote || !footnoteText.isEmpty {
RDReaderTapDebug.log("TextContentView.handleTap", "resolved footnote attachment tap")
delegate?.textContentView(
self,
didActivateAttachmentText: footnoteText,
@@ -777,12 +813,14 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
// Regular image attachments: present image viewer
if let image = imageFromPage(attachment: attachment, page: page) {
let altText = attachmentText(at: point)
RDReaderTapDebug.log("TextContentView.handleTap", "resolved image attachment tap altTextPresent=\(altText?.isEmpty == false)")
delegate?.textContentView(self, didActivateImage: image, sourceRect: sourceRect, altText: altText)
return
}
}
if let attachmentText = attachmentText(at: point),
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
RDReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap")
delegate?.textContentView(
self,
didActivateAttachmentText: attachmentText,
@@ -793,9 +831,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
}
guard let highlight = highlight(at: point),
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
RDReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
return
}
RDReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
}
@@ -935,26 +975,36 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
#if canImport(DTCoreText)
if interactionCoordinator.interactionState == .selectionPending {
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending")
return true
}
guard let renderView = coreTextRenderView else {
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil")
return false
}
let renderPoint = convert(point, to: renderView)
if renderView.selectionHandle(at: renderPoint) != nil {
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle")
return true
}
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection")
return true
}
switch interactionCoordinator.interactionState {
case .idle:
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle")
return false
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
RDReaderTapDebug.log(
"TextContentView.shouldSuppressReaderTap",
"return true because interactionState=\(interactionCoordinator.interactionState)"
)
return true
}
#else
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable")
return false
#endif
}
@@ -1003,16 +1053,23 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
) -> Bool {
if gestureRecognizer === tapGestureRecognizer {
guard let renderView = coreTextRenderView else {
RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil")
return true
}
let point = touch.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle")
return false
}
}
return interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
let result = interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
RDReaderTapDebug.log(
"TextContentView.gestureShouldReceive",
"gesture=\(type(of: gestureRecognizer)) touchView=\(RDReaderTapDebug.describe(touch.view)) result=\(result)"
)
return result
}
func gestureRecognizer(
@@ -66,6 +66,10 @@ final class RDReaderPreloadController {
}
func invalidate(environment: Environment) {
RDReaderTapDebug.log(
"PreloadController.invalidate",
"clearing pageCurlCached=\(pageCurlCachedViews.keys.sorted()) preloaded=\(preloadedPageViews.keys.sorted())"
)
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
pageCurlCachedViews.removeAll()
@@ -78,8 +82,20 @@ final class RDReaderPreloadController {
environment: Environment,
contentViewProvider: (Int, UIView?) -> UIView?
) -> UIView {
let reusableView = detachedReusablePageView(for: pageNum)
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
let view: UIView
if let reusableView = detachedReusablePageView(for: pageNum) {
view = reusableView
RDReaderTapDebug.log(
"PreloadController.pageViewForDisplay",
"cache HIT page=\(pageNum) view=\(RDReaderTapDebug.describe(reusableView))"
)
} else {
view = contentViewProvider(pageNum, nil) ?? UIView()
RDReaderTapDebug.log(
"PreloadController.pageViewForDisplay",
"cache MISS page=\(pageNum) created=\(RDReaderTapDebug.describe(view))"
)
}
if shouldCache(view: view, for: pageNum, environment: environment) {
pageCurlCachedViews[pageNum] = view
} else {
@@ -91,6 +107,10 @@ final class RDReaderPreloadController {
func takePreloadedView(for pageNum: Int) -> UIView? {
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
preloaded?.removeFromSuperview()
RDReaderTapDebug.log(
"PreloadController.takePreloadedView",
"cache \(preloaded == nil ? "MISS" : "HIT") page=\(pageNum) view=\(RDReaderTapDebug.describe(preloaded))"
)
return preloaded
}
@@ -111,8 +131,26 @@ final class RDReaderPreloadController {
preloadHostView.frame = parentView.bounds
for targetPage in targets {
let existing = detachedReusablePageView(for: targetPage)
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
let contentView: UIView
if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView {
contentView = existing
RDReaderTapDebug.log(
"PreloadController.prime",
"cache HIT(preloaded) page=\(targetPage) view=\(RDReaderTapDebug.describe(existing))"
)
} else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil {
contentView = cached
RDReaderTapDebug.log(
"PreloadController.prime",
"cache HIT(pageCurl) page=\(targetPage) view=\(RDReaderTapDebug.describe(cached))"
)
} else {
contentView = contentViewProvider(targetPage, nil) ?? UIView()
RDReaderTapDebug.log(
"PreloadController.prime",
"cache MISS page=\(targetPage) created=\(RDReaderTapDebug.describe(contentView))"
)
}
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
if shouldCacheContentView {
preloadedPageViews[targetPage] = contentView
@@ -6,6 +6,10 @@ extension RDReaderView {
func tapCenter() {
refreshToolViewsFromProviderIfNeeded()
isShowToolView = !isShowToolView
RDReaderTapDebug.log(
"ReaderView.tapCenter",
"toggle toolView visible=\(isShowToolView) top=\(RDReaderTapDebug.describe(topToolView)) bottom=\(RDReaderTapDebug.describe(bottomToolView))"
)
if isShowToolView {
if let topToolView = topToolView {
installToolViewIfNeeded(topToolView, position: .top)
@@ -27,6 +31,7 @@ extension RDReaderView {
collectionView.isUserInteractionEnabled = false
pageViewController.view.isUserInteractionEnabled = false
RDReaderTapDebug.log("ReaderView.tapCenter", "disabled collectionView/pageViewController interaction while toolView is visible")
} else {
if let topToolView = topToolView {
UIView.animate(withDuration: toolViewAnimationDuration, animations: {
@@ -46,6 +51,7 @@ extension RDReaderView {
collectionView.isUserInteractionEnabled = true
pageViewController.view.isUserInteractionEnabled = true
RDReaderTapDebug.log("ReaderView.tapCenter", "re-enabled collectionView/pageViewController interaction after hiding toolView")
}
onToolViewVisibilityChanged?(isShowToolView)
}
@@ -1,6 +1,53 @@
import UIKit
enum RDReaderTapDebug {
private static let enabledArguments: Set<String> = [
"--demo-tap-debug",
"--demo-reader-interaction-debug"
]
static var isEnabled: Bool {
#if DEBUG
return true
#else
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains(where: { enabledArguments.contains($0) }) {
return true
}
let environment = ProcessInfo.processInfo.environment
return environment["RDREADER_TAP_DEBUG"] == "1"
#endif
}
static func log(_ scope: String, _ message: String) {
guard isEnabled else { return }
let threadRole = Thread.isMainThread ? "main" : "bg"
let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
print("[RDReaderTap][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)")
}
static func describe(_ point: CGPoint) -> String {
"(\(format(point.x)), \(format(point.y)))"
}
static func describe(_ rect: CGRect) -> String {
"(x:\(format(rect.origin.x)), y:\(format(rect.origin.y)), w:\(format(rect.size.width)), h:\(format(rect.size.height)))"
}
static func describe(_ view: UIView?) -> String {
guard let view else { return "nil" }
let identifier = view.accessibilityIdentifier ?? "nil"
return "\(type(of: view))(addr=\(Unmanaged.passUnretained(view).toOpaque()), id=\(identifier))"
}
private static func format(_ value: CGFloat) -> String {
String(format: "%.1f", value)
}
}
public class RDReaderView: UIView {
enum TapEvent {
@@ -524,10 +571,16 @@ public class RDReaderView: UIView {
@objc private func tapAction(tap: UITapGestureRecognizer) {
let point = tap.location(in: tap.view)
let hitView = hitTest(point, with: nil)
RDReaderTapDebug.log(
"ReaderView.tapAction",
"received point=\(RDReaderTapDebug.describe(point)) hitView=\(RDReaderTapDebug.describe(hitView)) display=\(currentDisplayType) currentPage=\(currentPage) toolVisible=\(isShowToolView) transitioning=\(isPageCurlTransitioning)"
)
if containsTextContentView(in: hitView) {
RDReaderTapDebug.log("ReaderView.tapAction", "ignored because hitView is inside text content view")
return
}
if shouldSuppressChromeToggle(for: hitView, point: point) {
RDReaderTapDebug.log("ReaderView.tapAction", "ignored because chrome toggle is suppressed")
return
}
handleResolvedTap(at: point, hitView: hitView, in: tap.view)
@@ -535,13 +588,24 @@ public class RDReaderView: UIView {
private func shouldSuppressChromeToggle(for hitView: UIView?, point: CGPoint) -> Bool {
if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
RDReaderTapDebug.log(
"ReaderView.suppressChromeToggle",
"suppressed by active selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)"
)
return true
}
var currentView = hitView
while let view = currentView {
if let textContentView = view as? RDEPUBTextContentView {
let localPoint = convert(point, to: textContentView)
return textContentView.shouldSuppressReaderTap(at: localPoint)
let shouldSuppress = textContentView.shouldSuppressReaderTap(at: localPoint)
if shouldSuppress {
RDReaderTapDebug.log(
"ReaderView.suppressChromeToggle",
"suppressed by text content view=\(RDReaderTapDebug.describe(textContentView)) localPoint=\(RDReaderTapDebug.describe(localPoint))"
)
}
return shouldSuppress
}
currentView = view.superview
}
@@ -566,6 +630,10 @@ public class RDReaderView: UIView {
} else {
selectionTapSuppressedContentViews.remove(contentView)
}
RDReaderTapDebug.log(
"ReaderView.selectionTapSuppression",
"contentView=\(RDReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionTapSuppressedContentViews.allObjects.count)"
)
}
func updateSelectionPagingSuppression(for contentView: UIView, isSuppressed: Bool) {
@@ -576,18 +644,32 @@ public class RDReaderView: UIView {
} else {
selectionPagingSuppressedContentViews.remove(contentView)
}
RDReaderTapDebug.log(
"ReaderView.selectionPagingSuppression",
"contentView=\(RDReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionPagingSuppressedContentViews.allObjects.count)"
)
updatePagingInteractionSuppression()
}
func handleContentTap(at point: CGPoint, in sourceView: UIView) {
let localPoint = sourceView.convert(point, to: self)
RDReaderTapDebug.log(
"ReaderView.handleContentTap",
"forwarded from sourceView=\(RDReaderTapDebug.describe(sourceView)) sourcePoint=\(RDReaderTapDebug.describe(point)) localPoint=\(RDReaderTapDebug.describe(localPoint))"
)
handleResolvedTap(at: localPoint, hitView: nil, in: self)
}
private func handleResolvedTap(at point: CGPoint, hitView: UIView?, in tapView: UIView?) {
if isShowToolView {
if let top = topToolView, isHitView(hitView, inside: top, point: point) { return }
if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { return }
if let top = topToolView, isHitView(hitView, inside: top, point: point) {
RDReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits top tool view")
return
}
if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) {
RDReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits bottom tool view")
return
}
}
guard let viewFrame = tapView?.frame else { return }
tapEvent = tapRegionHandler.resolveTapEvent(
@@ -595,6 +677,10 @@ public class RDReaderView: UIView {
viewFrame: viewFrame,
isToolViewVisible: isShowToolView
)
RDReaderTapDebug.log(
"ReaderView.handleResolvedTap",
"resolved tapEvent=\(tapEvent) point=\(RDReaderTapDebug.describe(point)) viewFrame=\(RDReaderTapDebug.describe(viewFrame))"
)
}
private func containsTextContentView(in hitView: UIView?) -> Bool {
@@ -612,6 +698,10 @@ public class RDReaderView: UIView {
let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false
guard shouldSuppress != isPagingInteractionSuppressed else { return }
isPagingInteractionSuppressed = shouldSuppress
RDReaderTapDebug.log(
"ReaderView.pagingSuppression",
"updated shouldSuppress=\(shouldSuppress) activeContentViews=\(activePagingSuppressionContentViews().count)"
)
setPagingInteractionEnabled(!shouldSuppress)
}
@@ -673,6 +763,14 @@ public class RDReaderView: UIView {
guard let safePageNum = clampedPageNumber(pageNum) else { return }
switch currentDisplayType {
case .pageCurl:
if !animated, safePageNum == currentPage, !detectPageViewControllerFault(pageViewController) {
RDReaderTapDebug.log(
"ReaderView.transitionToPage",
"skip same page page=\(safePageNum) animated=\(animated)"
)
return
}
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
if shouldQueuePageTransition(request) {
return
@@ -733,6 +831,7 @@ public class RDReaderView: UIView {
}
public func reloadData() {
invalidatePageCaches()
switchReaderDisplayType(currentDisplayType)
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
@@ -740,8 +839,9 @@ public class RDReaderView: UIView {
public func reloadPageCountOnly() {
if currentDisplayType == .pageCurl {
if !isPageCurlTransitioning, currentPage >= 0, numberOfPages() > 0 {
transitionToPage(pageNum: currentPage, animated: false)
let totalPages = numberOfPages()
if !isPageCurlTransitioning, currentPage >= totalPages, totalPages > 0 {
transitionToPage(pageNum: totalPages - 1, animated: false)
}
} else {
collectionView.reloadData()
@@ -771,22 +871,37 @@ extension RDReaderView: UIGestureRecognizerDelegate {
guard gestureRecognizer === tapGestureRecognizer else { return true }
if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
RDReaderTapDebug.log(
"ReaderView.gestureShouldReceive",
"return false because selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)"
)
return false
}
let point = touch.location(in: self)
if containsTextContentView(in: touch.view) {
RDReaderTapDebug.log(
"ReaderView.gestureShouldReceive",
"return false because touch.view is inside text content view point=\(RDReaderTapDebug.describe(point)) touchView=\(RDReaderTapDebug.describe(touch.view))"
)
return false
}
if let topToolView, isHitView(touch.view, inside: topToolView, point: point) {
RDReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit topToolView")
return false
}
if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
RDReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit bottomToolView")
return false
}
if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) {
RDReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit searchBarView")
return false
}
RDReaderTapDebug.log(
"ReaderView.gestureShouldReceive",
"return true point=\(RDReaderTapDebug.describe(point)) touchView=\(RDReaderTapDebug.describe(touch.view))"
)
return true
}