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
This commit is contained in:
+197
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user