460 lines
17 KiB
Markdown
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
|