ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift

245 lines
9.5 KiB
Swift

import CoreText
import UIKit
struct RDEPUBTextLayouter {
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let framesetter: CTFramesetter
private let path: CGPath
init(attributedString: NSAttributedString, pageSize: CGSize) {
self.attributedString = attributedString
self.pageSize = pageSize
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
}
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
}
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
while location < attributedString.length {
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil)
let visibleRange = CTFrameGetVisibleStringRange(frame)
guard visibleRange.length > 0 else {
break
}
let proposedRange = NSRange(location: location, length: visibleRange.length)
let adjusted = adjustedRange(from: proposedRange, totalLength: attributedString.length)
let trailingFragmentID = 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,
trailingFragmentID: trailingFragmentID,
diagnostics: adjusted.diagnostics
)
)
let nextLocation = adjusted.range.location + adjusted.range.length
guard nextLocation > location else {
location += max(visibleRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
private func adjustedRange(
from proposedRange: NSRange,
totalLength: Int
) -> (
range: NSRange,
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange?,
attachmentRanges: [NSRange],
attachmentKinds: [RDEPUBTextAttachmentKind],
diagnostics: [String]
) {
let pageEnd = proposedRange.location + proposedRange.length
guard pageEnd < totalLength else {
return (
range: proposedRange,
breakReason: .chapterEnd,
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
attachmentRanges: attachmentRanges(in: proposedRange),
attachmentKinds: attachmentKinds(in: proposedRange),
diagnostics: diagnostics(
reason: .chapterEnd,
range: proposedRange,
attachmentRanges: attachmentRanges(in: proposedRange),
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1))
)
)
}
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
let minimumEnd = proposedRange.location + minLength
let currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
let currentAttachmentKinds = attachmentKinds(in: proposedRange)
if let attachmentBoundary = preferredAttachmentBoundary(
in: proposedRange,
minimumEnd: minimumEnd
) {
let adjustedRange = NSRange(location: proposedRange.location, length: attachmentBoundary - proposedRange.location)
return (
range: adjustedRange,
breakReason: .attachmentBoundary,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
diagnostics: diagnostics(
reason: .attachmentBoundary,
range: adjustedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange
)
)
}
if let blockBoundary = preferredBlockBoundary(
near: pageEnd,
lowerBound: minimumEnd
) {
let adjustedRange = NSRange(location: proposedRange.location, length: blockBoundary - proposedRange.location)
return (
range: adjustedRange,
breakReason: .blockBoundary,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
diagnostics: diagnostics(
reason: .blockBoundary,
range: adjustedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange
)
)
}
return (
range: proposedRange,
breakReason: .frameLimit,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
diagnostics: diagnostics(
reason: .frameLimit,
range: proposedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange
)
)
}
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
var boundary: Int?
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
guard value != nil else { return }
let paragraphRange = paragraphRange(containing: attributeRange.location)
if paragraphRange.location > range.location, paragraphRange.location >= minimumEnd {
boundary = paragraphRange.location
stop.pointee = true
}
}
return boundary
}
private func preferredBlockBoundary(near location: Int, lowerBound: Int) -> Int? {
var probe = max(lowerBound, 0)
let searchEnd = min(location, attributedString.length)
guard probe < searchEnd else { return nil }
var lastBoundary: Int?
while probe < searchEnd {
let block = blockRange(at: probe) ?? paragraphRange(containing: probe)
let candidate = block.location
if candidate > lowerBound, candidate < location {
lastBoundary = candidate
}
probe = max(block.location + max(block.length, 1), probe + 1)
}
return lastBoundary
}
private 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
}
private 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))
}
private func attachmentRanges(in range: NSRange) -> [NSRange] {
var results: [NSRange] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
guard value != nil else { return }
results.append(attributeRange)
}
return results
}
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
var kinds: [RDEPUBTextAttachmentKind] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
private func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
) -> String? {
fragmentOffsets
.filter { $0.value <= location }
.max { lhs, rhs in lhs.value < rhs.value }?
.key
}
private func diagnostics(
reason: RDEPUBTextPageBreakReason,
range: NSRange,
attachmentRanges: [NSRange],
blockRange: NSRange?
) -> [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: ","))")
}
return items
}
}