feat(epub): align text rendering with WXRead
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
struct RDEPUBTextLayouter {
|
||||
private let attributedString: NSAttributedString
|
||||
private let pageSize: CGSize
|
||||
private let framesetter: CTFramesetter
|
||||
private let path: CGPath
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize) {
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
self.attributedString = attributedString
|
||||
self.pageSize = pageSize
|
||||
self.config = config
|
||||
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
|
||||
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
|
||||
}
|
||||
@@ -19,6 +25,18 @@ struct RDEPUBTextLayouter {
|
||||
return []
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
|
||||
#else
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
|
||||
@@ -30,7 +48,11 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
let adjusted = adjustedRange(from: proposedRange, totalLength: attributedString.length)
|
||||
|
||||
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
|
||||
let lineAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
|
||||
let adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
|
||||
let trailingFragmentID = nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
@@ -61,6 +83,63 @@ struct RDEPUBTextLayouter {
|
||||
return frames
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let pageRect = CGRect(origin: .zero, size: pageSize)
|
||||
|
||||
while location < attributedString.length {
|
||||
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
|
||||
break
|
||||
}
|
||||
|
||||
let visibleRange = layoutFrame.visibleStringRange()
|
||||
guard visibleRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
let lineAdjusted = trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
|
||||
let trailingFragmentID = nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
|
||||
frames.append(
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: adjusted.range,
|
||||
breakReason: adjusted.breakReason,
|
||||
blockRange: adjusted.blockRange,
|
||||
attachmentRanges: adjusted.attachmentRanges,
|
||||
attachmentKinds: adjusted.attachmentKinds,
|
||||
blockKinds: adjusted.blockKinds,
|
||||
semanticHints: adjusted.semanticHints,
|
||||
attachmentPlacements: adjusted.attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: adjusted.diagnostics
|
||||
)
|
||||
)
|
||||
|
||||
let nextLocation = adjusted.range.location + adjusted.range.length
|
||||
guard nextLocation > location else {
|
||||
location += max(visibleRange.length, 1)
|
||||
continue
|
||||
}
|
||||
location = nextLocation
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
#endif
|
||||
|
||||
private func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int
|
||||
@@ -164,32 +243,6 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
if let blockBoundary = preferredBlockBoundary(
|
||||
near: pageEnd,
|
||||
lowerBound: minimumEnd
|
||||
) {
|
||||
let adjustedRange = NSRange(location: proposedRange.location, length: blockBoundary - proposedRange.location)
|
||||
return (
|
||||
range: adjustedRange,
|
||||
breakReason: .blockBoundary,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .blockBoundary,
|
||||
range: adjustedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .frameLimit,
|
||||
@@ -239,15 +292,6 @@ struct RDEPUBTextLayouter {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
@@ -265,23 +309,6 @@ struct RDEPUBTextLayouter {
|
||||
return boundary
|
||||
}
|
||||
|
||||
private func preferredBlockBoundary(near location: Int, lowerBound: Int) -> Int? {
|
||||
var probe = max(lowerBound, 0)
|
||||
let searchEnd = min(location, attributedString.length)
|
||||
guard probe < searchEnd else { return nil }
|
||||
|
||||
var lastBoundary: Int?
|
||||
while probe < searchEnd {
|
||||
let block = blockRange(at: probe) ?? paragraphRange(containing: probe)
|
||||
let candidate = block.location
|
||||
if candidate > lowerBound, candidate < location {
|
||||
lastBoundary = candidate
|
||||
}
|
||||
probe = max(block.location + max(block.length, 1), probe + 1)
|
||||
}
|
||||
return lastBoundary
|
||||
}
|
||||
|
||||
private func blockRange(at location: Int) -> NSRange? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@@ -367,6 +394,123 @@ struct RDEPUBTextLayouter {
|
||||
.key
|
||||
}
|
||||
|
||||
// MARK: - Line-level avoidPageBreakInside (WXRead approach)
|
||||
|
||||
/// Scans CTFrame lines backward from the last line, removing trailing lines
|
||||
/// that fall inside an avoidPageBreakInside block. Mirrors WXRead's
|
||||
/// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded.
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||||
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
guard !lines.isEmpty else { return proposed }
|
||||
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
// Scan backward: count consecutive trailing lines inside protected blocks
|
||||
// kMaxLinesToRemove = 3 (same as WXRead)
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for i in stride(from: lines.count - 1, through: 0, by: -1) {
|
||||
let lineRange = CTLineGetStringRange(lines[i])
|
||||
let lineNSRange = NSRange(location: lineRange.location, length: lineRange.length)
|
||||
|
||||
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
// Don't remove more than kMaxLinesToRemove; stop here
|
||||
// (line at index i stays, so linesToRemove stays at kMaxLinesToRemove)
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lines.count - linesToRemove
|
||||
guard validLineCount > 0 else {
|
||||
// All lines are in protected blocks; fall back to proposed
|
||||
return proposed
|
||||
}
|
||||
|
||||
let lastValidLine = lines[validLineCount - 1]
|
||||
let lastLineRange = CTLineGetStringRange(lastValidLine)
|
||||
let endLocation = lastLineRange.location + lastLineRange.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||||
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||||
return proposed
|
||||
}
|
||||
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for line in lines.reversed() {
|
||||
let lineRange = line.stringRange()
|
||||
if lineIsInAvoidPageBreakInsideBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lines.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lines[validLineCount - 1]
|
||||
let lastLineRange = lastValidLine.stringRange()
|
||||
let endLocation = lastLineRange.location + lastLineRange.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Checks if a line's string range intersects with an avoidPageBreakInside block.
|
||||
private func lineIsInAvoidPageBreakInsideBlock(_ 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(.avoidPageBreakInside) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
|
||||
Reference in New Issue
Block a user