长章节内存优化 P1:章节级共享显示内容与 layouter,高亮转 overlay
- 新增 RDEPUBChapterDisplayContentCache(LRU 2,主线程限定,控制器
持有):整章显示串构建时一次性注入全章主题色与暗黑图替换,配套
共享 DTCoreTextLayouter;签名 = 章节内容对象标识 + 主题双色 +
暗黑图配置,设置/主题变化自动失效重建
- RDEPUBTextContentView.configure 改为引用共享 entry,删除每次翻页
的整章可变拷贝与按页属性写入
- 高亮/下划线改走 overlay decoration(高亮背景层、下划线前景层),
移除 applyHighlightsToContent 与 render view 的属性绘制路径,
高亮增删不再触发整章重建
- 内存警告时清空 display cache;shouldAvoidReaderPageCaching 保持
保守(P1-4 待真机数据)
验证:编译通过;高亮/批注/选区/翻页/设置 13 项 UI 回归全过。
SearchTests 10 项失败经二分确认为先存回归(5a41066 与 e4e629a 均
复现,最后已知通过为 2026-06-08),与本次改动无关,待单独排查。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e4e629a06c
commit
c4be426299
@ -61,28 +61,26 @@
|
|||||||
|
|
||||||
**任务**:
|
**任务**:
|
||||||
|
|
||||||
- [ ] 在章节运行时侧(建议挂在 `RDEPUBRuntimeChapter` 或新建 `RDEPUBChapterDisplayCache`)新增:
|
- [x] 已新建 `RDEPUBChapterDisplayContentCache`(LRU 容量 2,主线程限定,`RDEPUBReaderController` 持有):`content` + `layouter` + `signature`
|
||||||
- `chapterDisplayContent`(`typesetAttributedString` 的一次性拷贝)
|
- [x] 构建共享内容时一次性注入:全章范围主题色;高亮/下划线因 P1-2 转 overlay,不进共享内容也不进签名
|
||||||
- `displaySignature`(组成:主题、暗黑图配置、字体设置、本章高亮集合版本)
|
- [x] 暗黑图策略选 a:构建时全章一次性替换,复用 `RDEPUBDarkImageAdjuster` 的 NSCache
|
||||||
- [ ] 构建共享内容时一次性注入:全章范围主题色;全章范围高亮/下划线属性(若 P1-2 决定转 overlay 则此项取消)
|
- [x] `RDEPUBTextContentView.configure` 已改为接收 `displayCache` 参数并引用共享 entry,整章拷贝已删除
|
||||||
- [ ] 确定暗黑图策略(二选一):
|
- [x] 签名 = 章节内容对象标识 + 长度 + 主题双色 + 暗黑图配置;设置/主题变化经签名自动失效重建(高亮变化不触发重建,仅重建 overlay 装饰)
|
||||||
- a. 构建时全章一次性替换(依赖 `RDEPUBDarkImageAdjuster` 的 NSCache 兜底解码成本)
|
- [x] 控制器在 `didReceiveMemoryWarning` 清空 display cache;设置变更重建 chapterContent 实例后由签名兜底换代
|
||||||
- b. 移到渲染时按可见附件替换,不触碰共享字符串
|
|
||||||
- [ ] `RDEPUBTextContentView.configure` 改为接收"共享内容引用 + `page.contentRange`",删除 443 行的整章拷贝
|
|
||||||
- [ ] 高亮增删、搜索激活、主题/设置变化 → 比对 `displaySignature`,不一致则重建共享内容(禁止原地修改)
|
|
||||||
- [ ] 章节淘汰(`RDEPUBChapterRuntimeStore.evict` / `handleMemoryWarning` / `invalidateAllForSettingsChange`)同步释放 display cache
|
|
||||||
|
|
||||||
**验收**:连续翻页不再出现整章拷贝(打点确认);翻页延迟下降;高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。
|
**验收**:连续翻页不再出现整章拷贝(打点确认);翻页延迟下降;高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。
|
||||||
|
|
||||||
|
> 2026-07-08 回归记录:高亮/批注/选区/翻页/设置 13 项 UI 用例全过;SearchTests 10 项失败,但已在会话前提交 5a41066 与 P0 提交 e4e629a 上复现同样失败(搜索 0 命中),确认为先于本优化存在的独立回归(最后一次已知通过是 2026-06-08 全量跑),需单独排查,与 P1 改动无关。
|
||||||
|
|
||||||
### P1-2 高亮/下划线剥离为 overlay(与 P1-1 绑定)
|
### P1-2 高亮/下划线剥离为 overlay(与 P1-1 绑定)
|
||||||
|
|
||||||
**现状**:搜索高亮已走 overlay(`RDEPUBTextContentView.swift:759` 处 `buildDecorations` 传 `highlights: []`,注释明确为避免双画);普通高亮/下划线仍靠写 `kRDEPUBHighlightAttributeName` / `kRDEPUBUnderlineAttributeName` 属性由 render view 绘制。
|
**现状**:搜索高亮已走 overlay(`RDEPUBTextContentView.swift:759` 处 `buildDecorations` 传 `highlights: []`,注释明确为避免双画);普通高亮/下划线仍靠写 `kRDEPUBHighlightAttributeName` / `kRDEPUBUnderlineAttributeName` 属性由 render view 绘制。
|
||||||
|
|
||||||
**任务**:
|
**任务**:
|
||||||
|
|
||||||
- [ ] `buildDecorations` 改为传入真实 highlights,高亮/下划线由 decoration 层绘制
|
- [x] `buildDecorations` 已传入真实 highlights,高亮进背景层(文字下方)、下划线进前景层
|
||||||
- [ ] 移除 `applyHighlightsToContent`(`RDEPUBTextContentView.swift:617`)及 render view 对两个自定义属性的绘制路径,确认无双画
|
- [x] `applyHighlightsToContent` 与 render view 的 `drawHighlights`/`computeHighlightRects`/`attributedDisplayContent` 已整体移除,无双画
|
||||||
- [ ] 高亮命中(`highlight(at:)`)与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute
|
- [x] 高亮命中与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute(未改动)
|
||||||
|
|
||||||
**验收**:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。
|
**验收**:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。
|
||||||
|
|
||||||
@ -92,10 +90,10 @@
|
|||||||
|
|
||||||
**任务**:
|
**任务**:
|
||||||
|
|
||||||
- [ ] `DTCoreTextLayouter` 随 `chapterDisplayContent` 一起放入章节级 display cache,与 signature 同生命周期
|
- [x] `DTCoreTextLayouter` 已随共享内容放入 display cache,与 signature 同生命周期
|
||||||
- [ ] 页面按 `bounds` + `page.contentRange` 取 `layoutFrame`;`RDEPUBTextPageVerticalJustifier.justify` 会修改 layoutFrame,因此 **保持 `shouldCacheLayoutFrames = false`**,layoutFrame 永远按页新建、不共享
|
- [x] 页面按 `bounds` + `page.contentRange` 取 `layoutFrame`;`shouldCacheLayoutFrames = false` 保持,layoutFrame 按页新建(VerticalJustifier 的修改不会污染共享对象)
|
||||||
- [ ] bounds 变化(横竖屏/双页)纳入失效条件;确认旧 layoutFrame 随页面视图释放
|
- [x] framesetter 只依赖字符串、与 bounds 无关,bounds 变化无需失效共享对象;layoutFrame 随页面视图释放
|
||||||
- [ ] `RDEPUBTextPageBoundaryValidator` 校验路径不变
|
- [x] `RDEPUBTextPageBoundaryValidator` 校验路径不变
|
||||||
|
|
||||||
**验收**:翻页时不再重建 framesetter(打点确认);横竖屏切换后布局正确;`--demo-pagination-validate` 不回升。
|
**验收**:翻页时不再重建 framesetter(打点确认);横竖屏切换后布局正确;`--demo-pagination-validate` 不回升。
|
||||||
|
|
||||||
|
|||||||
2416
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
2416
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -64,7 +64,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
|||||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||||
highlights: textHighlights(for: resolvedPage.page),
|
highlights: textHighlights(for: resolvedPage.page),
|
||||||
searchState: searchState(for: resolvedPage.page)
|
searchState: searchState(for: resolvedPage.page),
|
||||||
|
displayCache: textDisplayCache
|
||||||
)
|
)
|
||||||
return contentView
|
return contentView
|
||||||
}
|
}
|
||||||
@ -108,7 +109,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
|||||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||||
highlights: textHighlights(for: page),
|
highlights: textHighlights(for: page),
|
||||||
searchState: searchState(for: page)
|
searchState: searchState(for: page),
|
||||||
|
displayCache: textDisplayCache
|
||||||
)
|
)
|
||||||
return contentView
|
return contentView
|
||||||
}
|
}
|
||||||
|
|||||||
@ -96,6 +96,10 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
|
|
||||||
let paginationHostView = UIView()
|
let paginationHostView = UIView()
|
||||||
|
|
||||||
|
/// Chapter-level shared display content/layouter for text pages
|
||||||
|
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1/P1-3).
|
||||||
|
let textDisplayCache = RDEPUBChapterDisplayContentCache()
|
||||||
|
|
||||||
lazy var readerContext = RDEPUBReaderContext(controller: self)
|
lazy var readerContext = RDEPUBReaderContext(controller: self)
|
||||||
|
|
||||||
var parser: RDEPUBParser? {
|
var parser: RDEPUBParser? {
|
||||||
@ -292,6 +296,7 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
public override func didReceiveMemoryWarning() {
|
public override func didReceiveMemoryWarning() {
|
||||||
super.didReceiveMemoryWarning()
|
super.didReceiveMemoryWarning()
|
||||||
runtime.handleMemoryWarning()
|
runtime.handleMemoryWarning()
|
||||||
|
textDisplayCache.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
|||||||
@ -0,0 +1,114 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
import DTCoreText
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Chapter-level shared display content and layouter
|
||||||
|
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1 / P1-3).
|
||||||
|
///
|
||||||
|
/// One entry holds the chapter-length display string (theme text color and
|
||||||
|
/// dark-image adjustment injected once over the full chapter at build time)
|
||||||
|
/// plus the DTCoreTextLayouter wrapping its framesetter. Page views reference
|
||||||
|
/// the shared string and request per-page layout frames; they must never
|
||||||
|
/// mutate the shared string — the framesetter and any live layout frame hold
|
||||||
|
/// it. Page-level decoration (highlights, search, selection) is drawn by the
|
||||||
|
/// overlay views instead.
|
||||||
|
///
|
||||||
|
/// Main-thread only.
|
||||||
|
final class RDEPUBChapterDisplayContentCache {
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
let content: NSAttributedString
|
||||||
|
let layouter: DTCoreTextLayouter?
|
||||||
|
fileprivate let signature: Signature
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct Signature: Equatable {
|
||||||
|
let chapterContentID: ObjectIdentifier
|
||||||
|
let contentLength: Int
|
||||||
|
let textColor: UIColor
|
||||||
|
let backgroundColor: UIColor
|
||||||
|
let darkImageAdjustmentEnabled: Bool
|
||||||
|
let darkImageBlendRatio: CGFloat
|
||||||
|
|
||||||
|
init(page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) {
|
||||||
|
chapterContentID = ObjectIdentifier(page.chapterContent)
|
||||||
|
contentLength = page.chapterContent.length
|
||||||
|
textColor = configuration.theme.contentTextColor
|
||||||
|
backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
|
darkImageAdjustmentEnabled = configuration.darkImageAdjustmentEnabled
|
||||||
|
darkImageBlendRatio = configuration.darkImageBlendRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current chapter plus one adjacent chapter during page-curl/scroll
|
||||||
|
/// transitions across a chapter boundary.
|
||||||
|
private static let capacity = 2
|
||||||
|
|
||||||
|
private var entries: [String: Entry] = [:]
|
||||||
|
|
||||||
|
private var accessOrder: [String] = []
|
||||||
|
|
||||||
|
func entry(for page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) -> Entry {
|
||||||
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
|
let signature = Signature(page: page, configuration: configuration)
|
||||||
|
if let cached = entries[page.href], cached.signature == signature {
|
||||||
|
touch(page.href)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
let entry = Self.buildEntry(page: page, configuration: configuration, signature: signature)
|
||||||
|
entries[page.href] = entry
|
||||||
|
touch(page.href)
|
||||||
|
evictIfNeeded()
|
||||||
|
RDEPUBMemoryProbe.log("displayContentBuilt href=\(page.href) length=\(entry.content.length)")
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
entries.removeAll()
|
||||||
|
accessOrder.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touch(_ href: String) {
|
||||||
|
accessOrder.removeAll { $0 == href }
|
||||||
|
accessOrder.append(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evictIfNeeded() {
|
||||||
|
while accessOrder.count > Self.capacity {
|
||||||
|
let evicted = accessOrder.removeFirst()
|
||||||
|
entries.removeValue(forKey: evicted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildEntry(
|
||||||
|
page: RDEPUBTextPage,
|
||||||
|
configuration: RDEPUBReaderConfiguration,
|
||||||
|
signature: Signature
|
||||||
|
) -> Entry {
|
||||||
|
let content = NSMutableAttributedString(attributedString: page.chapterContent)
|
||||||
|
let fullRange = NSRange(location: 0, length: content.length)
|
||||||
|
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
|
||||||
|
content,
|
||||||
|
in: fullRange,
|
||||||
|
configuration: configuration
|
||||||
|
)
|
||||||
|
content.addAttribute(
|
||||||
|
.foregroundColor,
|
||||||
|
value: configuration.theme.contentTextColor,
|
||||||
|
range: fullRange
|
||||||
|
)
|
||||||
|
let layouter = DTCoreTextLayouter(attributedString: content)
|
||||||
|
layouter?.shouldCacheLayoutFrames = false
|
||||||
|
return Entry(content: content, layouter: layouter, signature: signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
func removeAll() {}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@ -87,12 +87,16 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Chapter-level shared display string owned by the display cache.
|
||||||
|
/// Read-only from the page's perspective: theme color and dark-image
|
||||||
|
/// adjustment are baked in at build time; page decoration goes through
|
||||||
|
/// the overlay views.
|
||||||
private var coreTextDisplayContent: NSAttributedString?
|
private var coreTextDisplayContent: NSAttributedString?
|
||||||
|
|
||||||
private var coreTextDisplayRange: NSRange?
|
private var coreTextDisplayRange: NSRange?
|
||||||
|
|
||||||
/// Framesetter over the full chapter copy, rebuilt when the display
|
/// Shared chapter-level layouter (owned by the display cache); layout
|
||||||
/// content changes; layout frames are recomputed per bounds change.
|
/// frames are recomputed per page range and bounds change.
|
||||||
private var coreTextLayouter: DTCoreTextLayouter?
|
private var coreTextLayouter: DTCoreTextLayouter?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@ -397,7 +401,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
chapterCFIMap: RDEPUBCFIMap? = nil,
|
chapterCFIMap: RDEPUBCFIMap? = nil,
|
||||||
chapterFragmentOffsets: [String: Int] = [:],
|
chapterFragmentOffsets: [String: Int] = [:],
|
||||||
highlights: [RDEPUBHighlight] = [],
|
highlights: [RDEPUBHighlight] = [],
|
||||||
searchState: RDEPUBSearchState? = nil
|
searchState: RDEPUBSearchState? = nil,
|
||||||
|
displayCache: RDEPUBChapterDisplayContentCache? = nil
|
||||||
) {
|
) {
|
||||||
loadingSpinner.stopAnimating()
|
loadingSpinner.stopAnimating()
|
||||||
currentPage = page
|
currentPage = page
|
||||||
@ -437,37 +442,26 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
coverImageView.image = nil
|
coverImageView.image = nil
|
||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
// In-context display: keep the full chapter string and lay out only the
|
// In-context display: lay out the full chapter string and render only
|
||||||
// page's range. CTTypesetter line breaks are context-sensitive, so
|
// the page's range. CTTypesetter line breaks are context-sensitive, so
|
||||||
// re-wrapping a page substring can break ±1 character away from the
|
// re-wrapping a page substring can break ±1 character away from the
|
||||||
// paginator's line ends (lone characters spilling onto the last line);
|
// paginator's line ends (lone characters spilling onto the last line);
|
||||||
// laying out the same chapter string the paginator used cannot.
|
// laying out the same chapter string the paginator used cannot.
|
||||||
// String indices in the layout frame are therefore chapter-absolute.
|
// String indices in the layout frame are therefore chapter-absolute.
|
||||||
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
|
// The chapter string and layouter are shared per chapter via the
|
||||||
|
// display cache; this view must not mutate the shared string.
|
||||||
|
let display = (displayCache ?? RDEPUBChapterDisplayContentCache())
|
||||||
|
.entry(for: page, configuration: configuration)
|
||||||
let pageRange = NSIntersectionRange(
|
let pageRange = NSIntersectionRange(
|
||||||
page.contentRange,
|
page.contentRange,
|
||||||
NSRange(location: 0, length: displayContent.length)
|
NSRange(location: 0, length: display.content.length)
|
||||||
)
|
)
|
||||||
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
|
|
||||||
displayContent,
|
|
||||||
in: pageRange,
|
|
||||||
configuration: configuration
|
|
||||||
)
|
|
||||||
displayContent.addAttribute(
|
|
||||||
.foregroundColor,
|
|
||||||
value: configuration.theme.contentTextColor,
|
|
||||||
range: pageRange
|
|
||||||
)
|
|
||||||
|
|
||||||
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
|
||||||
coreTextContentView.isHidden = false
|
coreTextContentView.isHidden = false
|
||||||
coreTextContentView.backgroundColor = .clear
|
coreTextContentView.backgroundColor = .clear
|
||||||
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
||||||
coreTextDisplayContent = displayContent
|
coreTextDisplayContent = display.content
|
||||||
coreTextDisplayRange = pageRange
|
coreTextDisplayRange = pageRange
|
||||||
coreTextLayouter = DTCoreTextLayouter(attributedString: displayContent)
|
coreTextLayouter = display.layouter
|
||||||
coreTextLayouter?.shouldCacheLayoutFrames = false
|
|
||||||
coreTextContentView.attributedDisplayContent = displayContent
|
|
||||||
updateCoreTextLayoutFrameIfNeeded()
|
updateCoreTextLayoutFrameIfNeeded()
|
||||||
#else
|
#else
|
||||||
let selectionContent = normalizedPageContent(from: page)
|
let selectionContent = normalizedPageContent(from: page)
|
||||||
@ -521,7 +515,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
coreTextContentView.isHidden = false
|
coreTextContentView.isHidden = false
|
||||||
coreTextContentView.backgroundColor = .clear
|
coreTextContentView.backgroundColor = .clear
|
||||||
coreTextContentView.attributedDisplayContent = nil
|
|
||||||
coreTextContentView.layoutFrame = nil
|
coreTextContentView.layoutFrame = nil
|
||||||
coreTextDisplayContent = nil
|
coreTextDisplayContent = nil
|
||||||
coreTextDisplayRange = nil
|
coreTextDisplayRange = nil
|
||||||
@ -615,31 +608,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return image(from: attachmentValue)
|
return image(from: attachmentValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks highlight ranges on the chapter-length display copy. Ranges stay
|
|
||||||
/// chapter-absolute — the in-context layout frame uses the same indices.
|
|
||||||
private func applyHighlightsToContent(
|
|
||||||
_ content: NSMutableAttributedString,
|
|
||||||
highlights: [RDEPUBHighlight],
|
|
||||||
page: RDEPUBTextPage
|
|
||||||
) {
|
|
||||||
for highlight in highlights {
|
|
||||||
guard highlight.location.href == page.href else { continue }
|
|
||||||
guard let rangeInfo = highlight.rangeInfo,
|
|
||||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
|
|
||||||
let absoluteRange = info.nsRange else { continue }
|
|
||||||
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
|
|
||||||
guard overlap.length > 0,
|
|
||||||
overlap.location + overlap.length <= content.length else { continue }
|
|
||||||
|
|
||||||
switch highlight.style {
|
|
||||||
case .highlight:
|
|
||||||
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: overlap)
|
|
||||||
case .underline:
|
|
||||||
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: overlap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||||
let content = NSMutableAttributedString(attributedString: page.content)
|
let content = NSMutableAttributedString(attributedString: page.content)
|
||||||
guard content.length > 0 else { return content }
|
guard content.length > 0 else { return content }
|
||||||
@ -759,12 +727,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||||
|
|
||||||
if let page = currentPage {
|
if let page = currentPage {
|
||||||
|
// Highlights/underlines and search are all drawn as overlay
|
||||||
|
// decorations; the shared chapter display string carries no
|
||||||
|
// page-level annotation attributes.
|
||||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||||
page: page,
|
page: page,
|
||||||
// Text-page highlights/underlines are already painted by the
|
highlights: currentHighlights,
|
||||||
// CoreText render view via display-content attributes. Keep
|
|
||||||
// overlay decorations for search only to avoid double drawing.
|
|
||||||
highlights: [],
|
|
||||||
searchState: currentSearchState,
|
searchState: currentSearchState,
|
||||||
interactionController: interactionController
|
interactionController: interactionController
|
||||||
)
|
)
|
||||||
|
|||||||
@ -22,12 +22,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var attributedDisplayContent: NSAttributedString? {
|
|
||||||
didSet {
|
|
||||||
invalidateStaticContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectionRects: [CGRect] = [] {
|
var selectionRects: [CGRect] = [] {
|
||||||
didSet {
|
didSet {
|
||||||
setNeedsDisplay()
|
setNeedsDisplay()
|
||||||
@ -78,9 +72,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
|||||||
if let cachedStaticImage {
|
if let cachedStaticImage {
|
||||||
cachedStaticImage.draw(in: bounds)
|
cachedStaticImage.draw(in: bounds)
|
||||||
} else {
|
} else {
|
||||||
if let attributedDisplayContent {
|
|
||||||
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
|
||||||
}
|
|
||||||
layoutFrame.draw(in: context, options: drawOptions)
|
layoutFrame.draw(in: context, options: drawOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,59 +87,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drawHighlights(
|
|
||||||
in context: CGContext,
|
|
||||||
attributedString: NSAttributedString,
|
|
||||||
layoutFrame: DTCoreTextLayoutFrame
|
|
||||||
) {
|
|
||||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
|
||||||
|
|
||||||
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
|
|
||||||
guard let color = value as? UIColor else { return }
|
|
||||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
|
||||||
color.setFill()
|
|
||||||
for rect in rects {
|
|
||||||
context.fill(rect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in
|
|
||||||
guard let color = value as? UIColor else { return }
|
|
||||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
|
||||||
color.setStroke()
|
|
||||||
context.setLineWidth(2)
|
|
||||||
for rect in rects {
|
|
||||||
let y = rect.maxY - 1
|
|
||||||
context.move(to: CGPoint(x: rect.minX, y: y))
|
|
||||||
context.addLine(to: CGPoint(x: rect.maxX, y: y))
|
|
||||||
context.strokePath()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] {
|
|
||||||
var rects: [CGRect] = []
|
|
||||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects }
|
|
||||||
|
|
||||||
for line in lines {
|
|
||||||
let lineRange = line.stringRange()
|
|
||||||
let overlap = NSIntersectionRange(range, lineRange)
|
|
||||||
guard overlap.length > 0 else { continue }
|
|
||||||
|
|
||||||
let startX = line.offset(forStringIndex: overlap.location)
|
|
||||||
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
|
|
||||||
let rect = CGRect(
|
|
||||||
x: line.baselineOrigin.x + startX,
|
|
||||||
y: line.baselineOrigin.y - line.ascent,
|
|
||||||
width: endX - startX,
|
|
||||||
height: line.ascent + line.descent
|
|
||||||
)
|
|
||||||
rects.append(rect)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rects
|
|
||||||
}
|
|
||||||
|
|
||||||
private func drawSelection(in context: CGContext) {
|
private func drawSelection(in context: CGContext) {
|
||||||
guard !selectionRects.isEmpty else { return }
|
guard !selectionRects.isEmpty else { return }
|
||||||
selectionColor.setFill()
|
selectionColor.setFill()
|
||||||
@ -261,9 +199,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
|||||||
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
|
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
|
||||||
return renderer.image { _ in
|
return renderer.image { _ in
|
||||||
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
|
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
|
||||||
if let attributedDisplayContent {
|
|
||||||
drawHighlights(in: staticContext, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
|
||||||
}
|
|
||||||
layoutFrame.draw(in: staticContext, options: drawOptions)
|
layoutFrame.draw(in: staticContext, options: drawOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user