17 KiB
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):
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):
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
File existence check (lines 29):
if fileManager.fileExists(atPath: extractionURL.path) {
return extractionURL
}
Codable pattern for metadata — Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift lines 183-214:
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:
RDEPUBprefix for all public typespublicaccess for API types,internalfor implementation details- Struct-based value types preferred (see
RDEPUBTextRenderStyle,RDEPUBTextChapter) Equatableconformance on data types- Cache subdirectory name:
RDEPUBTextBookCacheunderLibrary/Caches
New type — RDEPUBTextBookCache should follow this structure:
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):
// 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:
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:
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:
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:
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
Add similar for performance:
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
Initializer pattern — existing at lines 101-107:
public init(renderer: RDEPUBTextRenderer) {
self.renderer = renderer
}
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
Add optional cache parameter:
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:
// 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):
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:
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:
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):
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):
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):
// 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:
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:
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
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
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)
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
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
let start = CFAbsoluteTimeGetCurrent()
// ... operation ...
let duration = CFAbsoluteTimeGetCurrent() - start
NSAttributedString Serialization (Cache)
Source: No codebase analog. NSAttributedString supports NSCoding.
Apply to: RDEPUBTextBookCache.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