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

23 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
08-pagination-quality-cache-performance 02 execute 1
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift
true
QUAL-02
QUAL-03
truths artifacts key_links
avoidPageBreakInside blocks are not split across page boundaries
Orphan lines (last line of paragraph alone at page top) are prevented
Widow lines (first line of paragraph alone at page bottom) are prevented
Oversized images are scaled to fit within a single page height
Image attachment blocks have vertical centering applied
Image sizing and placement details appear in diagnostics
Dark mode preserves original image colors (no color inversion applied)
Page background information is surfaced in diagnostics for attachment blocks
path provides contains
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift RDEPUBTextLayoutConfig struct definition struct RDEPUBTextLayoutConfig
path provides contains
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift avoidPageBreakInside enforcement, orphan/widow control orphanWidowAdjustedRange
path provides contains
Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift General image fit-to-page sizing and centering imageMaxHeight
from to via pattern
RDEPUBTextLayouter.swift RDEPUBTextRenderer.swift RDEPUBTextLayoutConfig parameter in init config: RDEPUBTextLayoutConfig
from to via pattern
RDEPUBTextRendererSupport.swift RDEPUBTextLayoutConfig imageMaxHeightRatio used in prepareHTMLElementForReaderRendering imageMaxHeightRatio
Improve pagination quality for complex image-heavy chapters by enforcing avoidPageBreakInside (currently only a marker with no behavior), adding orphan/widow line control, and formalizing image sizing rules so oversized images fit within a single page.

Purpose: QUAL-02 requires reducing bad page breaks, orphan/widow lines, and image whitespace issues. QUAL-03 requires verifiable image sizing rules. The layouter currently has semantic boundary detection but does not enforce avoidPageBreakInside, and images have no general fit-to-page logic (only cover/footnote special cases).

Output: Modified layouter with orphan/widow control and avoidPageBreakInside enforcement; new RDEPUBTextLayoutConfig struct; enhanced image sizing in renderer support.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/08-pagination-quality-cache-performance/08-CONTEXT.md @.planning/phases/08-pagination-quality-cache-performance/08-RESEARCH.md @.planning/phases/08-pagination-quality-cache-performance/08-PATTERNS.md @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift ```swift init(attributedString: NSAttributedString, pageSize: CGSize) ```
private func adjustedRange(
    from proposedRange: NSRange,
    totalLength: Int
) -> (
    range: NSRange,
    breakReason: RDEPUBTextPageBreakReason,
    blockRange: NSRange?,
    attachmentRanges: [NSRange],
    attachmentKinds: [RDEPUBTextAttachmentKind],
    blockKinds: [RDEPUBTextBlockKind],
    semanticHints: [RDEPUBTextSemanticHint],
    attachmentPlacements: [RDEPUBTextAttachmentPlacement],
    diagnostics: [String]
)
if hints.contains(.avoidPageBreakInside),
   attributeRange.location > range.location,
   attributeRange.location < range.location + range.length,
   attributeRange.location >= minimumEnd,
   attributeEnd > range.location + range.length {
    boundary = (attributeRange.location, RDEPUBTextSemanticHint.avoidPageBreakInside.rawValue)
    stop.pointee = true
}
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))
}
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
}
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
    }
}
extension NSAttributedString {
    func rd_paginatedFrames(
        size: CGSize,
        fragmentOffsets: [String: Int] = [:]
    ) -> [RDEPUBTextLayoutFrame] {
        RDEPUBTextLayouter(attributedString: self, pageSize: size)
            .layoutFrames(fragmentOffsets: fragmentOffsets)
    }
}
Task 1: Add RDEPUBTextLayoutConfig and wire into layouter + pagination support Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift, Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift, Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift **Step 1: Define RDEPUBTextLayoutConfig in RDEPUBTextRenderer.swift** (per D-02, D-03)

Insert RDEPUBTextLayoutConfig struct after RDEPUBTextRenderStyle (after 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

    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()
}

This follows the exact same pattern as RDEPUBTextRenderStyle at lines 36-48: public struct, public stored properties, public init with defaults, static default instance.

Step 2: Add config parameter to RDEPUBTextLayouter

In RDEPUBTextLayouter.swift:

  • Add stored property: private let config: RDEPUBTextLayoutConfig
  • Update init signature: init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default)
  • Store config: self.config = config

Step 3: Update rd_paginatedFrames extension

In RDEPUBTextPaginationSupport.swift, update the rd_paginatedFrames method to pass through config:

func rd_paginatedFrames(
    size: CGSize,
    fragmentOffsets: [String: Int] = [:],
    config: RDEPUBTextLayoutConfig = .default
) -> [RDEPUBTextLayoutFrame] {
    RDEPUBTextLayouter(attributedString: self, pageSize: size, config: config)
        .layoutFrames(fragmentOffsets: fragmentOffsets)
}

The config parameter has a default value so all existing call sites continue to work without changes. xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5 <acceptance_criteria> - RDEPUBTextLayoutConfig struct exists in RDEPUBTextRenderer.swift with properties: avoidOrphans, avoidWidows, avoidPageBreakInsideEnabled, imageMaxHeightRatio - RDEPUBTextLayoutConfig has public static let default instance - RDEPUBTextLayouter init accepts config: RDEPUBTextLayoutConfig parameter with .default default value - rd_paginatedFrames extension accepts config: RDEPUBTextLayoutConfig parameter with .default default value - Build succeeds with no errors at existing call sites (default parameter maintains backward compatibility) </acceptance_criteria> RDEPUBTextLayoutConfig struct defined in RDEPUBTextRenderer.swift; layouter and rd_paginatedFrames accept optional config parameter with backward-compatible defaults. Build succeeds.

Task 2: Implement avoidPageBreakInside enforcement and orphan/widow control in layouter Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift Modify `RDEPUBTextLayouter.swift` to add two pagination quality improvements. Both use the `config` property added in Task 1.

A. avoidPageBreakInside enforcement (per D-02, QUAL-02, WXRead reference: avoidPageBreakInsideByRemovingLastLinesIfNeeded)

The current preferredSemanticBoundary at lines 243-250 only triggers avoidPageBreakInside when the block START is the boundary point (i.e., the block starts after minimumEnd and extends past page end). This misses the case where the page end falls IN THE MIDDLE of an avoidPageBreakInside block.

Add a new private method avoidPageBreakInsideBoundary(in:proposedRange:minimumEnd:) that, after the existing preferredSemanticBoundary check and before preferredAttachmentBoundary in adjustedRange():

  1. Enumerate .rdPageSemanticHints in the proposed range
  2. For each range containing .avoidPageBreakInside:
    • If the attribute range overlaps with the last portion of the page (i.e., the attribute range contains characters near pageEnd)
    • And the attribute's start location is >= minimumEnd (so pushing to block start is reasonable)
    • Then return the attribute range's start location as the break point
  3. If config.avoidPageBreakInsideEnabled is false, skip this check entirely

Insert this check in adjustedRange() between the preferredSemanticBoundary block (line 114-139) and the preferredAttachmentBoundary block (line 141). When triggered, use breakReason: .semanticBoundary and add "avoidPageBreakInside enforcement" to diagnostics.

B. Orphan/widow control (per QUAL-02)

Add a new private method orphanWidowAdjustedRange(from:totalLength:) that:

  1. Orphan check (config.avoidOrphans): If the page starts at the beginning of a new paragraph, check if only 1-2 lines of that paragraph fit on the page. If so, and if pulling back 1-2 lines from the previous page would not cause that page to lose too much content, adjust the break point backward to include those lines. Use paragraphRange(containing:) to find paragraph boundaries.

    • Implementation: Check if the first paragraph on the page has fewer than 2 lines worth of characters (heuristic: paragraphRange.length < averageLineHeight * 2.5). If so, move the break point to include more of this paragraph from the previous page, respecting minimumEnd.
  2. Widow check (config.avoidWidows): If the page ends with just 1-2 lines of a paragraph, and the next page would start with the continuation of that same paragraph, check if pushing 1-2 lines forward would help. Use paragraphRange(containing:) to detect this.

    • Implementation: At the proposed page end, get the paragraph range. If the remaining portion of the paragraph after pageEnd is small (fewer than 2 lines), pull the break point back to before this paragraph, forcing the entire paragraph to the next page. Respect minimumEnd.

Insert this check after avoidPageBreakInsideBoundary and before preferredAttachmentBoundary in adjustedRange(). When triggered, use breakReason: .semanticBoundary and add "orphan control" or "widow control" to diagnostics.

C. Diagnostics enhancement: When avoidPageBreakInside or orphan/widow triggers, append a descriptive string to the diagnostics array, e.g., "avoidPageBreakInside: pushed block to next page", "orphan control: included 1 line from previous page", "widow control: pushed paragraph to next page". xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5 <acceptance_criteria> - adjustedRange() in RDEPUBTextLayouter contains a check for avoidPageBreakInside after preferredSemanticBoundary and before preferredAttachmentBoundary - The avoidPageBreakInside check is gated on config.avoidPageBreakInsideEnabled - Orphan control method exists and is called in adjustedRange() flow - Widow control method exists and is called in adjustedRange() flow - Both orphan and widow checks are gated on config.avoidOrphans and config.avoidWidows respectively - Diagnostic strings are appended when avoidPageBreakInside/orphan/widow triggers - Build succeeds </acceptance_criteria> Layouter enforces avoidPageBreakInside by detecting when page end falls inside an avoidPageBreakInside block and pushing the block to the next page. Orphan/widow control prevents single-line paragraphs at page boundaries. All gated via RDEPUBTextLayoutConfig. Build succeeds.

Task 3: Add general image fit-to-page sizing and enhanced diagnostics in renderer support Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift Modify `prepareHTMLElementForReaderRendering` in `RDEPUBTextRendererSupport.swift` (inside the `#if canImport(DTCoreText)` block, lines 216-262) to add general image sizing for all non-special-case images.

Current state: The method handles two special cases:

  • Footnote images (qqreader-footnote) at lines 228-241: inline, scaled to font size
  • Cover images (rd-front-cover-image) at lines 243-261: block display, scaled to fit screen

Add general image handling after the cover image check (after line 261, before the closing brace of the method). This handles ALL other images that are not footnotes or covers:

  1. Fit-to-page height (per D-03, QUAL-03, WXRead reference: images should not span pages):

    • Check if attachment.originalSize has valid dimensions (width > 0, height > 0)
    • Calculate maxImageHeight = pageSize.height * imageMaxHeightRatio — but since prepareHTMLElementForReaderRendering does not receive pageSize, use UIScreen.main.bounds.insetBy(dx: 20, dy: 28).height as the max height (same pattern used for cover images at line 244)
    • If originalSize.height > maxImageHeight: scale proportionally so display height = maxImageHeight
    • If originalSize.width > maxWidth (screen width minus insets): scale proportionally
    • Set attachment.displaySize to the scaled dimensions
    • The scale formula: let scale = min(maxWidth / originalSize.width, maxImageHeight / originalSize.height)
  2. Vertical centering for block-level images (per D-03, WXRead reference: wr-vertical-center-style):

    • If the element has displayStyle == .block (or is a figure/bodyPic), set attachment.verticalAlignment = .center
    • This aligns with the existing attachmentPlacements mechanism that already tracks .centered placement
  3. Enhanced diagnostic output (per QUAL-03):

    • After setting displaySize, print a diagnostic line: print("[EPUB][Attachment] general image path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize)) scaled=\(wasScaled)")
    • Use the existing string(from:) helper at line 424 to format CGSize values
    • Track wasScaled boolean: true if displaySize differs from originalSize
  4. Dark mode image preservation (per D-03):

    • Images in dark mode must preserve original colors (no color inversion). DTCoreText's NSAttributedString rendering already preserves image attachment colors by default.
    • Add a diagnostic check: if UITraitCollection.current.userInterfaceStyle == .dark, emit print("[EPUB][Attachment] dark mode: image colors preserved for path=\(lowercasedPath)") to verify the behavior is observable.
    • This is a declarative rule — ensure no future code adds tintColor overrides or color filters to image attachments in dark mode.
  5. Page background diagnostics (per D-03):

    • For attachment blocks, emit a diagnostic that includes the block's background color if one is set on the attributed string: print("[EPUB][Attachment] background: path=\(lowercasedPath) hasBackground=\(hasBackground)") where hasBackground checks if .backgroundColor attribute exists at the attachment location.
    • This provides verifiable evidence of background handling without changing rendering behavior.
  6. Guard condition: Only apply this general sizing if the image is NOT already handled by the footnote or cover checks above. Structure: the footnote check returns early (already does), the cover check returns early (add return at the end of the cover block), then the general image check runs for all remaining images.

Important: Add return at the end of the cover image block (after line 261) so the general image check does not double-process cover images. The current cover block does NOT have a return — it falls through. xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5 <acceptance_criteria> - prepareHTMLElementForReaderRendering contains a general image sizing block after the cover image block - General image block checks originalSize.height > maxImageHeight and scales proportionally - General image block sets attachment.verticalAlignment = .center for block-level images - Cover image block has return to prevent fall-through to general image handling - Diagnostic print("[EPUB][Attachment] general image ...") is emitted with original/display sizes - Dark mode diagnostic print("[EPUB][Attachment] dark mode: image colors preserved ...") is emitted when userInterfaceStyle == .dark - Background diagnostic print("[EPUB][Attachment] background: ...") is emitted for attachment blocks - Build succeeds </acceptance_criteria> General images (not footnote, not cover) are scaled to fit within page height, vertically centered when block-level, dark mode preserves original image colors, page background info diagnosed, and all decisions logged via console output. Build succeeds.

<threat_model>

Trust Boundaries

Boundary Description
CoreText layout → page break decisions Layouter now makes more aggressive break adjustments; incorrect logic could produce empty or overlapping pages

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation
T-08-03 Tampering RDEPUBTextLayoutConfig defaults accept Config is a value type with safe defaults; no external input can modify it
T-08-SC Tampering npm/pip/cargo installs mitigate No external packages installed in this phase
</threat_model>
- `xcodebuild build -scheme ReadViewDemo` succeeds - RDEPUBTextLayoutConfig struct exists in RDEPUBTextRenderer.swift - Layouter adjustedRange includes avoidPageBreakInside, orphan, and widow checks - prepareHTMLElementForReaderRendering handles general image sizing - Runtime log shows `[EPUB][Attachment] general image` for non-special images - Runtime log shows `[EPUB][Attachment] dark mode: image colors preserved` in dark mode - Runtime log shows `[EPUB][Attachment] background:` for attachment blocks

<success_criteria>

  • avoidPageBreakInside blocks are never split across page boundaries when config enabled
  • Orphan/widow control reduces single-line paragraphs at page boundaries
  • Oversized images scale to fit within page height
  • Dark mode preserves original image colors (no inversion)
  • Page background information is diagnosed for attachment blocks
  • Diagnostic output confirms image sizing decisions
  • Build succeeds with no regressions </success_criteria>
Create `.planning/phases/08-pagination-quality-cache-performance/08-02-SUMMARY.md` when done