ReadViewSDK/.planning/phases/08-pagination-quality-cache-performance/08-PATTERNS.md

460 lines
17 KiB
Markdown

# Phase 8: Pagination Quality, Cache & Performance Sampling - Pattern Map
**Mapped:** 2026-05-23
**Files analyzed:** 7 (2 new, 5 modified)
**Analogs found:** 7 / 7
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift` (NEW) | utility | file-I/O | `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift` | role-match |
| `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift` (NEW) | utility | transform | (none — standard iOS pattern) | no-analog |
| `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` (MODIFY) | controller | request-response | self | exact |
| `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift` (MODIFY) | service | transform | self | exact |
| `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift` (MODIFY) | model | transform | self | exact |
| `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift` (MODIFY) | utility | transform | self | exact |
| `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift` (MODIFY) | model | transform | self | exact |
## Pattern Assignments
### `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift` (NEW — utility, file-I/O)
**Analog:** `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
**File I/O pattern — cachesDirectory + FileManager** (lines 55-66):
```swift
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
let slug = epubURL.deletingPathExtension().lastPathComponent
.replacingOccurrences(of: " ", with: "-")
let signature = String(format: "%.0f", modifiedAt)
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
}
```
**Directory creation pattern** (lines 37):
```swift
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
```
**File existence check** (lines 29):
```swift
if fileManager.fileExists(atPath: extractionURL.path) {
return extractionURL
}
```
**Codable pattern for metadata**`Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift` lines 183-214:
```swift
public struct RDEPUBTextPageMetadata: Codable, Equatable {
public var breakReason: RDEPUBTextPageBreakReason
public var blockRange: NSRange?
public var attachmentRanges: [NSRange]
// ...
}
```
Note: `RDEPUBTextPageMetadata` declares `Codable` conformance but uses `NSRange` fields. The cache implementation must handle `NSRange` serialization — either via `NSKeyedArchiver` (which handles `NSAttributedString` + `NSRange` natively) or via a custom `Codable` wrapper that encodes `NSRange` as `{location: Int, length: Int}`.
**Conventions to follow:**
- `RDEPUB` prefix for all public types
- `public` access for API types, `internal` for implementation details
- Struct-based value types preferred (see `RDEPUBTextRenderStyle`, `RDEPUBTextChapter`)
- `Equatable` conformance on data types
- Cache subdirectory name: `RDEPUBTextBookCache` under `Library/Caches`
**New type — `RDEPUBTextBookCache`** should follow this structure:
```swift
import Foundation
import CryptoKit // for SHA256
public final class RDEPUBTextBookCache {
private let cacheDirectory: URL
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
private let schemaVersion: Int = 1
public init(subdirectory: String = "RDEPUBTextBookCache") {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
self.cacheDirectory = base.appendingPathComponent(subdirectory, isDirectory: true)
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
public func cacheKey(bookID: String, fontSize: CGFloat, lineHeightMultiple: CGFloat, contentInsets: UIEdgeInsets, pageSize: CGSize) -> String {
// SHA256 hash for safe filename
}
public func load(key: String) -> RDEPUBTextBook? { ... }
public func save(_ book: RDEPUBTextBook, key: String) { ... }
public func invalidateAll() { ... }
}
```
---
### `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift` (NEW — utility, transform)
**No close analog in codebase.** Use standard iOS performance measurement pattern.
**Conventions to follow** (from existing types in `RDEPUBTextRenderer.swift`):
```swift
// Public struct pattern — line 90-113
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
public var kind: RDEPUBTextResourceReferenceKind
public var chapterHref: String
// ...
public init(kind: ..., chapterHref: ..., ...) { ... }
}
```
**New type structure:**
```swift
import Foundation
public struct RDEPUBTextPerformanceSample: Equatable {
public var chapterHref: String
public var renderDuration: TimeInterval
public var paginateDuration: TimeInterval
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) { ... }
}
public final class RDEPUBTextPerformanceSampler {
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
public func record(_ sample: RDEPUBTextPerformanceSample) { ... }
public func summary() -> String { ... }
public func reset() { samples.removeAll() }
}
```
**Measurement pattern:**
```swift
let start = CFAbsoluteTimeGetCurrent()
// ... operation ...
let duration = CFAbsoluteTimeGetCurrent() - start
```
---
### `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` (MODIFY — controller, request-response)
**Analog:** self (exact match)
**Cache integration point** — insert before the `for` loop at line 143:
```swift
public func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook {
// NEW: Performance sampling
let buildStart = CFAbsoluteTimeGetCurrent()
// NEW: Cache lookup
// if let cached = cache.load(key: cacheKey) { return cached }
var chapters: [RDEPUBTextChapter] = []
var flatPages: [RDEPUBTextPage] = []
// ... existing loop ...
// NEW: Cache save + performance record
// cache.save(book, key: cacheKey)
// sampler.record(sample)
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
}
```
**Diagnostics property pattern** — existing at lines 98-99:
```swift
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
```
Add similar for performance:
```swift
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
```
**Initializer pattern** — existing at lines 101-107:
```swift
public init(renderer: RDEPUBTextRenderer) {
self.renderer = renderer
}
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
```
Add optional cache parameter:
```swift
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
self.renderer = renderer
self.cache = cache
}
```
---
### `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift` (MODIFY — service, transform)
**Analog:** self (exact match)
**avoidPageBreakInside enforcement** — insert into `adjustedRange()` at line 64, after the `preferredSemanticBoundary` check (line 114-139) but before `preferredAttachmentBoundary` (line 141):
The existing `preferredSemanticBoundary` at lines 243-250 already handles `avoidPageBreakInside` as a semantic hint boundary. The enhancement is to make this more aggressive — when the proposed page end falls INSIDE an `avoidPageBreakInside` block, push the entire block to the next page:
```swift
// After preferredSemanticBoundary returns nil, check if pageEnd
// falls inside an avoidPageBreakInside block
if let avoidBoundary = avoidPageBreakInsideBoundary(
in: proposedRange,
pageEnd: pageEnd,
minimumEnd: minimumEnd
) {
// Push to block start
let adjustedRange = NSRange(location: proposedRange.location, length: avoidBoundary - proposedRange.location)
return (range: adjustedRange, breakReason: .semanticBoundary, ...)
}
```
**Orphan/widow control** — add new private method after `preferredBlockBoundary` (line 268):
```swift
private func orphanWidowAdjustedRange(
from proposedRange: NSRange,
totalLength: Int
) -> NSRange? {
// Check if page starts with last line of paragraph (orphan)
// Check if page ends with first line of paragraph (widow)
// Use paragraphRange(containing:) pattern from line 294-298
}
```
**Existing paragraph range helper** — line 294-298:
```swift
private func paragraphRange(containing location: Int) -> NSRange {
let source = attributedString.string as NSString
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
let safeLocation = min(max(location, 0), max(source.length - 1, 0))
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
}
```
**Config type** — add to `RDEPUBTextRenderer.swift` (see below), then use in layouter init:
```swift
struct RDEPUBTextLayouter {
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let config: RDEPUBTextLayoutConfig // NEW
// ...
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
self.config = config
// ...
}
}
```
---
### `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift` (MODIFY — model, transform)
**Analog:** self (exact match)
Current structure (lines 3-28):
```swift
struct RDEPUBTextLayoutFrame: Equatable {
var contentRange: NSRange
var breakReason: RDEPUBTextPageBreakReason
var blockRange: NSRange?
var attachmentRanges: [NSRange]
var attachmentKinds: [RDEPUBTextAttachmentKind]
var blockKinds: [RDEPUBTextBlockKind]
var semanticHints: [RDEPUBTextSemanticHint]
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
var trailingFragmentID: String?
var diagnostics: [String]
var metadata: RDEPUBTextPageMetadata { ... }
}
```
No structural changes needed for this file. The `RDEPUBTextLayoutConfig` type goes in `RDEPUBTextRenderer.swift` (see next).
---
### `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift` (MODIFY — utility, transform)
**Analog:** self (exact match)
**Image sizing enhancement** — extend `prepareHTMLElementForReaderRendering` at line 217-262. The existing method already handles footnote and cover images. Add general image fit-to-page logic:
Existing pattern for image sizing (lines 243-261):
```swift
if lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg" {
let maxSize = UIScreen.main.bounds.insetBy(dx: 20, dy: 28).size
let originalSize = attachment.originalSize
if originalSize.width > 0, originalSize.height > 0 {
let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
attachment.displaySize = CGSize(
width: round(originalSize.width * scale),
height: round(originalSize.height * scale)
)
}
attachment.verticalAlignment = .baseline
element.displayStyle = .block
}
```
Add general image sizing after the cover check (around line 261):
```swift
// General image: fit within page height, do not cross pages
if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true {
let originalSize = attachment.originalSize
let maxImageHeight = UIScreen.main.bounds.insetBy(dx: 20, dy: 28).height
if originalSize.height > maxImageHeight {
let scale = maxImageHeight / originalSize.height
attachment.displaySize = CGSize(
width: round(originalSize.width * scale),
height: round(maxImageHeight)
)
}
// Center vertically if not already set
if element.displayStyle == .block {
attachment.verticalAlignment = .center
}
}
```
**Diagnostic output for attachments** — the existing `print("[EPUB][Attachment]...")` pattern at lines 237-239, 258-259 shows how diagnostics are emitted. Extend with size/placement/scale info.
---
### `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift` (MODIFY — model, transform)
**Analog:** self (exact match)
**Add `RDEPUBTextLayoutConfig` struct** — insert after `RDEPUBTextRenderStyle` (line 48), following the same struct pattern:
```swift
public struct RDEPUBTextLayoutConfig: Equatable {
public var avoidOrphans: Bool
public var avoidWidows: Bool
public var avoidPageBreakInsideEnabled: Bool
public var imageMaxHeightRatio: CGFloat // ratio of page height
public init(
avoidOrphans: Bool = true,
avoidWidows: Bool = true,
avoidPageBreakInsideEnabled: Bool = true,
imageMaxHeightRatio: CGFloat = 0.85
) {
self.avoidOrphans = avoidOrphans
self.avoidWidows = avoidWidows
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
self.imageMaxHeightRatio = imageMaxHeightRatio
}
public static let `default` = RDEPUBTextLayoutConfig()
}
```
Pattern source — `RDEPUBTextRenderStyle` at lines 36-48:
```swift
public struct RDEPUBTextRenderStyle {
public var font: UIFont
public var lineSpacing: CGFloat
public var textColor: UIColor?
public var backgroundColor: UIColor?
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
self.font = font
self.lineSpacing = lineSpacing
self.textColor = textColor
self.backgroundColor = backgroundColor
}
}
```
## Shared Patterns
### File I/O — Cache Directory
**Source:** `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift` lines 55-66
**Apply to:** `RDEPUBTextBookCache.swift`
```swift
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("RDEPUBTextBookCache", isDirectory: true)
?? FileManager.default.temporaryDirectory.appendingPathComponent("RDEPUBTextBookCache", isDirectory: true)
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
```
### Thread Safety — Serial Dispatch Queue
**Source:** No existing pattern in EPUBTextRendering (no concurrent access currently). Standard iOS approach.
**Apply to:** `RDEPUBTextBookCache.swift`
```swift
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
// All cache read/write operations wrapped in queue.sync { }
```
### Struct Declaration Pattern
**Source:** `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift` lines 36-48, 58-66, 68-83
**Apply to:** All new struct types (`RDEPUBTextLayoutConfig`, `RDEPUBTextPerformanceSample`, `RDEPUBTextBookCacheKey`)
```swift
public struct RDEPUBTextTypeName: Equatable {
public var propertyName: PropertyType
public init(propertyName: PropertyType) {
self.propertyName = propertyName
}
}
```
### Enum Declaration Pattern
**Source:** `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift` lines 13-21, 23-28, 50-56
**Apply to:** Any new enums
```swift
public enum RDEPUBTextEnumName: String, Codable, Equatable, CaseIterable {
case value1
case value2
}
```
### Performance Measurement
**Source:** No codebase analog. Standard iOS pattern.
**Apply to:** `RDEPUBTextBookBuilder.swift` build method, `RDEPUBTextPerformanceSampler.swift`
```swift
let start = CFAbsoluteTimeGetCurrent()
// ... operation ...
let duration = CFAbsoluteTimeGetCurrent() - start
```
### NSAttributedString Serialization (Cache)
**Source:** No codebase analog. `NSAttributedString` supports `NSCoding`.
**Apply to:** `RDEPUBTextBookCache.swift`
```swift
// Archive
let data = try NSKeyedArchiver.archivedData(withRootObject: attributedString, requiringSecureCoding: false)
// Unarchive
let attributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data)
```
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `RDEPUBTextPerformanceSampler.swift` | utility | transform | No existing performance measurement code in project |
| `RDEPUBTextBookCache.swift` (serialization) | utility | file-I/O | No `NSKeyedArchiver` usage in project; `NSAttributedString` + `NSCoding` is new territory |
## Metadata
**Analog search scope:** `Sources/RDReaderView/EPUBTextRendering/`, `Sources/RDReaderView/EPUBCore/`, `Sources/RDReaderView/`
**Files scanned:** 12
**Pattern extraction date:** 2026-05-23