- 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
198 lines
7.3 KiB
Swift
198 lines
7.3 KiB
Swift
import Foundation
|
|
|
|
struct RDEPUBChapterTailNormalizer {
|
|
|
|
func normalize(
|
|
_ frames: [RDEPUBTextLayoutFrame],
|
|
content: NSAttributedString,
|
|
href: String
|
|
) -> [RDEPUBTextLayoutFrame] {
|
|
|
|
guard frames.count > 1 else { return frames }
|
|
|
|
var normalized = frames
|
|
|
|
var compacted: [RDEPUBTextLayoutFrame] = []
|
|
compacted.reserveCapacity(normalized.count)
|
|
for frame in normalized {
|
|
if shouldDropWhitespaceOnlyFrame(frame, in: content) {
|
|
let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
|
|
|
|
if var previous = compacted.popLast() {
|
|
previous.diagnostics.append(note)
|
|
compacted.append(previous)
|
|
} else {
|
|
}
|
|
continue
|
|
}
|
|
compacted.append(frame)
|
|
}
|
|
normalized = compacted
|
|
|
|
while let lastFrame = normalized.last,
|
|
shouldDropWhitespaceOnlyFrame(lastFrame, in: content) {
|
|
normalized.removeLast()
|
|
let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))"
|
|
if var previousFrame = normalized.popLast() {
|
|
previousFrame.diagnostics.append(note)
|
|
normalized.append(previousFrame)
|
|
} else {
|
|
}
|
|
}
|
|
|
|
guard normalized.count > 1,
|
|
let lastFrame = normalized.last,
|
|
let previousFrame = normalized.dropLast().last,
|
|
shouldMergeShortTrailingFrame(lastFrame, previousFrame: previousFrame, in: content) else {
|
|
return normalized
|
|
}
|
|
|
|
let mergedFrame = mergeTrailingFrame(previousFrame, with: lastFrame)
|
|
normalized.removeLast(2)
|
|
normalized.append(mergedFrame)
|
|
return normalized
|
|
}
|
|
|
|
private func shouldDropWhitespaceOnlyFrame(
|
|
_ frame: RDEPUBTextLayoutFrame,
|
|
in content: NSAttributedString
|
|
) -> Bool {
|
|
guard frame.contentRange.length > 0,
|
|
attachmentCount(in: content, range: frame.contentRange) == 0 else {
|
|
return false
|
|
}
|
|
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
|
|
}
|
|
|
|
private func shouldMergeShortTrailingFrame(
|
|
_ trailingFrame: RDEPUBTextLayoutFrame,
|
|
previousFrame: RDEPUBTextLayoutFrame,
|
|
in content: NSAttributedString
|
|
) -> Bool {
|
|
|
|
guard trailingFrame.contentRange.length > 0,
|
|
NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
|
|
return false
|
|
}
|
|
|
|
let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange)
|
|
let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange)
|
|
|
|
guard visibleCount <= 2,
|
|
trailingFrame.contentRange.length <= 2,
|
|
visibleCount > 0 || trailingAttachmentCount > 0 else {
|
|
return false
|
|
}
|
|
|
|
guard !frameHasAvoidPageBreakInside(trailingFrame, in: content) else {
|
|
return false
|
|
}
|
|
|
|
let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
|
|
return previousVisibleCount >= max(visibleCount * 8, 12)
|
|
}
|
|
|
|
private func frameHasAvoidPageBreakInside(
|
|
_ frame: RDEPUBTextLayoutFrame,
|
|
in content: NSAttributedString
|
|
) -> Bool {
|
|
if frame.semanticHints.contains(.avoidPageBreakInside) {
|
|
return true
|
|
}
|
|
|
|
guard content.length > 0,
|
|
let safeRange = clampedRange(frame.contentRange, in: content),
|
|
safeRange.length > 0 else {
|
|
return false
|
|
}
|
|
|
|
var found = false
|
|
content.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, stop in
|
|
guard let rawValue = value as? String else { return }
|
|
let hints = rawValue
|
|
.split(separator: ",")
|
|
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
|
if hints.contains(.avoidPageBreakInside) {
|
|
found = true
|
|
stop.pointee = true
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
private func mergeTrailingFrame(
|
|
_ previousFrame: RDEPUBTextLayoutFrame,
|
|
with trailingFrame: RDEPUBTextLayoutFrame
|
|
) -> RDEPUBTextLayoutFrame {
|
|
|
|
let mergedRange = NSRange(
|
|
location: previousFrame.contentRange.location,
|
|
length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
|
|
)
|
|
|
|
return RDEPUBTextLayoutFrame(
|
|
contentRange: mergedRange,
|
|
breakReason: trailingFrame.breakReason,
|
|
blockRange: trailingFrame.blockRange ?? previousFrame.blockRange,
|
|
attachmentRanges: uniqueRanges(from: previousFrame.attachmentRanges + trailingFrame.attachmentRanges),
|
|
attachmentKinds: uniqueValues(from: previousFrame.attachmentKinds + trailingFrame.attachmentKinds),
|
|
blockKinds: uniqueValues(from: previousFrame.blockKinds + trailingFrame.blockKinds),
|
|
semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints),
|
|
attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements),
|
|
trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID,
|
|
|
|
diagnostics: previousFrame.diagnostics
|
|
+ trailingFrame.diagnostics
|
|
+ ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"]
|
|
)
|
|
}
|
|
|
|
private func visibleCharacterCount(
|
|
in content: NSAttributedString,
|
|
range: NSRange
|
|
) -> Int {
|
|
guard range.length > 0 else { return 0 }
|
|
let string = content.attributedSubstring(from: range).string
|
|
|
|
let filteredScalars = string.unicodeScalars.filter { scalar in
|
|
!CharacterSet.whitespacesAndNewlines.contains(scalar)
|
|
&& !CharacterSet.controlCharacters.contains(scalar)
|
|
}
|
|
return filteredScalars.count
|
|
}
|
|
|
|
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
|
|
guard content.length > 0, range.length > 0 else { return 0 }
|
|
var count = 0
|
|
content.enumerateAttribute(.attachment, in: range) { value, _, _ in
|
|
if value != nil {
|
|
count += 1
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
private func clampedRange(_ range: NSRange, in content: NSAttributedString) -> NSRange? {
|
|
guard range.location >= 0, range.length >= 0, range.location < content.length else {
|
|
return nil
|
|
}
|
|
return NSRange(location: range.location, length: min(range.length, content.length - range.location))
|
|
}
|
|
|
|
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
|
values.reduce(into: [T]()) { result, value in
|
|
if !result.contains(value) {
|
|
result.append(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
|
|
ranges.reduce(into: [NSRange]()) { result, value in
|
|
if !result.contains(value) {
|
|
result.append(value)
|
|
}
|
|
}
|
|
}
|
|
}
|