refactor: split reader architecture and chrome handling

This commit is contained in:
shen
2026-05-31 21:47:54 +08:00
parent ea21c6a831
commit 44202357c0
80 changed files with 10635 additions and 8522 deletions
@@ -0,0 +1,270 @@
import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
///
///
///
/// 1. avoidPageBreakInside WXRead 退
/// 2. keepWithNext
/// 3. pageBreakBefore/After
/// 4. pageRelate
/// 5.
/// 6. CoreText
struct RDEPUBChapterPageCounter {
private let factory: RDEPUBCoreTextPageFrameFactory
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
/// CoreText
private let framesetter: CTFramesetter
/// DTCoreText
private let dtLayoutRect: CGRect
init(factory: RDEPUBCoreTextPageFrameFactory) {
self.factory = factory
self.attributedString = factory.attributedString
self.pageSize = factory.pageSize
self.config = factory.config
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: factory.attributedString)
self.framesetter = CTFramesetterCreateWithAttributedString(factory.attributedString)
self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize)
}
///
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 退
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
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
guard usableWidth > 0, usableHeight > 0 else {
return []
}
while location < attributedString.length {
let framePath = CGMutablePath()
let pageRect = CGRect(
x: config.edgeInsets.left,
y: config.edgeInsets.bottom,
width: usableWidth,
height: usableHeight
)
framePath.addRect(pageRect)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil)
let proposedRange = proposedRangeUsingWXReadPageCount(
from: frame,
start: location,
usableHeight: usableHeight,
totalLength: attributedString.length
)
guard proposedRange.length > 0 else {
break
}
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
let adjusted = pageBreakPolicy.adjustedRange(
from: avoidAdjusted,
totalLength: attributedString.length,
lineRanges: lineRanges,
factory: factory
)
let trailingFragmentID = factory.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(proposedRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
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 = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
let adjusted = pageBreakPolicy.adjustedRange(
from: lineAdjusted,
totalLength: attributedString.length,
lineRanges: lineRanges,
factory: factory
)
let verifiedRange: NSRange
if adjusted.breakReason == .attachmentBoundary {
verifiedRange = verifiedDisplayRange(for: adjusted.range)
} else {
verifiedRange = adjusted.range
}
let trailingFragmentID = factory.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: factory.blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
attachmentRanges: factory.attachmentRanges(in: verifiedRange),
attachmentKinds: factory.attachmentKinds(in: verifiedRange),
blockKinds: factory.blockKinds(in: verifiedRange),
semanticHints: factory.semanticHints(in: verifiedRange),
attachmentPlacements: factory.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 let clampedRange = factory.clampedRange(range),
clampedRange.length > 0,
!factory.attachmentRanges(in: clampedRange).isEmpty else {
return range
}
let pageContent = attributedString.attributedSubstring(from: clampedRange)
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
return clampedRange
}
layouter.shouldCacheLayoutFrames = false
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
return clampedRange
}
let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
return clampedRange
}
return NSRange(location: clampedRange.location, length: visibleRange.length)
}
#endif
// MARK: - WXRead
/// WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString`
private func proposedRangeUsingWXReadPageCount(
from frame: CTFrame,
start location: Int,
usableHeight: CGFloat,
totalLength: Int
) -> NSRange {
let lines = CTFrameGetLines(frame) as! [CTLine]
guard !lines.isEmpty else {
return NSRange(location: location, length: 0)
}
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
var pageCharCount = 0
for (index, line) in lines.enumerated() {
let lineRange = CTLineGetStringRange(line)
let lineY = origins[index].y
var ascent: CGFloat = 0
var descent: CGFloat = 0
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
if lineY - ascent > usableHeight {
break
}
pageCharCount += lineRange.length
}
if pageCharCount == 0 {
pageCharCount = 1
}
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
}
}
@@ -0,0 +1,389 @@
import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
/// CoreText
///
/// RDEPUBChapterPageCounter RDEPUBPageBreakPolicy
struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
let attributedString: NSAttributedString
let pageSize: CGSize
let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
self.attributedString = attributedString
self.pageSize = pageSize
self.config = config
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
}
/// 便使
init() {
self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
}
// MARK: - RDEPUBPageFrameBuilding
func makeFrames(
attributedString: NSAttributedString,
pageSize: CGSize,
config: RDEPUBTextLayoutConfig,
fragmentOffsets: [String: Int]
) -> [RDEPUBTextLayoutFrame] {
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
let counter = RDEPUBChapterPageCounter(factory: factory)
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
// MARK: -
/// CGPath CTFramesetterCreateFrame
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: -
/// CTFrame avoidPageBreakInside
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 }
let lineRanges = lines.map {
let range = CTLineGetStringRange($0)
return NSRange(location: range.location, length: range.length)
}
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
}
/// CoreText keepWithNext
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)
/// DTCoreText avoidPageBreakInside
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 lineRanges = lines.map { $0.stringRange() }
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
}
/// DTCoreText keepWithNext
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
/// avoidPageBreakInside
func trimmedRangeForAvoidPageBreakInside(
proposed: NSRange,
lineRanges: [NSRange]
) -> NSRange {
guard config.avoidPageBreakInsideEnabled, !lineRanges.isEmpty else { return proposed }
let kMaxLinesToRemove = 3
var linesToRemove = 0
for lineRange in lineRanges.reversed() {
if pageBreakPolicy.lineIsInAvoidPageBreakInsideBlock(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)
}
/// keepWithNext
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 pageBreakPolicy.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)
}
// MARK: -
/// CTFrame
static 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
static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return []
}
return lines.map { $0.stringRange() }
}
#endif
// MARK: -
///
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
}
///
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)
}
///
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)
}
///
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))
}
///
func attachmentRanges(in range: NSRange) -> [NSRange] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var results: [NSRange] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
guard value != nil else { return }
results.append(attributeRange)
}
return results
}
///
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)) }
}
///
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var kinds: [RDEPUBTextAttachmentKind] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
///
func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var kinds: [RDEPUBTextBlockKind] = []
attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
///
func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var hints: [RDEPUBTextSemanticHint] = []
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { 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
}
///
func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var placements: [RDEPUBTextAttachmentPlacement] = []
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
guard let rawValue = value as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
!placements.contains(placement) else {
return
}
placements.append(placement)
}
return placements
}
/// attributedString
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))
}
/// fragment ID
func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
) -> String? {
fragmentOffsets
.filter { $0.value <= location }
.max { lhs, rhs in lhs.value < rhs.value }?
.key
}
// MARK: -
///
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
}
}
@@ -0,0 +1,308 @@
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))
}
}
@@ -0,0 +1,44 @@
import Foundation
/// CoreText
///
/// `RDEPUBTextLayouter`
/// `RDEPUBTextBookBuilder` `RDEPUBTextLayoutFrame`
/// `RDEPUBTextPage` `RDEPUBTextBook`
struct RDEPUBTextLayoutFrame: Equatable {
///
var contentRange: NSRange
///
var breakReason: RDEPUBTextPageBreakReason
/// HTML
var blockRange: NSRange?
///
var attachmentRanges: [NSRange]
///
var attachmentKinds: [RDEPUBTextAttachmentKind]
/// HTML
var blockKinds: [RDEPUBTextBlockKind]
///
var semanticHints: [RDEPUBTextSemanticHint]
/// 线
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// fragment ID
var trailingFragmentID: String?
///
var diagnostics: [String]
///
var metadata: RDEPUBTextPageMetadata {
RDEPUBTextPageMetadata(
breakReason: breakReason,
blockRange: blockRange,
attachmentRanges: attachmentRanges,
attachmentKinds: attachmentKinds,
blockKinds: blockKinds,
semanticHints: semanticHints,
attachmentPlacements: attachmentPlacements,
trailingFragmentID: trailingFragmentID,
diagnostics: diagnostics
)
}
}
@@ -0,0 +1,26 @@
import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
/// CoreText Facade
///
///
/// - RDEPUBCoreTextPageFrameFactory
/// - RDEPUBPageBreakPolicy keepWithNext
/// - RDEPUBChapterPageCounter
struct RDEPUBTextLayouter {
private let counter: RDEPUBChapterPageCounter
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
self.counter = RDEPUBChapterPageCounter(factory: factory)
}
///
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
}
@@ -0,0 +1,28 @@
import Foundation
import UIKit
// MARK: -
struct RDEPUBPageBreakDecision {
var range: NSRange
var reason: RDEPUBTextPageBreakReason
var diagnostics: [String]
}
protocol RDEPUBChapterPageCounting {
func pageRanges(
for attributedString: NSAttributedString,
pageSize: CGSize,
config: RDEPUBTextLayoutConfig,
fragmentOffsets: [String: Int]
) -> [RDEPUBPageBreakDecision]
}
protocol RDEPUBPageFrameBuilding {
func makeFrames(
attributedString: NSAttributedString,
pageSize: CGSize,
config: RDEPUBTextLayoutConfig,
fragmentOffsets: [String: Int]
) -> [RDEPUBTextLayoutFrame]
}
@@ -0,0 +1,45 @@
import CoreText
import UIKit
// MARK: - NSAttributedString
/// NSAttributedString 便 CoreText
extension NSAttributedString {
///
///
/// `RDEPUBTextBookBuilder` `RDPlainTextBookBuilder`
/// - Parameters:
/// - size:
/// - fragmentOffsets: fragment ID
/// - config: avoidPageBreakInside
/// - Returns:
func rd_paginatedFrames(
size: CGSize,
fragmentOffsets: [String: Int] = [:],
config: RDEPUBTextLayoutConfig = .default
) -> [RDEPUBTextLayoutFrame] {
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: self, pageSize: size, config: config)
let counter = RDEPUBChapterPageCounter(factory: factory)
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
/// NSRange
func ss_pageRanges(size: CGSize) -> [NSRange] {
rd_paginatedFrames(size: size).map(\.contentRange)
}
}
// MARK: - UIColor CSS
/// UIColor CSS CSS
extension UIColor {
/// UIColor CSS rgba()
var ss_cssString: String {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha)
}
}