ReadViewSDK/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00

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