本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。 主要改动: 1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。 2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。 3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。 4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。 5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。 6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
108 lines
4.4 KiB
Swift
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
|
|
}
|