ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift

283 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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 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
}
// 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
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
// MARK: - WXRead
/// CoreText frame path
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)
}
}
}