733 lines
30 KiB
Swift
733 lines
30 KiB
Swift
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, 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)
|
|
}
|
|
|
|
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
|
|
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
|
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
|
|
|
|
while location < attributedString.length {
|
|
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil)
|
|
let visibleRange = CTFrameGetVisibleStringRange(frame)
|
|
guard visibleRange.length > 0 else {
|
|
break
|
|
}
|
|
|
|
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
|
|
|
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
|
|
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,
|
|
lineRanges: lineRanges
|
|
)
|
|
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
|
|
}
|
|
|
|
#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 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
|
|
)
|
|
|
|
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,
|
|
lineRanges: [NSRange]
|
|
) -> (
|
|
range: NSRange,
|
|
breakReason: RDEPUBTextPageBreakReason,
|
|
blockRange: NSRange?,
|
|
attachmentRanges: [NSRange],
|
|
attachmentKinds: [RDEPUBTextAttachmentKind],
|
|
blockKinds: [RDEPUBTextBlockKind],
|
|
semanticHints: [RDEPUBTextSemanticHint],
|
|
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
|
diagnostics: [String]
|
|
) {
|
|
let pageEnd = proposedRange.location + proposedRange.length
|
|
let proposedBlockKinds = blockKinds(in: proposedRange)
|
|
let proposedSemanticHints = semanticHints(in: proposedRange)
|
|
let proposedAttachmentPlacements = attachmentPlacements(in: proposedRange)
|
|
guard pageEnd < totalLength else {
|
|
return (
|
|
range: proposedRange,
|
|
breakReason: .chapterEnd,
|
|
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
|
attachmentRanges: attachmentRanges(in: proposedRange),
|
|
attachmentKinds: attachmentKinds(in: proposedRange),
|
|
blockKinds: proposedBlockKinds,
|
|
semanticHints: proposedSemanticHints,
|
|
attachmentPlacements: proposedAttachmentPlacements,
|
|
diagnostics: diagnostics(
|
|
reason: .chapterEnd,
|
|
range: proposedRange,
|
|
attachmentRanges: attachmentRanges(in: proposedRange),
|
|
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
|
blockKinds: proposedBlockKinds,
|
|
semanticHints: proposedSemanticHints,
|
|
attachmentPlacements: proposedAttachmentPlacements
|
|
)
|
|
)
|
|
}
|
|
|
|
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
|
|
let minimumEnd = proposedRange.location + minLength
|
|
|
|
let currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
|
|
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
|
|
let currentAttachmentKinds = attachmentKinds(in: proposedRange)
|
|
let currentBlockKinds = proposedBlockKinds
|
|
let currentSemanticHints = proposedSemanticHints
|
|
let currentAttachmentPlacements = proposedAttachmentPlacements
|
|
|
|
if let semanticBoundary = preferredSemanticBoundary(
|
|
in: proposedRange,
|
|
minimumEnd: minimumEnd
|
|
) {
|
|
let adjustedRange = NSRange(location: proposedRange.location, length: semanticBoundary.location - 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: semanticBoundary.trigger
|
|
)
|
|
)
|
|
}
|
|
|
|
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
|
|
) {
|
|
let adjustedRange = NSRange(location: proposedRange.location, length: attachmentBoundary - proposedRange.location)
|
|
return (
|
|
range: adjustedRange,
|
|
breakReason: .attachmentBoundary,
|
|
blockRange: currentBlockRange,
|
|
attachmentRanges: currentAttachmentRanges,
|
|
attachmentKinds: currentAttachmentKinds,
|
|
blockKinds: currentBlockKinds,
|
|
semanticHints: currentSemanticHints,
|
|
attachmentPlacements: currentAttachmentPlacements,
|
|
diagnostics: diagnostics(
|
|
reason: .attachmentBoundary,
|
|
range: adjustedRange,
|
|
attachmentRanges: currentAttachmentRanges,
|
|
blockRange: currentBlockRange,
|
|
blockKinds: currentBlockKinds,
|
|
semanticHints: currentSemanticHints,
|
|
attachmentPlacements: currentAttachmentPlacements
|
|
)
|
|
)
|
|
}
|
|
|
|
return (
|
|
range: proposedRange,
|
|
breakReason: .frameLimit,
|
|
blockRange: currentBlockRange,
|
|
attachmentRanges: currentAttachmentRanges,
|
|
attachmentKinds: currentAttachmentKinds,
|
|
blockKinds: currentBlockKinds,
|
|
semanticHints: currentSemanticHints,
|
|
attachmentPlacements: currentAttachmentPlacements,
|
|
diagnostics: diagnostics(
|
|
reason: .frameLimit,
|
|
range: proposedRange,
|
|
attachmentRanges: currentAttachmentRanges,
|
|
blockRange: currentBlockRange,
|
|
blockKinds: currentBlockKinds,
|
|
semanticHints: currentSemanticHints,
|
|
attachmentPlacements: currentAttachmentPlacements
|
|
)
|
|
)
|
|
}
|
|
|
|
private func preferredSemanticBoundary(
|
|
in range: NSRange,
|
|
minimumEnd: Int
|
|
) -> (location: Int, trigger: String)? {
|
|
var boundary: (location: Int, trigger: String)?
|
|
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, attributeRange, stop in
|
|
guard let rawValue = value as? String else { return }
|
|
let hints = rawValue
|
|
.split(separator: ",")
|
|
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
|
guard !hints.isEmpty else { return }
|
|
|
|
if hints.contains(.pageBreakBefore),
|
|
attributeRange.location > range.location,
|
|
attributeRange.location >= minimumEnd {
|
|
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
|
|
stop.pointee = true
|
|
return
|
|
}
|
|
|
|
let attributeEnd = attributeRange.location + attributeRange.length
|
|
if hints.contains(.pageBreakAfter),
|
|
attributeEnd > minimumEnd,
|
|
attributeEnd < range.location + range.length {
|
|
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
|
stop.pointee = true
|
|
return
|
|
}
|
|
}
|
|
return boundary
|
|
}
|
|
|
|
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
|
|
var boundary: Int?
|
|
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
|
guard value != nil else { return }
|
|
|
|
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)
|
|
if let encodedRange = attributes[.rdPageBlockRange] as? String {
|
|
return NSRangeFromString(encodedRange)
|
|
}
|
|
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) }
|
|
let safeLocation = min(max(location, 0), max(source.length - 1, 0))
|
|
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
|
|
}
|
|
|
|
private func attachmentRanges(in range: NSRange) -> [NSRange] {
|
|
var results: [NSRange] = []
|
|
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
|
|
guard value != nil else { return }
|
|
results.append(attributeRange)
|
|
}
|
|
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
|
|
guard let rawValue = value as? String,
|
|
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
|
|
!kinds.contains(kind) else {
|
|
return
|
|
}
|
|
kinds.append(kind)
|
|
}
|
|
return kinds
|
|
}
|
|
|
|
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
|
var kinds: [RDEPUBTextBlockKind] = []
|
|
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
|
|
guard let rawValue = value as? String,
|
|
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
|
!kinds.contains(kind) else {
|
|
return
|
|
}
|
|
kinds.append(kind)
|
|
}
|
|
return kinds
|
|
}
|
|
|
|
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
|
var hints: [RDEPUBTextSemanticHint] = []
|
|
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in
|
|
guard let rawValue = value as? String else { return }
|
|
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
|
|
hints.append(hint)
|
|
}
|
|
}
|
|
return hints
|
|
}
|
|
|
|
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
|
var placements: [RDEPUBTextAttachmentPlacement] = []
|
|
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
|
|
guard let rawValue = value as? String,
|
|
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
|
!placements.contains(placement) else {
|
|
return
|
|
}
|
|
placements.append(placement)
|
|
}
|
|
return placements
|
|
}
|
|
|
|
private func nearestTrailingFragmentID(
|
|
endingAt location: Int,
|
|
fragmentOffsets: [String: Int]
|
|
) -> String? {
|
|
fragmentOffsets
|
|
.filter { $0.value <= location }
|
|
.max { lhs, rhs in lhs.value < rhs.value }?
|
|
.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)
|
|
}
|
|
|
|
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,
|
|
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)
|
|
}
|
|
|
|
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
|
|
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 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,
|
|
attachmentRanges: [NSRange],
|
|
blockRange: NSRange?,
|
|
blockKinds: [RDEPUBTextBlockKind],
|
|
semanticHints: [RDEPUBTextSemanticHint],
|
|
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
|
trigger: String? = nil
|
|
) -> [String] {
|
|
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
|
|
if let blockRange {
|
|
items.append("block range: \(NSStringFromRange(blockRange))")
|
|
}
|
|
if !attachmentRanges.isEmpty {
|
|
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
|
|
}
|
|
if !blockKinds.isEmpty {
|
|
items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
|
|
}
|
|
if !semanticHints.isEmpty {
|
|
items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
|
|
}
|
|
if !attachmentPlacements.isEmpty {
|
|
items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
|
|
}
|
|
if let trigger, !trigger.isEmpty {
|
|
items.append("semantic trigger: \(trigger)")
|
|
}
|
|
return items
|
|
}
|
|
}
|