ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift
shenlei 5a41066b66 修复 EPUB 文本分页显示错位并补充分页调试能力
本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。

主要改动:

1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。

2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。

3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。

4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。

5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。

6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
2026-07-08 14:27:36 +09:00

108 lines
4.4 KiB
Swift

import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
enum RDEPUBDarkImageAdjuster {
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)
static func adjustIfNeeded(
_ content: NSMutableAttributedString,
in range: NSRange? = nil,
configuration: RDEPUBReaderConfiguration
) -> NSMutableAttributedString {
guard configuration.darkImageAdjustmentEnabled,
configuration.darkImageBlendRatio > 0,
configuration.theme.contentBackgroundColor.rd_isDarkBackground else {
return content
}
let fullRange = NSRange(location: 0, length: content.length)
let targetRange = range.map { NSIntersectionRange($0, fullRange) } ?? fullRange
content.enumerateAttribute(.attachment, in: targetRange) { value, range, _ in
guard let attachment = value as? DTImageTextAttachment,
!isCoverAttachment(attachment),
let image = attachment.image,
shouldAdjust(image) else { return }
let adjustedAttachment = DTImageTextAttachment()
adjustedAttachment.image = adjustedImage(
image,
backgroundColor: configuration.theme.contentBackgroundColor,
blendRatio: configuration.darkImageBlendRatio,
cacheKey: cacheKey(for: attachment, image: image, configuration: configuration)
)
adjustedAttachment.originalSize = attachment.originalSize
adjustedAttachment.displaySize = attachment.displaySize
adjustedAttachment.verticalAlignment = attachment.verticalAlignment
adjustedAttachment.contentURL = attachment.contentURL
adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL
adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID
adjustedAttachment.attributes = attachment.attributes
content.addAttribute(.attachment, value: adjustedAttachment, range: range)
}
return content
}
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover")
}
private static func shouldAdjust(_ image: UIImage) -> Bool {
image.size.width >= 80 && image.size.height >= 80
}
private static func cacheKey(
for attachment: DTImageTextAttachment,
image: UIImage,
configuration: RDEPUBReaderConfiguration
) -> NSString {
let source = attachment.contentURL?.absoluteString
?? "\(Unmanaged.passUnretained(image).toOpaque())"
return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.rd_cssString)|\(configuration.darkImageBlendRatio)" as NSString
}
private static func adjustedImage(
_ image: UIImage,
backgroundColor: UIColor,
blendRatio: CGFloat,
cacheKey: NSString
) -> UIImage {
if let cached = imageCache.object(forKey: cacheKey) { return cached }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
let adjusted = renderer.image { context in
image.draw(in: CGRect(origin: .zero, size: image.size))
backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill()
context.cgContext.setBlendMode(.sourceAtop)
context.fill(CGRect(origin: .zero, size: image.size))
}
imageCache.setObject(adjusted, forKey: cacheKey, cost: imageCost(of: adjusted))
return adjusted
}
#endif
}