23 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 08-pagination-quality-cache-performance | 02 | execute | 1 |
|
true |
|
|
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.
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():
- Enumerate
.rdPageSemanticHintsin the proposed range - 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
- If the attribute range overlaps with the last portion of the page (i.e., the attribute range contains characters near
- If
config.avoidPageBreakInsideEnabledis 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:
-
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.
- Implementation: Check if the first paragraph on the page has fewer than 2 lines worth of characters (heuristic:
-
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.
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:
-
Fit-to-page height (per D-03, QUAL-03, WXRead reference: images should not span pages):
- Check if
attachment.originalSizehas valid dimensions (width > 0, height > 0) - Calculate
maxImageHeight = pageSize.height * imageMaxHeightRatio— but sinceprepareHTMLElementForReaderRenderingdoes not receive pageSize, useUIScreen.main.bounds.insetBy(dx: 20, dy: 28).heightas 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.displaySizeto the scaled dimensions - The scale formula:
let scale = min(maxWidth / originalSize.width, maxImageHeight / originalSize.height)
- Check if
-
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), setattachment.verticalAlignment = .center - This aligns with the existing
attachmentPlacementsmechanism that already tracks.centeredplacement
- If the element has
-
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
wasScaledboolean: true if displaySize differs from originalSize
- After setting displaySize, print a diagnostic line:
-
Dark mode image preservation (per D-03):
- Images in dark mode must preserve original colors (no color inversion). DTCoreText's
NSAttributedStringrendering already preserves image attachment colors by default. - Add a diagnostic check: if
UITraitCollection.current.userInterfaceStyle == .dark, emitprint("[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
tintColoroverrides or color filters to image attachments in dark mode.
- Images in dark mode must preserve original colors (no color inversion). DTCoreText's
-
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)")wherehasBackgroundchecks if.backgroundColorattribute exists at the attachment location. - This provides verifiable evidence of background handling without changing rendering behavior.
- For attachment blocks, emit a diagnostic that includes the block's background color if one is set on the attributed string:
-
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
returnat 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> |
<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>