6 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
13 changed files with 1720 additions and 1521 deletions
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)
@@ -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)
)
}
}
@@ -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
}
}
@@ -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
@@ -160,6 +160,11 @@ final class RDEPUBPresentationRuntime {
) {
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)
@@ -172,11 +177,12 @@ final class RDEPUBPresentationRuntime {
let resolvedTargetPage = controller.pageNumber(for: currentLocation)
let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
resolvedTargetPage,
livePageBeforeApply: livePageBeforeApply
livePageBeforeApply: livePageBeforeApply,
locationMatchesLivePage: oldResolvedPage == livePageBeforeApply
)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)"
"applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)"
)
if shouldTrustResolvedLocation,
@@ -433,9 +439,16 @@ final class RDEPUBPresentationRuntime {
private func shouldTrustFullReplaceResolvedPage(
_ resolvedTargetPage: Int?,
livePageBeforeApply: 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
}
@@ -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)
}
@@ -47,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
)
@@ -68,7 +68,7 @@ final class RDEPUBReaderEnvironment {
configuration: RDEPUBReaderConfiguration,
pageSize: CGSize
) -> RDEPUBTextLayoutConfig {
let safeAreaInsets = controller?.view.safeAreaInsets ?? .zero
let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1),
@@ -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,
@@ -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
@@ -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()
@@ -81,8 +85,16 @@ final class RDReaderPreloadController {
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
@@ -95,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
}
@@ -118,10 +134,22 @@ final class RDReaderPreloadController {
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 {