245 lines
9.5 KiB
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
|
|
}
|
|
}
|