--- phase: 08-pagination-quality-cache-performance plan: 02 type: execute wave: 1 depends_on: [] files_modified: - Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift - Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift - Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift - Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift autonomous: true requirements: - QUAL-02 - QUAL-03 must_haves: truths: - "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" artifacts: - path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift" provides: "RDEPUBTextLayoutConfig struct definition" contains: "struct RDEPUBTextLayoutConfig" - path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift" provides: "avoidPageBreakInside enforcement, orphan/widow control" contains: "orphanWidowAdjustedRange" - path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift" provides: "General image fit-to-page sizing and centering" contains: "imageMaxHeight" key_links: - from: "RDEPUBTextLayouter.swift" to: "RDEPUBTextRenderer.swift" via: "RDEPUBTextLayoutConfig parameter in init" pattern: "config: RDEPUBTextLayoutConfig" - from: "RDEPUBTextRendererSupport.swift" to: "RDEPUBTextLayoutConfig" via: "imageMaxHeightRatio used in prepareHTMLElementForReaderRendering" pattern: "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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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) ``` ```swift 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] ) ``` ```swift 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 } ``` ```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)) } ``` ```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 } ``` ```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 } } ``` ```swift 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: ```swift 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: ```swift 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 - `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) 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 - `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 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 - `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 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. ## 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 | - `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 - 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 Create `.planning/phases/08-pagination-quality-cache-performance/08-02-SUMMARY.md` when done