ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00

272 lines
11 KiB
Swift

import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
struct RDEPUBChapterPageCounter {
private let factory: RDEPUBCoreTextPageFrameFactory
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
private let framesetter: CTFramesetter
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
}
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 contentRect = config.contentRect(fallback: resolvedSize)
guard contentRect.width > 0, contentRect.height > 0 else {
return []
}
while location < attributedString.length {
let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
pageSize: resolvedSize,
config: config
)
let frame = CTFramesetterCreateFrame(
framesetter,
CFRangeMake(location, 0),
framePath,
nil
)
let proposedRange = proposedVisibleRange(
from: frame,
start: location,
totalLength: attributedString.length
)
guard proposedRange.length > 0 else {
break
}
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
proposed: keepWithNextAdjusted,
lineRanges: lineRanges
)
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
let adjusted = pageBreakPolicy.adjustedRange(
from: widowOrphanAdjusted,
totalLength: attributedString.length,
lineRanges: effectiveLineRanges,
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
}
#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
let isDebug = ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
if isDebug {
print("[PAGINATION-DEBUG] pageRect=\(pageRect) totalLength=\(attributedString.length)")
}
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 widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
proposed: lineAdjusted,
lineRanges: lineRanges
)
if isDebug {
let pageCount = frames.count + 1
let previewText = (attributedString.string as NSString).substring(with: NSRange(location: location, length: min(20, attributedString.length - location)))
let avoidRemoved = proposedRange.length - avoidAdjusted.length
let kwNextRemoved = avoidAdjusted.length - lineAdjusted.length
let widowRemoved = lineAdjusted.length - widowOrphanAdjusted.length
let totalRemoved = proposedRange.length - widowOrphanAdjusted.length
print("[PAGINATION-DEBUG] page#\(pageCount) loc=\(location) proposed=\(proposedRange.length) avoid(-\(avoidRemoved)) kwNext(-\(kwNextRemoved)) widowOrphan(-\(widowRemoved)) total(-\(totalRemoved)) lastLine=\"\(previewText)\"")
}
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
let adjusted = pageBreakPolicy.adjustedRange(
from: widowOrphanAdjusted,
totalLength: attributedString.length,
lineRanges: effectiveLineRanges,
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
private func proposedVisibleRange(
from frame: CTFrame,
start location: Int,
totalLength: Int
) -> NSRange {
let visibleRange = CTFrameGetVisibleStringRange(frame)
guard visibleRange.length > 0 else {
return NSRange(location: location, length: 0)
}
let resolvedLocation = max(location, visibleRange.location)
let visibleEnd = visibleRange.location + visibleRange.length
let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
guard length > 0 else {
return NSRange(location: resolvedLocation, length: 0)
}
return NSRange(location: resolvedLocation, length: length)
}
private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
lineRanges.filter { lineRange in
lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
}
}
}