- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
500 lines
18 KiB
Swift
500 lines
18 KiB
Swift
import CoreText
|
|
import UIKit
|
|
|
|
#if canImport(DTCoreText)
|
|
import DTCoreText
|
|
#endif
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
|
|
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 }
|
|
|
|
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
|
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
|
|
print("[PAGINATION-DEBUG] avoidPageBreakInside removed \(linesToRemove) lines: \"\(removedText)\"")
|
|
}
|
|
|
|
return NSRange(location: proposed.location, length: adjustedLength)
|
|
}
|
|
|
|
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 }
|
|
|
|
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
|
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
|
|
print("[PAGINATION-DEBUG] keepWithNext removed \(linesToRemove) lines: \"\(removedText)\"")
|
|
}
|
|
|
|
return NSRange(location: proposed.location, length: adjustedLength)
|
|
}
|
|
|
|
func trimmedRangeForWidowAndOrphanControl(
|
|
proposed: NSRange,
|
|
lineRanges: [NSRange]
|
|
) -> NSRange {
|
|
guard !lineRanges.isEmpty else { return proposed }
|
|
guard config.avoidWidows || config.avoidOrphans else { return proposed }
|
|
|
|
var adjusted = proposed
|
|
var visibleLines = lineRanges
|
|
|
|
if config.avoidWidows,
|
|
let widowAdjusted = trimmedRangeAvoidingWidow(
|
|
proposed: adjusted,
|
|
lineRanges: visibleLines
|
|
),
|
|
widowAdjusted != adjusted {
|
|
adjusted = widowAdjusted
|
|
visibleLines = Array(visibleLines.dropLast())
|
|
}
|
|
|
|
if config.avoidOrphans,
|
|
let orphanAdjusted = trimmedRangeAvoidingOrphan(
|
|
proposed: adjusted,
|
|
lineRanges: visibleLines
|
|
) {
|
|
adjusted = orphanAdjusted
|
|
}
|
|
|
|
return adjusted
|
|
}
|
|
|
|
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)
|
|
|
|
static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
|
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
|
return []
|
|
}
|
|
return lines.map { $0.stringRange() }
|
|
}
|
|
#endif
|
|
|
|
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)) }
|
|
}
|
|
|
|
private func trimmedRangeAvoidingWidow(
|
|
proposed: NSRange,
|
|
lineRanges: [NSRange]
|
|
) -> NSRange? {
|
|
guard lineRanges.count >= 2,
|
|
let lastLine = lineRanges.last else {
|
|
return nil
|
|
}
|
|
|
|
let paragraphRange = paragraphRange(containing: lastLine.location)
|
|
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
|
|
let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange)
|
|
|
|
guard trailingLines == 1, paragraphContinuesOnNextPage else {
|
|
return nil
|
|
}
|
|
|
|
let keptLine = lineRanges[lineRanges.count - 2]
|
|
let adjustedLength = NSMaxRange(keptLine) - proposed.location
|
|
guard adjustedLength > 0 else { return nil }
|
|
|
|
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
|
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: NSMaxRange(keptLine), length: min(proposed.length - adjustedLength, 40)))
|
|
print("[PAGINATION-DEBUG] widow control removed 1 line: \"\(removedText)\" paragraphRange=\(NSStringFromRange(paragraphRange))")
|
|
}
|
|
|
|
return NSRange(location: proposed.location, length: adjustedLength)
|
|
}
|
|
|
|
private func trimmedRangeAvoidingOrphan(
|
|
proposed: NSRange,
|
|
lineRanges: [NSRange]
|
|
) -> NSRange? {
|
|
guard lineRanges.count >= 2,
|
|
let lastLine = lineRanges.last else {
|
|
return nil
|
|
}
|
|
|
|
let paragraphRange = paragraphRange(containing: lastLine.location)
|
|
let pageEnd = NSMaxRange(proposed)
|
|
let paragraphEnd = NSMaxRange(paragraphRange)
|
|
guard pageEnd < paragraphEnd else { return nil }
|
|
|
|
let remainingLineCount = estimatedRemainingLineCount(
|
|
in: paragraphRange,
|
|
startingAt: pageEnd
|
|
)
|
|
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
|
|
guard remainingLineCount == 1,
|
|
trailingLines >= 2 else {
|
|
return nil
|
|
}
|
|
|
|
let keptLine = lineRanges[lineRanges.count - 2]
|
|
let adjustedLength = NSMaxRange(keptLine) - proposed.location
|
|
guard adjustedLength > 0 else { return nil }
|
|
return NSRange(location: proposed.location, length: adjustedLength)
|
|
}
|
|
|
|
private func trailingLineCount(
|
|
in lineRanges: [NSRange],
|
|
paragraphRange: NSRange
|
|
) -> Int {
|
|
var count = 0
|
|
for lineRange in lineRanges.reversed() {
|
|
if NSIntersectionRange(lineRange, paragraphRange).length > 0 {
|
|
count += 1
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
private func estimatedRemainingLineCount(
|
|
in paragraphRange: NSRange,
|
|
startingAt location: Int
|
|
) -> Int {
|
|
let paragraphEnd = NSMaxRange(paragraphRange)
|
|
var currentLocation = max(location, paragraphRange.location)
|
|
guard currentLocation < paragraphEnd else { return 0 }
|
|
|
|
let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1)
|
|
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
|
|
var lineCount = 0
|
|
|
|
while currentLocation < paragraphEnd {
|
|
let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width))
|
|
let lineLength = max(suggestedCount, 1)
|
|
currentLocation += lineLength
|
|
lineCount += 1
|
|
if lineCount > 2 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return lineCount
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
func nearestTrailingFragmentID(
|
|
endingAt location: Int,
|
|
fragmentOffsets: [String: Int]
|
|
) -> String? {
|
|
fragmentOffsets
|
|
.filter { $0.value <= location }
|
|
.max { lhs, rhs in lhs.value < rhs.value }?
|
|
.key
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|