ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift
2026-05-26 21:08:27 +08:00

859 lines
36 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
/// CoreText
///
///
/// 1. avoidPageBreakInside WXRead 退
/// 2. keepWithNext
/// 3. pageBreakBefore/After
/// 4. pageRelate
/// 5.
/// 6. CoreText
///
///
/// - DTCoreText DTCoreTextLayouter DTCoreTextLayoutFrame
/// - CoreText 退CTFramesetterCreateFrame
struct RDEPUBTextLayouter {
///
private let attributedString: NSAttributedString
///
private let pageSize: CGSize
/// CoreText
private let framesetter: CTFramesetter
/// CTFrame
private let path: CGPath
/// DTCoreText
private let dtLayoutRect: CGRect
/// avoidPageBreakInside
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.dtLayoutRect = config.contentRect(fallback: pageSize)
self.path = Self.makeLayoutPath(pageSize: pageSize, config: config)
}
///
/// DTCoreText CoreText
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
}
// MARK: - CoreText 退
/// 使 CoreText API
///
/// CTFramesetterCreateFrame
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)
// avoidPageBreakInside WXRead
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
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// 使 DTCoreTextLayouter
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard config.numberOfColumns == 1 else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
layouter.shouldCacheLayoutFrames = false
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
let pageRect = dtLayoutRect
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 verifiedRange = verifiedDisplayRange(for: adjusted.range)
let trailingFragmentID = nearestTrailingFragmentID(
endingAt: verifiedRange.location + verifiedRange.length,
fragmentOffsets: fragmentOffsets
)
let diagnostics = verifiedRange == adjusted.range
? adjusted.diagnostics
: adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
frames.append(
RDEPUBTextLayoutFrame(
contentRange: verifiedRange,
breakReason: adjusted.breakReason,
blockRange: blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
attachmentRanges: attachmentRanges(in: verifiedRange),
attachmentKinds: attachmentKinds(in: verifiedRange),
blockKinds: blockKinds(in: verifiedRange),
semanticHints: semanticHints(in: verifiedRange),
attachmentPlacements: attachmentPlacements(in: verifiedRange),
trailingFragmentID: trailingFragmentID,
diagnostics: diagnostics
)
)
let nextLocation = verifiedRange.location + verifiedRange.length
guard nextLocation > location else {
location += max(visibleRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
private func verifiedDisplayRange(for range: NSRange) -> NSRange {
guard range.length > 0,
!attachmentRanges(in: range).isEmpty else {
return range
}
let pageContent = attributedString.attributedSubstring(from: range)
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
return range
}
layouter.shouldCacheLayoutFrames = false
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
return range
}
let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0, visibleRange.length < range.length else {
return range
}
return NSRange(location: range.location, length: visibleRange.length)
}
#endif
private static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
let columnRects = config.columnRects(fallback: pageSize)
guard columnRects.count > 1 else {
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
}
let path = CGMutablePath()
for rect in columnRects {
path.addRect(rect)
}
return path
}
// MARK: -
/// CoreText
///
///
/// 1. chapterEnd
/// 2. pageBreakBefore/After
/// 3. pageRelate
/// 4.
/// 5. 使
///
/// 55%
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
)
)
}
// 55%
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
// 1. pageBreakBefore/After
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
)
)
}
// 2. pageRelate
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
)
)
}
// 3.
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
)
)
}
// 4.
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
)
)
}
// MARK: -
/// pageBreakBefore / pageBreakAfter
///
/// minimumEnd
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
}
/// .attachment .centered
///
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)
// WXRead
//
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
}
/// pageRelate pageRelate
///
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
}
// MARK: -
///
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
}
/// fragment ID
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: - avoidPageBreakInsideWXRead
/// CTFrame avoidPageBreakInside
/// WXRead WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded
/// 3 kMaxLinesToRemove
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)
//
// kMaxLinesToRemove = 3 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 {
// 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 = 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)
}
/// CoreText keepWithNext
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)
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// DTCoreText avoidPageBreakInside
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)
}
/// DTCoreText keepWithNext
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
/// keepWithNext
/// 3
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)
}
/// avoidPageBreakInside
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
}
/// keepWithNext
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
}
/// CTFrame
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)
/// DTCoreTextLayoutFrame
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return []
}
return lines.map { $0.stringRange() }
}
#endif
// MARK: -
///
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
}
}