feat(wxread): align pagination, rendering, and docs
This commit is contained in:
@@ -50,9 +50,15 @@ struct RDEPUBTextLayouter {
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
|
||||
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
|
||||
let lineAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
|
||||
let lineRanges = lineRanges(from: frame)
|
||||
|
||||
let adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
|
||||
let adjusted = adjustedRange(
|
||||
from: lineAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let trailingFragmentID = nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
@@ -106,8 +112,14 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
let lineAdjusted = trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
|
||||
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let lineAdjusted = trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
|
||||
let lineRanges = lineRanges(from: layoutFrame)
|
||||
let adjusted = adjustedRange(
|
||||
from: lineAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let trailingFragmentID = nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
@@ -142,7 +154,8 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
private func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int
|
||||
totalLength: Int,
|
||||
lineRanges: [NSRange]
|
||||
) -> (
|
||||
range: NSRange,
|
||||
breakReason: RDEPUBTextPageBreakReason,
|
||||
@@ -217,6 +230,34 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||
after: proposedRange,
|
||||
minimumEnd: minimumEnd,
|
||||
lineRanges: lineRanges
|
||||
) {
|
||||
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
|
||||
return (
|
||||
range: adjustedRange,
|
||||
breakReason: .semanticBoundary,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .semanticBoundary,
|
||||
range: adjustedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if let attachmentBoundary = preferredAttachmentBoundary(
|
||||
in: proposedRange,
|
||||
minimumEnd: minimumEnd
|
||||
@@ -300,15 +341,49 @@ struct RDEPUBTextLayouter {
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
||||
guard value != nil else { return }
|
||||
let paragraphRange = paragraphRange(containing: attributeRange.location)
|
||||
if paragraphRange.location > range.location, paragraphRange.location >= minimumEnd {
|
||||
boundary = paragraphRange.location
|
||||
|
||||
let location = attributeRange.location
|
||||
let placement = attachmentPlacement(at: location)
|
||||
let blockKind = blockKind(at: location)
|
||||
|
||||
// Mirror WXRead more closely: only block-level attachments should
|
||||
// push the entire block to the next page. Inline footnote icons and
|
||||
// other inline attachments must not cause a whole paragraph to move.
|
||||
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
|
||||
guard isBlockLevelAttachment else { return }
|
||||
|
||||
let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location)
|
||||
if boundaryRange.location > range.location, boundaryRange.location >= minimumEnd {
|
||||
boundary = boundaryRange.location
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
private func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
minimumEnd: Int,
|
||||
lineRanges: [NSRange]
|
||||
) -> Int? {
|
||||
let pageStartOfNext = range.location + range.length
|
||||
guard pageStartOfNext > range.location,
|
||||
pageStartOfNext < attributedString.length,
|
||||
lineRanges.count >= 2,
|
||||
semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let boundaryBlockStart = blockRange(at: pageStartOfNext)?.location ?? paragraphRange(containing: pageStartOfNext).location
|
||||
guard boundaryBlockStart == pageStartOfNext else { return nil }
|
||||
|
||||
let lastLineStart = lineRanges[lineRanges.count - 1].location
|
||||
guard lastLineStart > range.location, lastLineStart >= minimumEnd else {
|
||||
return nil
|
||||
}
|
||||
return lastLineStart
|
||||
}
|
||||
|
||||
private func blockRange(at location: Int) -> NSRange? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@@ -318,6 +393,20 @@ struct RDEPUBTextLayouter {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageBlockKind] as? String else { return nil }
|
||||
return RDEPUBTextBlockKind(rawValue: rawValue)
|
||||
}
|
||||
|
||||
private func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageAttachmentPlacement] as? String else { return nil }
|
||||
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
|
||||
}
|
||||
|
||||
private func paragraphRange(containing location: Int) -> NSRange {
|
||||
let source = attributedString.string as NSString
|
||||
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
|
||||
@@ -334,6 +423,15 @@ struct RDEPUBTextLayouter {
|
||||
return results
|
||||
}
|
||||
|
||||
private func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
|
||||
guard location >= 0, location < attributedString.length else { return [] }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return [] }
|
||||
return rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
var kinds: [RDEPUBTextAttachmentKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
|
||||
@@ -451,6 +549,18 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
let lineRanges = lines.map {
|
||||
let range = CTLineGetStringRange($0)
|
||||
return NSRange(location: range.location, length: range.length)
|
||||
}
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
@@ -492,8 +602,52 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||||
return proposed
|
||||
}
|
||||
let lineRanges = lines.map { $0.stringRange() }
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard !lineRanges.isEmpty else { return proposed }
|
||||
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if lineIsInKeepWithNextBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lineRanges.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lineRanges[validLineCount - 1]
|
||||
let endLocation = lastValidLine.location + lastValidLine.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// Checks if a line's string range intersects with an avoidPageBreakInside block.
|
||||
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||||
var found = false
|
||||
@@ -511,6 +665,39 @@ struct RDEPUBTextLayouter {
|
||||
return found
|
||||
}
|
||||
|
||||
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||
var found = false
|
||||
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
if hints.contains(.keepWithNext) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
return lines.map {
|
||||
let lineRange = CTLineGetStringRange($0)
|
||||
return NSRange(location: lineRange.location, length: lineRange.length)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||||
return []
|
||||
}
|
||||
return lines.map { $0.stringRange() }
|
||||
}
|
||||
#endif
|
||||
|
||||
private func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
|
||||
Reference in New Issue
Block a user