309 lines
13 KiB
Swift
309 lines
13 KiB
Swift
import Foundation
|
||
import UIKit
|
||
|
||
/// 分页规则策略:语义边界搜索、行级保护检查、范围调整调度器。
|
||
///
|
||
/// 不构建 CoreText frame,只基于 attributed string 属性做规则判定。
|
||
struct RDEPUBPageBreakPolicy {
|
||
private let attributedString: NSAttributedString
|
||
|
||
init(attributedString: NSAttributedString) {
|
||
self.attributedString = attributedString
|
||
}
|
||
|
||
// MARK: - 行级保护检查
|
||
|
||
/// 检查指定行范围是否落在 avoidPageBreakInside 保护块内。
|
||
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
||
return false
|
||
}
|
||
var found = false
|
||
attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
|
||
guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
|
||
return
|
||
}
|
||
guard let rawValue = attributes[.rdPageSemanticHints] 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 保护块内。
|
||
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
||
return false
|
||
}
|
||
var found = false
|
||
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
|
||
}
|
||
|
||
// MARK: - 范围调整调度器
|
||
|
||
/// 对 CoreText/DTCoreText 提出的分页范围进行语义边界调整。
|
||
///
|
||
/// 调整优先级:
|
||
/// 1. 若已达章节末尾,直接返回 chapterEnd
|
||
/// 2. pageRelate 跨页关联边界
|
||
/// 3. 以上都不满足时,使用原始帧限制分页
|
||
func adjustedRange(
|
||
from proposedRange: NSRange,
|
||
totalLength: Int,
|
||
lineRanges: [NSRange],
|
||
factory: RDEPUBCoreTextPageFrameFactory
|
||
) -> (
|
||
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 = factory.blockKinds(in: proposedRange)
|
||
let proposedSemanticHints = factory.semanticHints(in: proposedRange)
|
||
let proposedAttachmentPlacements = factory.attachmentPlacements(in: proposedRange)
|
||
guard pageEnd < totalLength else {
|
||
return (
|
||
range: proposedRange,
|
||
breakReason: .chapterEnd,
|
||
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||
attachmentRanges: factory.attachmentRanges(in: proposedRange),
|
||
attachmentKinds: factory.attachmentKinds(in: proposedRange),
|
||
blockKinds: proposedBlockKinds,
|
||
semanticHints: proposedSemanticHints,
|
||
attachmentPlacements: proposedAttachmentPlacements,
|
||
diagnostics: factory.diagnostics(
|
||
reason: .chapterEnd,
|
||
range: proposedRange,
|
||
attachmentRanges: factory.attachmentRanges(in: proposedRange),
|
||
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||
blockKinds: proposedBlockKinds,
|
||
semanticHints: proposedSemanticHints,
|
||
attachmentPlacements: proposedAttachmentPlacements
|
||
)
|
||
)
|
||
}
|
||
|
||
let currentBlockRange = factory.blockRange(at: max(proposedRange.location, pageEnd - 1))
|
||
let currentAttachmentRanges = factory.attachmentRanges(in: proposedRange)
|
||
let currentAttachmentKinds = factory.attachmentKinds(in: proposedRange)
|
||
let currentBlockKinds = proposedBlockKinds
|
||
let currentSemanticHints = proposedSemanticHints
|
||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||
|
||
// 对齐 WXRead:默认按 CTFrame 已经容纳的行数分页,仅保留 pageRelate 这种
|
||
// 微信读书特有的跨页关联规则。
|
||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||
after: proposedRange,
|
||
minimumEnd: proposedRange.location + 1,
|
||
lineRanges: lineRanges,
|
||
factory: factory
|
||
) {
|
||
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: factory.diagnostics(
|
||
reason: .semanticBoundary,
|
||
range: adjustedRange,
|
||
attachmentRanges: currentAttachmentRanges,
|
||
blockRange: currentBlockRange,
|
||
blockKinds: currentBlockKinds,
|
||
semanticHints: currentSemanticHints,
|
||
attachmentPlacements: currentAttachmentPlacements,
|
||
trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
|
||
)
|
||
)
|
||
}
|
||
|
||
// 帧限制(默认分页)
|
||
return (
|
||
range: proposedRange,
|
||
breakReason: .frameLimit,
|
||
blockRange: currentBlockRange,
|
||
attachmentRanges: currentAttachmentRanges,
|
||
attachmentKinds: currentAttachmentKinds,
|
||
blockKinds: currentBlockKinds,
|
||
semanticHints: currentSemanticHints,
|
||
attachmentPlacements: currentAttachmentPlacements,
|
||
diagnostics: factory.diagnostics(
|
||
reason: .frameLimit,
|
||
range: proposedRange,
|
||
attachmentRanges: currentAttachmentRanges,
|
||
blockRange: currentBlockRange,
|
||
blockKinds: currentBlockKinds,
|
||
semanticHints: currentSemanticHints,
|
||
attachmentPlacements: currentAttachmentPlacements
|
||
)
|
||
)
|
||
}
|
||
|
||
// MARK: - 语义边界查找
|
||
|
||
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
|
||
func preferredSemanticBoundary(
|
||
in range: NSRange,
|
||
minimumEnd: Int,
|
||
factory: RDEPUBCoreTextPageFrameFactory
|
||
) -> (location: Int, trigger: String)? {
|
||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
||
return nil
|
||
}
|
||
var boundary: (location: Int, trigger: String)?
|
||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { 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 > safeRange.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 < safeRange.location + safeRange.length {
|
||
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
||
stop.pointee = true
|
||
return
|
||
}
|
||
}
|
||
return boundary
|
||
}
|
||
|
||
/// 查找附件边界:只有块级附件才触发分页。
|
||
func preferredAttachmentBoundary(
|
||
in range: NSRange,
|
||
minimumEnd: Int,
|
||
factory: RDEPUBCoreTextPageFrameFactory
|
||
) -> Int? {
|
||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
||
return nil
|
||
}
|
||
var boundary: Int?
|
||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
|
||
guard value != nil else { return }
|
||
|
||
let location = attributeRange.location
|
||
let placement = factory.attachmentPlacement(at: location)
|
||
let blockKind = factory.blockKind(at: location)
|
||
|
||
let isBlockLevelAttachment: Bool
|
||
switch placement {
|
||
case .centered:
|
||
isBlockLevelAttachment = true
|
||
case .inline, .baseline:
|
||
isBlockLevelAttachment = false
|
||
case nil:
|
||
isBlockLevelAttachment = blockKind == .attachment
|
||
}
|
||
guard isBlockLevelAttachment else { return }
|
||
|
||
let boundaryRange = factory.blockRange(at: location) ?? factory.paragraphRange(containing: location)
|
||
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
|
||
boundary = boundaryRange.location
|
||
stop.pointee = true
|
||
}
|
||
}
|
||
return boundary
|
||
}
|
||
|
||
/// 查找 pageRelate 跨页关联边界。
|
||
func preferredPageRelateBoundary(
|
||
after range: NSRange,
|
||
minimumEnd: Int,
|
||
lineRanges: [NSRange],
|
||
factory: RDEPUBCoreTextPageFrameFactory
|
||
) -> Int? {
|
||
let pageStartOfNext = range.location + range.length
|
||
guard pageStartOfNext > range.location,
|
||
pageStartOfNext < attributedString.length,
|
||
lineRanges.count >= 2,
|
||
factory.semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
|
||
return nil
|
||
}
|
||
|
||
let boundaryBlockStart = factory.blockRange(at: pageStartOfNext)?.location
|
||
?? factory.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 shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
|
||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
|
||
return false
|
||
}
|
||
let hints = rawValue
|
||
.split(separator: ",")
|
||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||
guard hints.contains(.avoidPageBreakInside) else {
|
||
return false
|
||
}
|
||
|
||
let placement = (attributes[.rdPageAttachmentPlacement] as? String)
|
||
.flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
|
||
let blockKind = (attributes[.rdPageBlockKind] as? String)
|
||
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
||
|
||
if blockKind == .attachment, placement != .centered {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
|
||
clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
|
||
}
|
||
|
||
private func clampedRange(_ range: NSRange) -> NSRange? {
|
||
guard range.location >= 0, range.length >= 0 else { return nil }
|
||
guard attributedString.length > 0 else {
|
||
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
|
||
}
|
||
guard range.location < attributedString.length else { return nil }
|
||
|
||
let maxLength = attributedString.length - range.location
|
||
return NSRange(location: range.location, length: min(range.length, maxLength))
|
||
}
|
||
}
|