# 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