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)) } }