feat(wxread): align pagination, rendering, and docs

This commit is contained in:
shen
2026-05-24 15:36:01 +08:00
parent a318c0e3d0
commit 2a94921e88
103 changed files with 6124 additions and 4623 deletions
@@ -2,33 +2,50 @@
phase: 08-pagination-quality-cache-performance
plan: 03
type: execute
wave: 1
depends_on: []
wave: 2
depends_on:
- 08-01
- 08-02
files_modified:
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
autonomous: true
requirements:
- QUAL-01
- QUAL-04
must_haves:
truths:
- "Pagination timing is logged with chapter ID, page count, and elapsed milliseconds"
- "Builder records per-chapter render time and paginate time"
- "Builder records total build time"
- "Builder records cache hit/miss per chapter"
- "Performance samples are accessible via lastBuildPerformanceSamples"
- "Cache integration: builder accepts optional cache parameter and skips build on hit"
- "Cache miss triggers full build and stores result"
- "Console logs show [PERF] summary with timing data"
artifacts:
- path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift"
provides: "Pagination timing instrumentation in existing rendering path"
contains: "[EPUB][Perf]"
- path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift"
provides: "RDEPUBTextPerformanceSample struct and RDEPUBTextPerformanceSampler class"
contains: "struct RDEPUBTextPerformanceSample"
- path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift"
provides: "Cache integration and performance instrumentation in build()"
contains: "lastBuildPerformanceSamples"
key_links:
- from: "RDEPUBTextRendererSupport"
to: "os.signpost / print"
via: "timing instrumentation"
pattern: "\\[EPUB\\]\\[Perf\\]"
- from: "RDEPUBTextBookBuilder.swift"
to: "RDEPUBTextBookCache.swift"
via: "cache.load(key:) / cache.save(_:key:)"
pattern: "cache\\.load\\|cache\\.save"
- from: "RDEPUBTextBookBuilder.swift"
to: "RDEPUBTextPerformanceSampler.swift"
via: "sampler.record(sample)"
pattern: "sampler\\.record"
---
<objective>
Add performance sampling and diagnostic output to the pagination pipeline: log chapter ID, page count, and elapsed time for each pagination pass. This provides the baseline data needed to detect regressions and validate that quality improvements don't degrade performance.
Create performance sampling infrastructure and integrate cache + performance instrumentation into RDEPUBTextBookBuilder.build(). This adds timing measurements for render/paginate/build operations and wires the cache from Plan 01 so that repeated builds with the same parameters skip the full pipeline.
Purpose: QUAL-04 requires that pagination improvements don't significantly degrade first-screen time, re-pagination time, or interaction smoothness. Without measurement, this can't be verified.
Purpose: QUAL-04 requires stable diagnostic/sampling data to ensure no degradation. QUAL-01 requires cache integration to avoid redundant pagination. This plan brings both into the builder.
Output: Timing instrumentation in the existing rendering path with `[EPUB][Perf]` log prefix.
Output: New RDEPUBTextPerformanceSampler.swift; modified RDEPUBTextBookBuilder.swift with cache integration and timing instrumentation.
</objective>
<execution_context>
@@ -42,77 +59,336 @@ Output: Timing instrumentation in the existing rendering path with `[EPUB][Perf]
@.planning/STATE.md
@.planning/phases/08-pagination-quality-cache-performance/08-CONTEXT.md
@.planning/phases/08-pagination-quality-cache-performance/08-RESEARCH.md
@.planning/phases/08-pagination-quality-cache-performance/08-PATTERNS.md
@.planning/phases/08-pagination-quality-cache-performance/08-01-SUMMARY.md
@.planning/phases/08-pagination-quality-cache-performance/08-02-SUMMARY.md
@Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
@Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift
@Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift
@Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
<interfaces>
<!-- Key types the executor needs -->
<!-- Builder init pattern. From RDEPUBTextBookBuilder.swift lines 101-107 -->
```swift
public init(renderer: RDEPUBTextRenderer) {
self.renderer = renderer
}
From RDEPUBTextRendererSupport.swift:
- The main pagination entry point processes a chapter HTML string and produces an array of pages
- Existing logging pattern: `print("[EPUB][Attachment] footnote classes=...")`
- Use the same `print("[EPUB]...")` convention for consistency
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
```
From RDEPUBDTCoreTextRenderer.swift:
- `RDEPUBTextChapterRenderRequest` contains chapter identification
- Rendering produces `NSAttributedString` per page
<!-- Existing diagnostics properties. From RDEPUBTextBookBuilder.swift lines 98-99 -->
```swift
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
```
<!-- Build method signature. From RDEPUBTextBookBuilder.swift lines 132-137 -->
```swift
public func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook
```
<!-- Cache public API (from Plan 01) -->
```swift
public final class RDEPUBTextBookCache {
public init(subdirectory: String = "RDEPUBTextBookCache")
public func cacheKey(bookID: String, fontSize: CGFloat, lineHeightMultiple: CGFloat, contentInsets: UIEdgeInsets, pageSize: CGSize) -> String
public func load(key: String) -> RDEPUBTextBook?
public func save(_ book: RDEPUBTextBook, key: String)
public func invalidateAll()
public var schemaVersion: Int
}
```
<!-- Performance measurement pattern (standard iOS) -->
```swift
let start = CFAbsoluteTimeGetCurrent()
// ... operation ...
let duration = CFAbsoluteTimeGetCurrent() - start
```
<!-- Existing struct pattern for diagnostics. From RDEPUBTextRenderer.swift lines 90-113 -->
```swift
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
public var kind: RDEPUBTextResourceReferenceKind
public var chapterHref: String
// ...
public init(kind: ..., chapterHref: ..., ...) { ... }
}
```
<!-- PaginationSupport extension (from Plan 02, Task 1 — updated to pass config) -->
```swift
extension NSAttributedString {
func rd_paginatedFrames(
size: CGSize,
fragmentOffsets: [String: Int] = [:],
config: RDEPUBTextLayoutConfig = .default
) -> [RDEPUBTextLayoutFrame]
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add pagination timing instrumentation</name>
<files>Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift</files>
<name>Task 1: Create RDEPUBTextPerformanceSampler with timing sample struct and recording API</name>
<files>Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift</files>
<read_first>
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift (full file — find the main pagination entry point)
- Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift (understand render request flow and chapter identification)
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
</read_first>
<action>
Add timing instrumentation to the pagination pipeline in `RDEPUBTextRendererSupport.swift`.
Create a new file `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift`.
**What to instrument:** The function that takes chapter HTML and produces paginated output (the main entry point called by the renderer). Wrap the core pagination logic with `CFAbsoluteTimeGetCurrent()` before and after.
**RDEPUBTextPerformanceSample struct** (per QUAL-04, D-04):
**What to log:** After pagination completes, print a single diagnostic line:
```
[EPUB][Perf] chapter={chapterIdentifier} pages={pageCount} elapsed={milliseconds}ms
```swift
public struct RDEPUBTextPerformanceSample: Equatable {
public var chapterHref: String
public var renderDuration: TimeInterval // DTCoreText render time
public var paginateDuration: TimeInterval // CoreText pagination time
public var pageCount: Int
public var attributedStringLength: Int
public var cacheHit: Bool
public init(
chapterHref: String,
renderDuration: TimeInterval,
paginateDuration: TimeInterval,
pageCount: Int,
attributedStringLength: Int,
cacheHit: Bool
) { ... }
}
```
Where:
- `chapterIdentifier` = the chapter href or file name passed into the rendering request
- `pageCount` = number of pages produced
- `milliseconds` = elapsed time as a rounded integer
Follow the exact pattern from `RDEPUBTextResourceReferenceDiagnostic` at RDEPUBTextRenderer.swift lines 90-113: public struct, Equatable, explicit public init with all properties.
**Implementation approach:**
1. At the top of the pagination function, capture `let startTime = CFAbsoluteTimeGetCurrent()`
2. At the end (after pages are produced), compute `let elapsed = Int(round((CFAbsoluteTimeGetCurrent() - startTime) * 1000))`
3. Print: `print("[EPUB][Perf] chapter=\(chapterID) pages=\(pages.count) elapsed=\(elapsed)ms")`
**RDEPUBTextPerformanceSampler class**:
Use the same logging convention as the existing `[EPUB][Attachment]` logs. If the function already has a chapter identifier parameter, use it directly. If not, extract it from the request context.
```swift
public final class RDEPUBTextPerformanceSampler {
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
public private(set) var totalBuildDuration: TimeInterval = 0
Do NOT add `os.signpost` or `OSLog` — keep it simple with `print` to match existing project conventions. A more sophisticated logging system can be added in Phase 9.
public init() {}
public func record(_ sample: RDEPUBTextPerformanceSample) {
samples.append(sample)
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
}
public func summary() -> String {
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
let hitCount = samples.filter(\.cacheHit).count
return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
}
public func reset() {
samples.removeAll()
totalBuildDuration = 0
}
private func formatMS(_ duration: TimeInterval) -> String {
String(format: "%.0fms", duration * 1000)
}
}
```
**Console output pattern** (follows existing `[EPUB]` style in the codebase):
- Each `record()` call prints a per-chapter `[PERF]` line
- `summary()` returns a formatted string (caller prints it)
- Use milliseconds (multiply TimeInterval by 1000) for human-readable output
</action>
<verify>
<automated>xcodebuild build -project ReadViewDemo/ReadViewDemo.xcodeproj -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -5</automated>
<automated>xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- RDEPUBTextRendererSupport.swift contains `CFAbsoluteTimeGetCurrent()` for timing capture
- RDEPUBTextRendererSupport.swift contains `print("[EPUB][Perf]` diagnostic output
- Diagnostic line includes chapter identifier, page count, and elapsed milliseconds
- xcodebuild build exits 0
- File `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift` exists
- `RDEPUBTextPerformanceSample` is a public struct with Equatable conformance
- Properties: `chapterHref`, `renderDuration`, `paginateDuration`, `pageCount`, `attributedStringLength`, `cacheHit`
- `RDEPUBTextPerformanceSampler` is a public final class
- `record(_:)` method appends sample and prints `[PERF]` line
- `summary()` returns formatted string with totals and cache hit rate
- `reset()` clears samples and totalBuildDuration
- `totalBuildDuration` is a public property
- Build succeeds
</acceptance_criteria>
<done>Pagination timing instrumentation is in place, build compiles, diagnostic log line fires after each chapter pagination</done>
<done>
RDEPUBTextPerformanceSampler.swift contains performance sample struct and sampler class with record/summary/reset API and [PERF] console logging. Build succeeds.
</done>
</task>
<task type="auto">
<name>Task 2: Integrate cache and performance sampling into RDEPUBTextBookBuilder.build()</name>
<files>Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift</files>
<read_first>
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
</read_first>
<action>
Modify `RDEPUBTextBookBuilder.swift` to integrate the cache (from Plan 01) and performance sampling (Task 1).
**Step 1: Add new stored properties** (per D-01, D-04)
After the existing `lastBuildPaginationDiagnostics` property (line 99), add:
```swift
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
```
**Step 2: Add cache and sampler to init** (per D-01)
Update the initializer to accept optional cache:
```swift
private let cache: RDEPUBTextBookCache?
private let sampler: RDEPUBTextPerformanceSampler
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
self.renderer = renderer
self.cache = cache
self.sampler = RDEPUBTextPerformanceSampler()
}
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
```
The `cache` parameter defaults to nil for backward compatibility. The sampler is always created (lightweight, no cost until samples recorded).
**Step 3: Add cache key helper**
Add a private helper method to generate the cache key from build parameters:
```swift
private func makeCacheKey(
bookID: String,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> String? {
guard let cache else { return nil }
return cache.cacheKey(
bookID: bookID,
fontSize: style.font.pointSize,
lineHeightMultiple: style.lineSpacing,
contentInsets: .zero, // or derive from pageSize insets if available
pageSize: pageSize
)
}
```
Note: The `bookID` needs to come from the publication. Use `publication.metadata.identifier ?? publication.metadata.title ?? "unknown"` as the bookID. If no identifier is available, return nil to skip caching.
**Step 4: Instrument the build() method** (per QUAL-04, QUAL-01)
At the start of `build()`, after the existing `lastBuildResourceDiagnostics = []` / `lastBuildPaginationDiagnostics = []` lines:
1. **Reset sampler**: `sampler.reset()`, `lastBuildCacheStats = (0, 0)`
2. **Start build timer**: `let buildStart = CFAbsoluteTimeGetCurrent()`
3. **Cache lookup**: If cache is available, generate key and try `cache.load(key:)`. If hit, set `lastBuildCacheStats.hits += 1`, print `[Cache] HIT`, and return the cached book immediately.
4. **Cache miss**: If cache miss, proceed with existing build loop.
Inside the for loop, around each chapter's render + paginate:
1. **Render timing**: Wrap `renderer.renderChapter(request:)` (line 158) with `CFAbsoluteTimeGetCurrent()` before and after
2. **Paginate timing**: Wrap `content.rd_paginatedFrames(size:)` (line 196) with `CFAbsoluteTimeGetCurrent()` before and after
3. **Record sample**: After getting layoutFrames, record:
```swift
sampler.record(RDEPUBTextPerformanceSample(
chapterHref: item.href,
renderDuration: renderDuration,
paginateDuration: paginateDuration,
pageCount: effectiveFrames.count,
attributedStringLength: content.length,
cacheHit: false
))
```
4. **Miss counter**: `lastBuildCacheStats.misses += 1`
After the loop, before returning:
1. **Set total build duration**: `sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart`
2. **Save to cache**: If cache available and key was generated, `cache.save(book, key: cacheKey)`
3. **Print summary**: `print(sampler.summary())`
4. **Store samples**: `lastBuildPerformanceSamples = sampler.samples`
**Step 5: Update build() method signature** — keep the existing signature unchanged. The cache is accessed via the stored property, not a method parameter.
**Important implementation notes**:
- All timing uses `CFAbsoluteTimeGetCurrent()` (no external deps, per D-04)
- Timing happens on the same queue as the build (DispatchQueue.global(qos: .userInitiated)), not dispatched to main
- The `publication.metadata.identifier` or equivalent needs to be accessible — check the `RDEPUBPublication` type for an identifier field
- If publication has no identifier, skip cache (return nil from makeCacheKey)
</action>
<verify>
<automated>xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `RDEPUBTextBookBuilder` has `lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample]` property
- `RDEPUBTextBookBuilder` has `lastBuildCacheStats: (hits: Int, misses: Int)` property
- `RDEPUBTextBookBuilder` init accepts optional `cache: RDEPUBTextBookCache?` parameter (defaults to nil)
- `build()` method contains `CFAbsoluteTimeGetCurrent()` timing calls for render and paginate
- `build()` method calls `cache.load(key:)` before the build loop when cache is available
- `build()` method calls `cache.save(_:key:)` after the build loop when cache is available
- `build()` method calls `sampler.record()` for each chapter with timing data
- `build()` method prints `sampler.summary()` at the end
- Existing `convenience init()` still works (cache defaults to nil)
- Build succeeds with no regressions
</acceptance_criteria>
<done>
RDEPUBTextBookBuilder.build() is instrumented with per-chapter render/paginate timing, cache hit/miss logging, and performance sample recording. Cache integration skips build on hit and stores result on miss. Build succeeds.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| builder → cache | Builder writes to cache after successful build; corrupted cache could return invalid RDEPUBTextBook on next load |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation |
|-----------|----------|-----------|-------------|------------|
| T-08-04 | Tampering | Cache integration in builder | accept | Cache is optional (nil default); NSKeyedArchiver deserialization fails safely returning nil |
| T-08-SC | Tampering | npm/pip/cargo installs | mitigate | No external packages installed in this phase |
</threat_model>
<verification>
- Build succeeds
- Running the demo app produces `[EPUB][Perf]` log lines in the console after navigating between chapters
- `xcodebuild build -scheme ReadViewDemo` succeeds
- Builder init accepts optional cache parameter
- build() method has CFAbsoluteTimeGetCurrent timing instrumentation
- Cache load/save calls are present in build() flow
- Performance samples accessible via lastBuildPerformanceSamples
- Console shows [PERF] summary and [Cache] HIT/MISS logs
</verification>
<success_criteria>
- Each pagination pass logs chapter ID, page count, and elapsed time
- Log format is consistent with existing `[EPUB]` convention
- No performance overhead beyond a single `CFAbsoluteTimeGetCurrent()` call (negligible)
- Per-chapter render and paginate durations are recorded
- Total build time is captured
- Cache hit skips the entire build loop
- Cache miss runs full build and stores result
- Performance summary printed to console after each build
- No functional regression: book opens, paginates, and displays correctly
</success_criteria>
<output>