26 KiB
Phase 8: 分页质量、缓存与性能采样 - Research
Researched: 2026-05-23 Domain: iOS/Swift EPUB reader - pagination caching, quality improvement, performance sampling Confidence: HIGH
Summary
Phase 8 builds on Phase 6 (page geometry) and Phase 7 (custom attribute closure) to address three concerns: pagination caching, pagination quality for complex image-heavy chapters, and performance diagnostics.
Critical finding: Several capabilities assumed to be "not yet integrated" in CONTEXT.md and ARCHITECTURE-CONTEXT.md are actually already implemented:
RDEPUBTextBookBuilder.build()already callscontent.rd_paginatedFrames(size:)(notss_pageRanges). The integration is DONE.- CSS
<link>inlining is already implemented viainlineLinkedStyleSheets()inRDEPUBTextRendererSupport. - The 5-layer CSS cascade is already implemented via
makeStyleSheetLayers()producingRDEPUBTextStyleSheetPackagelayers. RDEPUBTextPage.metadatais already populated withRDEPUBTextPageMetadatafromframe.metadata.
What actually needs building:
- Pagination cache (QUAL-01): Serialize
RDEPUBTextBookto disk, keyed by layout parameters - Image/attachment rules (QUAL-03): Formalize sizing, dark mode, and page-break-around-image rules
- Performance sampling (QUAL-04): Instrument render/paginate/build timings
- Pagination quality (QUAL-02): Implement
avoidPageBreakInsideline-removal, orphan/widow control, image fit enforcement
Primary recommendation: Focus Phase 8 on cache serialization (the largest new code), performance instrumentation (straightforward), and incremental pagination quality improvements. Do NOT re-implement CSS <link> inlining or 5-layer cascade -- they already work.
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Pagination cache storage | API / Backend (file system) | -- | Cache is local disk persistence, no UI involvement |
| Cache key generation | API / Backend | -- | Pure computation from layout parameters |
| Performance timing | API / Backend | -- | CFAbsoluteTimeGetCurrent at build/render points |
| Image sizing rules | API / Backend (renderer) | Browser/Client (display) | Rules applied during DTCoreText rendering |
| avoidPageBreakInside enforcement | API / Backend (layouter) | -- | CoreText line-level calculation |
| Orphan/widow control | API / Backend (layouter) | -- | CoreText line-level calculation |
| Pagination diagnostic output | API / Backend | Browser/Client (logging) | Diagnostic data flows to console/log |
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01 [P0]: Integrate
RDEPUBTextLayouterintoRDEPUBTextBookBuilder-- ALREADY DONE (see finding above) - D-02 [P0]: Expose
metadata: RDEPUBTextPageMetadataonRDEPUBTextPage-- ALREADY DONE - D-03 [P0]: Pagination cache by
bookID + fontSize + lineHeightMultiple + contentInsets, cache fullRDEPUBTextBookto disk - D-04 [P0]: CSS
<link>inline processing -- ALREADY DONE - D-05 [P0]: 5-layer CSS cascade -- ALREADY DONE
- D-06 [P1]: Image/attachment processing rules with diagnostics
- D-07 [P1]: Performance sampling -- output stable sampling data
Claude's Discretion
- Cache storage format (file system plist/JSON vs SQLite) -- implementer decides
- CSS cascade replace.css content -- reference WXRead but don't copy exactly
- Performance sampling threshold values -- determine at implementation time
Deferred Ideas (OUT OF SCOPE)
- CoreText direct drawing migration (v1.2/v2.0)
- Font system (embedded font set, future version)
- Character-level position precision (P2)
- Chapter data model cohesion (P2)
- TTS / DRM / Pencil / multi-column layout (P3)
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| QUAL-01 | Cache mechanism for layout frames/pagination results to avoid redundant full-layout | Cache key design from WXRead WRChapterPageCount.currentCacheKeyWithBookId:, serialization strategy for RDEPUBTextBook |
| QUAL-02 | Improve pagination quality for complex image-heavy chapters -- reduce bad breaks, orphan/widow lines, image whitespace issues, code/table truncation | WXRead avoidPageBreakInsideByRemovingLastLinesIfNeeded pattern, WRCoreTextLayoutConfig widow/orphan settings |
| QUAL-03 | Image/attachment sizing rules, dark mode adaptation, page background info with verifiable rules | WXRead wr-vertical-center-style, DTPageBreakInsideAvoid, existing prepareHTMLElementForReaderRendering |
| QUAL-04 | No significant degradation of first-screen time, repagination time, or interaction fluidity; output stable diagnostic/sampling data | Performance sampling points at render/paginate/build, CFAbsoluteTimeGetCurrent pattern |
| </phase_requirements> |
Standard Stack
Core (no new external dependencies)
This phase uses only existing project dependencies and Apple frameworks:
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Foundation | iOS SDK | NSKeyedArchiver, JSONEncoder, FileManager | System framework, no alternative |
| CoreText | iOS SDK | CTFramesetter, CTFrame line inspection | Already used by RDEPUBTextLayouter |
| DTCoreText | (existing) | HTML-to-NSAttributedString | Already integrated |
| CryptoKit / CommonCrypto | iOS SDK | SHA256 for cache key hashing | System framework |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| os.signpost | iOS 15+ | Performance instrumentation with Instruments integration | If structured performance logging desired |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| NSKeyedArchiver for cache | JSONEncoder | JSON is human-readable but can't handle NSAttributedString natively; NSKeyedArchiver handles it but produces opaque binary |
| File system cache | SQLite | File system is simpler for whole-object caching; SQLite adds complexity for no gain when caching complete objects |
| SHA256 cache key | Simple string concatenation | SHA256 produces fixed-length keys safe for filenames; raw string could exceed filesystem limits |
Installation: No new packages needed.
Package Legitimacy Audit
No external packages are installed in this phase. All dependencies are existing project dependencies or Apple system frameworks.
| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition |
|---|---|---|---|---|---|---|
| (none) | -- | -- | -- | -- | -- | N/A |
Architecture Patterns
Current Data Flow (already working)
RDEPUBReaderController.paginate(restoreLocation:)
-> RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)
-> for each spine item:
-> RDEPUBTextRendererSupport.makeChapterRenderRequest(...)
-> inlineLinkedStyleSheets() [CSS <link> inlining - DONE]
-> makeStyleSheetLayers() [5-layer cascade - DONE]
-> injectPaginationSemanticMarkers() [semantic markers - DONE]
-> renderer.renderChapter(request:) [DTCoreText render]
-> content.rd_paginatedFrames(size:) [4-level semantic pagination - DONE]
-> RDEPUBTextPage with metadata [metadata exposure - DONE]
-> RDEPUBTextBook(chapters:, pages:)
-> applyTextBook(restoreLocation:)
Recommended Cache Integration Point
RDEPUBReaderController.paginate(restoreLocation:)
-> compute cacheKey from bookID + layout params
-> if cached RDEPUBTextBook exists for key:
-> applyTextBook(cachedBook, restoreLocation:) [CACHE HIT - skip build]
-> else:
-> builder.build(...) [existing flow]
-> save RDEPUBTextBook to cache [CACHE WRITE]
-> applyTextBook(textBook, restoreLocation:)
Recommended Project Structure
Sources/RDReaderView/EPUBTextRendering/
├── RDEPUBTextBookBuilder.swift # existing - add cache integration
├── RDEPUBTextBookCache.swift # NEW - cache key, read/write, eviction
├── RDEPUBTextLayouter.swift # existing - add avoidPageBreakInside enforcement
├── RDEPUBTextLayoutFrame.swift # existing - no changes needed
├── RDEPUBTextPaginationSupport.swift # existing - no changes needed
├── RDEPUBTextRenderer.swift # existing - no changes needed
├── RDEPUBTextRendererSupport.swift # existing - add image rule formalization
├── RDEPUBDTCoreTextRenderer.swift # existing - no changes needed
├── RDEPUBTextPerformanceSampler.swift # NEW - timing instrumentation
└── RDPlainTextBookBuilder.swift # existing - no changes needed
Pattern 1: Cache Key Generation
What: Generate a deterministic cache key from layout parameters that change pagination results.
When to use: Before every builder.build() call.
Example:
// Reference: WXRead WRChapterPageCount.currentCacheKeyWithBookId:
// WXRead encodes: bookId + fontSize + lineSpacing + pageWidth + pageHeight
struct RDEPUBTextBookCacheKey {
let bookID: String
let fontSize: CGFloat
let lineHeightMultiple: CGFloat
let contentInsets: UIEdgeInsets
let pageSize: CGSize
var stringValue: String {
let raw = "\(bookID)|\(fontSize)|\(lineHeightMultiple)|\(contentInsets.top)|\(contentInsets.left)|\(contentInsets.bottom)|\(contentInsets.right)|\(pageSize.width)|\(pageSize.height)"
// SHA256 for safe filename
let data = Data(raw.utf8)
let hash = SHA256.hash(data: data)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
Source: [VERIFIED: Doc/WXRead/decompiled/WRChapterPageCount.m lines 64-90]
Pattern 2: avoidPageBreakInside Line Removal
What: When a block marked avoidPageBreakInside would cross a page boundary, remove trailing lines from the current page to push the entire block to the next page.
When to use: During RDEPUBTextLayouter.adjustedRange() after the 4-level semantic break detection.
Example:
// Reference: WXRead WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded
// If the proposed page end falls inside an avoidPageBreakInside block:
// 1. Find the block's start location
// 2. If block start >= minimumEnd, break at block start
// 3. If block start < minimumEnd (block is too large), fall through to frameLimit
Source: [VERIFIED: Doc/WXRead/decompiled/WRCoreTextLayoutFrame.h line 280]
Pattern 3: Performance Sampling
What: Measure wall-clock time for render, paginate, and full-build operations.
When to use: At the entry/exit of builder.build(), renderer.renderChapter(), and content.rd_paginatedFrames().
Example:
struct RDEPUBTextPerformanceSample {
var chapterHref: String
var renderDuration: TimeInterval // DTHTMLAttributedStringBuilder
var paginateDuration: TimeInterval // CTFramesetter pagination
var totalDuration: TimeInterval // end-to-end
var attributedStringLength: Int
var pageCount: Int
var cacheHit: Bool
}
// Measurement pattern:
let start = CFAbsoluteTimeGetCurrent()
// ... operation ...
let duration = CFAbsoluteTimeGetCurrent() - start
Source: [ASSUMED] -- standard iOS performance measurement pattern
Anti-Patterns to Avoid
- Storing NSAttributedString via JSONEncoder: NSAttributedString is not Codable. Use NSKeyedArchiver or store the raw HTML + re-render parameters instead.
- Cache key that includes theme colors: Theme (dark/light) changes CSS output but the pagination structure (NSRanges) is the same for the same font/size/insets. However, the attributed string content differs. Cache should store the full RDEPUBTextBook including styled content, so theme must be part of the key OR cache should store structure only and re-render content.
- Measuring on main thread: All build/paginate work happens on
DispatchQueue.global(qos: .userInitiated). Timing must be captured there, not dispatched back to main. - Infinite cache growth: Must implement eviction (LRU or size-based) to prevent disk space issues.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| SHA256 hashing | Custom hash function | CryptoKit or CommonCrypto | Cryptographic correctness, no collision risk |
| File system cache directory | Custom path logic | FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) |
Standard iOS cache location, auto-managed by OS |
| Thread-safe cache access | Manual locks | DispatchQueue or NSLock |
Standard concurrency pattern |
| Orphan/widow detection | Line counting heuristics | CTFrame line origins + paragraph range analysis | CoreText provides exact line positions |
Key insight: The hardest part of this phase is NOT the algorithms -- it's serializing RDEPUBTextBook which contains NSAttributedString (not Codable) and NSRange (not natively Codable in Swift). The serialization strategy is the most consequential design decision.
Common Pitfalls
Pitfall 1: NSAttributedString Serialization
What goes wrong: Attempting to JSONEncode an RDEPUBTextBook fails silently because NSAttributedString and NSRange are not Codable.
Why it happens: RDEPUBTextPage.content is NSAttributedString, and RDEPUBTextPageMetadata contains [NSRange] fields.
How to avoid: Use NSKeyedArchiver for the whole object, OR decompose into Codable parts (store HTML + parameters, re-render on load). The second approach is more resilient to code changes but slower on cache load.
Warning signs: JSONEncoder.encode() returns nil or throws.
Pitfall 2: Cache Invalidation on Code Changes
What goes wrong: After updating the pagination engine code, cached results are stale but the cache key hasn't changed. Why it happens: Cache key only encodes user-facing parameters (font, size, insets), not code version. How to avoid: Include a schema version number in the cache key. Bump the version when pagination logic changes. Warning signs: Pagination quality doesn't improve after code changes until cache is manually cleared.
Pitfall 3: NSRange Codable in Metadata
What goes wrong: RDEPUBTextPageMetadata conforms to Codable but contains NSRange fields (blockRange, attachmentRanges) which don't have native Codable conformance.
Why it happens: NSRange is an Objective-C struct, not a Swift Codable type.
How to avoid: Add Codable conformance for NSRange via an extension that encodes as {"location": Int, "length": Int}, or use NSKeyedArchiver for the whole metadata.
Warning signs: Compiler error or runtime crash when encoding metadata.
Pitfall 4: Image Pages Crossing Boundaries
What goes wrong: A large image that doesn't fit on the current page causes the page to have excessive whitespace or the image gets truncated.
Why it happens: CoreText treats NSTextAttachment as inline content and will break the line at the attachment, but doesn't check if the image height exceeds remaining page space.
How to avoid: Before pagination, check attachment heights against remaining page space. If an image won't fit, break the page before the image paragraph.
Warning signs: Pages with only 1-2 lines of text followed by a large gap.
Pitfall 5: Cache Thread Safety
What goes wrong: Concurrent reads/writes to the cache directory cause file corruption or crashes.
Why it happens: builder.build() runs on a background queue, and multiple paginate() calls can overlap (pagination token pattern handles cancellation but not serialization).
How to avoid: Use a serial dispatch queue for all cache operations, or use NSFileCoordinator for file-level coordination.
Warning signs: Intermittent crashes on NSKeyedUnarchiver.unarchiveObject.
Code Examples
Cache Key Construction
// Based on WXRead WRChapterPageCount.currentCacheKeyWithBookId:
// Source: Doc/WXRead/decompiled/WRChapterPageCount.m lines 64-90
import CryptoKit
struct RDEPUBTextBookCacheKey: Hashable {
let bookID: String
let fontSize: CGFloat
let lineHeightMultiple: CGFloat
let contentInsets: UIEdgeInsets
let pageSize: CGSize
let schemaVersion: Int // bump when pagination logic changes
var filename: String {
let components = [
bookID,
String(format: "%.1f", fontSize),
String(format: "%.2f", lineHeightMultiple),
String(format: "%.0f", contentInsets.top),
String(format: "%.0f", contentInsets.left),
String(format: "%.0f", contentInsets.bottom),
String(format: "%.0f", contentInsets.right),
String(format: "%.0f", pageSize.width),
String(format: "%.0f", pageSize.height),
"v\(schemaVersion)"
]
let raw = components.joined(separator: "_")
let hash = SHA256.hash(data: Data(raw.utf8))
return hash.map { String(format: "%02x", $0) }.joined() + ".cache"
}
}
NSKeyedArchiver Serialization for RDEPUBTextBook
// NSAttributedString supports NSCoding via NSKeyedArchiver
// RDEPUBTextBook/Chapter/Page need NSCoding conformance or wrapper
// Option A: Store as NSCoding-compatible wrapper
final class RDEPUBTextBookArchive: NSObject, NSCoding {
let chapters: [RDEPUBTextChapterArchive]
init(chapters: [RDEPUBTextChapterArchive]) { self.chapters = chapters }
required init?(coder: NSCoder) {
guard let chapters = coder.decodeObject(forKey: "chapters") as? [RDEPUBTextChapterArchive] else { return nil }
self.chapters = chapters
}
func encode(with coder: NSCoder) {
coder.encode(chapters, forKey: "chapters")
}
}
// Each RDEPUBTextChapterArchive stores:
// - attributedContent as NSData (NSKeyedArchiver)
// - page ranges as [[location, length]]
// - metadata as JSON Data
Performance Sample Collection
// No existing pattern in codebase -- standard iOS approach
final class RDEPUBTextPerformanceSampler {
struct Sample {
let chapterHref: String
let renderDuration: TimeInterval
let paginateDuration: TimeInterval
let pageCount: Int
let attributedStringLength: Int
let cacheHit: Bool
}
private(set) var samples: [Sample] = []
func record(_ sample: Sample) {
samples.append(sample)
print("[PERF] \(sample.chapterHref): render=\(String(format: "%.1f", sample.renderDuration*1000))ms paginate=\(String(format: "%.1f", sample.paginateDuration*1000))ms pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
}
func summary() -> String {
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
let hitRate = samples.filter(\.cacheHit).count
return "[PERF] chapters=\(samples.count) render=\(String(format: "%.0f", totalRender*1000))ms paginate=\(String(format: "%.0f", totalPaginate*1000))ms cacheHits=\(hitRate)/\(samples.count)"
}
}
State of the Art
| Old Approach (assumed) | Current Approach (actual) | When Changed | Impact |
|---|---|---|---|
ss_pageRanges for pagination |
rd_paginatedFrames via RDEPUBTextLayouter |
Phase 7 | 4-level semantic break engine already active |
No CSS <link> handling |
inlineLinkedStyleSheets() in renderer support |
Phase 7 or earlier | EPUB external stylesheets already resolved |
| Flat CSS injection | 5-layer cascade via RDEPUBTextStyleSheetPackage |
Phase 7 | default/replace/dark/epub/user layers already built |
| No page metadata | RDEPUBTextPageMetadata on every page |
Phase 7 | breakReason, blockKinds, semanticHints already available |
Deprecated/outdated in CONTEXT.md:
- D-01 (integrate layouter): Already done.
RDEPUBTextBookBuilderline 196 callscontent.rd_paginatedFrames(size:). - D-02 (expose metadata): Already done.
RDEPUBTextPage.metadatais populated fromframe.metadata. - D-04 (CSS
<link>inline): Already done.RDEPUBTextRendererSupport.inlineLinkedStyleSheets()handles this. - D-05 (5-layer cascade): Already done.
makeStyleSheetLayers()produces the 5-layer package.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | NSKeyedArchiver can serialize NSAttributedString with DTCoreText custom attributes |
Cache serialization | Cache would fail silently; fallback to re-render every time |
| A2 | CFAbsoluteTimeGetCurrent() has sufficient precision for millisecond-level timing |
Performance sampling | Sub-millisecond operations may show as 0ms |
| A3 | Cache key schema version bump is sufficient for invalidation on code changes | Cache invalidation | Stale cache could persist if version is forgotten |
| A4 | CTFrame line origins can detect orphan/widow conditions | Pagination quality | Orphan/widow control would need different approach |
Open Questions (RESOLVED)
-
Cache storage format: NSKeyedArchiver vs decomposed JSON? ✅ RESOLVED
- Decision: Use
NSKeyedArchiverfirst. Create privateNSCodingwrapper classes forRDEPUBTextBook/RDEPUBTextChapter/RDEPUBTextPage/RDEPUBTextPageMetadata. - Rationale:
NSAttributedStringandNSRangeboth supportNSCodingnatively. DTCoreText custom attributes are standardNSAttributedStringattribute keys (string-typed) and survive archiver round-trip. - Fallback: If runtime test reveals attribute loss, fall back to decomposed JSON (store HTML + render parameters, re-render on cache load).
- Decision: Use
-
Should cache store full attributed content or just page ranges? ✅ RESOLVED
- Decision: Cache full
RDEPUBTextBookincludingNSAttributedStringper page (matches D-01 decision in CONTEXT.md). - Rationale: Re-slicing on load would negate the cache performance benefit. Memory pressure is manageable for typical EPUB books (< 500 pages).
- Mitigation: Cache eviction on memory warning (
didReceiveMemoryWarningnotification).
- Decision: Cache full
-
Orphan/widow control: how aggressive? ✅ RESOLVED
- Decision: Add
RDEPUBTextLayoutConfigstruct with configurable thresholds (avoidOrphans: Bool = true,avoidWidows: Bool = true), defaulting to enabled. - Rationale: Follows the same pattern as
RDEPUBTextRenderStyle(public struct + Equatable + explicit init). Hard-coding would prevent future tuning. - WXRead reference:
WRCoreTextLayoutConfiguses the same configurable approach.
- Decision: Add
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Xcode / iOS SDK | Build & run | ✓ | (system) | -- |
| DTCoreText | HTML rendering | ✓ | (existing dependency) | -- |
| CoreText | Pagination | ✓ | (system framework) | -- |
| CryptoKit | SHA256 for cache key | ✓ | iOS 13+ | CommonCrypto |
| FileManager | Cache directory | ✓ | (system framework) | -- |
Missing dependencies with no fallback: None
Missing dependencies with fallback: None
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | None detected -- no test target in project |
| Config file | none |
| Quick run command | N/A |
| Full suite command | N/A |
Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| QUAL-01 | Cache hit skips rebuild | manual/runtime | Print cache HIT/MISS in console | N/A |
| QUAL-02 | avoidPageBreakInside enforced | manual/runtime | Inspect page diagnostics for semanticBoundary breaks | N/A |
| QUAL-03 | Image sizing rules applied | manual/runtime | Verify image dimensions in diagnostic output | N/A |
| QUAL-04 | Performance samples output | manual/runtime | Check console for [PERF] lines | N/A |
Sampling Rate
- Per task commit: Build succeeds (
build_sim) - Per wave merge: Runtime log includes [PERF] summary and cache HIT/MISS
- Phase gate: Full book builds with cache, second open shows HIT; complex chapter pagination quality visibly improved
Wave 0 Gaps
- No test framework exists. All verification is manual/runtime via console output and simulator build.
- If automated tests desired, would need to create test target first (out of scope for Phase 8).
Security Domain
No new security concerns introduced. Cache files are stored in the app's caches directory (sandboxed). No network requests, no user data exposure.
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | no | N/A |
| V3 Session Management | no | N/A |
| V4 Access Control | no | N/A |
| V5 Input Validation | no | N/A |
| V6 Cryptography | no | SHA256 is one-way hash for cache key, not security-sensitive |
Sources
Primary (HIGH confidence)
- Project source code:
Sources/RDReaderView/EPUBTextRendering/*.swift-- direct code inspection Doc/WXRead/decompiled/WRChapterPageCount.h/.m-- cache key pattern referenceDoc/WXRead/decompiled/WRCoreTextLayouter.h-- layout config referenceDoc/WXRead/decompiled/WRCoreTextLayoutFrame.h/.m-- avoidPageBreakInside referenceDoc/WXRead/resources/css/replace.css-- CSS rule reference
Secondary (MEDIUM confidence)
Doc/架构对比分析_WXRead_vs_ReadViewSDK.md-- gap analysis (some gaps already closed)
Tertiary (LOW confidence)
- A1 (NSKeyedArchiver + DTCoreText attributes): Needs runtime verification
Metadata
Confidence breakdown:
- Standard stack: HIGH -- no new dependencies, all system/existing frameworks
- Architecture: HIGH -- integration points clearly visible in code
- Pitfalls: HIGH -- serialization issues are well-understood Cocoa patterns
- Cache design: MEDIUM -- NSKeyedArchiver with DTCoreText custom attributes needs runtime test
Research date: 2026-05-23 Valid until: 2026-06-23 (30 days -- stable codebase, incremental changes)