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

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 metadataSources/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:

  • 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:

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