chore: checkpoint current milestone work
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user