- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip) - 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示 - 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配 - 新增 RDEPUBSelectionLoupeView 选区放大镜 - 新增 MetadataParseWorker/CancellationController 元数据解析取消机制 - 重构 PresentationRuntime/PaginationCoordinator 精简职责 - 优化 ChapterLoader/WarmupOrchestrator 异步章节加载 - CFI 模块微调与 NoteModels 更新 - 清理冗余文档,更新架构/UML/业务逻辑文档 Co-Authored-By: Claude <noreply@anthropic.com>
543 lines
21 KiB
Swift
543 lines
21 KiB
Swift
|
|
import Foundation
|
|
|
|
public struct RDEPUBRowColumnIndex: Codable, Equatable {
|
|
|
|
public let row: Int
|
|
|
|
public let startOffset: Int
|
|
|
|
public let endOffset: Int
|
|
|
|
public init(row: Int, startOffset: Int, endOffset: Int) {
|
|
self.row = row
|
|
self.startOffset = startOffset
|
|
self.endOffset = endOffset
|
|
}
|
|
|
|
public func contains(_ offset: Int) -> Bool {
|
|
offset >= startOffset && offset <= endOffset
|
|
}
|
|
}
|
|
|
|
public struct RDEPUBTextIndexTable {
|
|
|
|
public let chapterStartOffsets: [Int]
|
|
|
|
public let chapterLengths: [Int]
|
|
|
|
public let hrefToChapterIndex: [String: Int]
|
|
|
|
public let hrefToFileIndex: [String: Int]
|
|
|
|
public let fragmentOffsetsByHref: [String: [String: Int]]
|
|
|
|
public let fileIndexToHref: [Int: String]
|
|
|
|
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
|
|
|
|
public let fileTextMap: [Int: String]
|
|
|
|
public let fileCFIMap: [Int: RDEPUBCFIMap]
|
|
|
|
public var totalCharacterCount: Int {
|
|
guard let lastIndex = chapterStartOffsets.indices.last,
|
|
chapterLengths.indices.contains(lastIndex) else {
|
|
return 0
|
|
}
|
|
return chapterStartOffsets[lastIndex] + chapterLengths[lastIndex]
|
|
}
|
|
|
|
public init(chapters: [RDEPUBTextChapter]) {
|
|
var offsets: [Int] = []
|
|
var lengths: [Int] = []
|
|
var hrefMap: [String: Int] = [:]
|
|
var hrefToFileMap: [String: Int] = [:]
|
|
var fragmentMap: [String: [String: Int]] = [:]
|
|
var fileIndexMap: [Int: String] = [:]
|
|
var rowColumnMap: [Int: [RDEPUBRowColumnIndex]] = [:]
|
|
var textMap: [Int: String] = [:]
|
|
var cfiMap: [Int: RDEPUBCFIMap] = [:]
|
|
var running = 0
|
|
|
|
for (index, chapter) in chapters.enumerated() {
|
|
hrefMap[chapter.href] = index
|
|
hrefToFileMap[chapter.href] = chapter.spineIndex
|
|
offsets.append(running)
|
|
lengths.append(chapter.attributedContent.length)
|
|
running += chapter.attributedContent.length
|
|
fragmentMap[chapter.href] = chapter.fragmentOffsets
|
|
fileIndexMap[chapter.spineIndex] = chapter.href
|
|
let chapterText = chapter.attributedContent.string
|
|
rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapterText)
|
|
textMap[chapter.spineIndex] = chapterText
|
|
if let chapterCFIMap = chapter.cfiMap {
|
|
cfiMap[chapter.spineIndex] = chapterCFIMap
|
|
}
|
|
}
|
|
|
|
self.chapterStartOffsets = offsets
|
|
self.chapterLengths = lengths
|
|
self.hrefToChapterIndex = hrefMap
|
|
self.hrefToFileIndex = hrefToFileMap
|
|
self.fragmentOffsetsByHref = fragmentMap
|
|
self.fileIndexToHref = fileIndexMap
|
|
self.fileRowColumnMap = rowColumnMap
|
|
self.fileTextMap = textMap
|
|
self.fileCFIMap = cfiMap
|
|
}
|
|
|
|
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
|
|
let normalizedIndex = clampedOffset(index, in: chapter)
|
|
let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
|
|
let row = row(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
|
let column = column(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
|
return RDEPUBTextAnchor(
|
|
fileIndex: chapter.spineIndex,
|
|
row: row,
|
|
column: column,
|
|
chapterOffset: normalizedIndex,
|
|
fragmentID: fragmentID
|
|
)
|
|
}
|
|
|
|
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
|
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
|
|
let anchor = anchor(for: cfiRange.start) {
|
|
return anchor
|
|
}
|
|
|
|
if let anchor = location.rangeAnchor?.start {
|
|
return anchor
|
|
}
|
|
|
|
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
|
let anchor = anchor(for: cfi) {
|
|
return anchor
|
|
}
|
|
|
|
guard let chapterIndex = hrefToChapterIndex[location.href],
|
|
let fileIndex = hrefToFileIndex[location.href] else { return nil }
|
|
let fragments = fragmentOffsetsByHref[location.href] ?? [:]
|
|
let chapterOffset: Int
|
|
|
|
if let fragment = location.fragment, let fragmentOffset = fragments[fragment] {
|
|
chapterOffset = fragmentOffset
|
|
} else {
|
|
let estimatedLength = max(chapterLengths.indices.contains(chapterIndex) ? chapterLengths[chapterIndex] : 0, 1)
|
|
let lastOffset = max(estimatedLength - 1, 0)
|
|
chapterOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
|
|
}
|
|
|
|
return RDEPUBTextAnchor(
|
|
fileIndex: fileIndex,
|
|
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
|
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
|
chapterOffset: chapterOffset,
|
|
fragmentID: location.fragment
|
|
)
|
|
}
|
|
|
|
public func anchor(for cfi: RDEPUBCFI) -> RDEPUBTextAnchor? {
|
|
let resolved = RDEPUBCFIResolver.resolve(cfi)
|
|
let fileIndex: Int
|
|
if let resolvedFileIndex = resolved.fileIndex {
|
|
fileIndex = resolvedFileIndex
|
|
} else if let href = resolved.href, let mappedFileIndex = hrefToFileIndex[href] {
|
|
fileIndex = mappedFileIndex
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
guard let href = href(for: fileIndex) ?? resolved.href else { return nil }
|
|
let chapterLength = max(chapterLength(forFileIndex: fileIndex) ?? 0, 1)
|
|
let lastOffset = max(chapterLength - 1, 0)
|
|
let recovery = RDEPUBCFIRecoveryEngine.recover(
|
|
cfi: cfi,
|
|
cfiMap: fileCFIMap[fileIndex],
|
|
chapterText: fileTextMap[fileIndex],
|
|
fragmentOffsets: fragmentOffsetsByHref[href] ?? [:],
|
|
fallbackOffset: resolved.chapterOffset,
|
|
lastOffset: lastOffset
|
|
)
|
|
let chapterOffset = recovery?.chapterOffset ?? min(max(resolved.chapterOffset ?? 0, 0), lastOffset)
|
|
let fragmentID = resolved.fragmentID ?? nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
|
|
|
|
return RDEPUBTextAnchor(
|
|
fileIndex: fileIndex,
|
|
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
|
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
|
chapterOffset: chapterOffset,
|
|
fragmentID: fragmentID
|
|
)
|
|
}
|
|
|
|
public func rangeAnchor(for cfiRange: RDEPUBCFIRange) -> RDEPUBTextRangeAnchor? {
|
|
guard let start = anchor(for: cfiRange.start),
|
|
let end = anchor(for: cfiRange.end),
|
|
start.fileIndex == end.fileIndex else {
|
|
return nil
|
|
}
|
|
return RDEPUBTextRangeAnchor(start: start, end: end)
|
|
}
|
|
|
|
public func cfi(for anchor: RDEPUBTextAnchor) -> RDEPUBCFI? {
|
|
guard let href = href(for: anchor.fileIndex) else { return nil }
|
|
let chapterOffset = chapterOffset(for: anchor)
|
|
if let marker = preciseMarker(fileIndex: anchor.fileIndex, chapterOffset: chapterOffset) {
|
|
let localOffset = max(chapterOffset - (marker.chapterOffset ?? 0), 0)
|
|
return RDEPUBCFIGenerator.makeCFI(
|
|
href: href,
|
|
fileIndex: anchor.fileIndex,
|
|
contentPath: marker.cfiPath,
|
|
characterOffset: localOffset,
|
|
textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
|
|
)
|
|
}
|
|
return RDEPUBCFIGenerator.makeOffsetCFI(
|
|
href: href,
|
|
fileIndex: anchor.fileIndex,
|
|
chapterOffset: chapterOffset,
|
|
fragmentID: anchor.fragmentID,
|
|
textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
|
|
)
|
|
}
|
|
|
|
public func cfiRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> RDEPUBCFIRange? {
|
|
guard rangeAnchor.start.fileIndex == rangeAnchor.end.fileIndex,
|
|
let href = href(for: rangeAnchor.start.fileIndex) else {
|
|
return nil
|
|
}
|
|
let startOffset = chapterOffset(for: rangeAnchor.start)
|
|
let endOffset = chapterOffset(for: rangeAnchor.end)
|
|
let startCFI = cfi(for: rangeAnchor.start)
|
|
let endCFI = cfi(for: rangeAnchor.end)
|
|
if let startCFI, let endCFI {
|
|
let parent = RDEPUBCFI(
|
|
packagePath: startCFI.packagePath.commonPrefix(with: endCFI.packagePath),
|
|
contentPath: startCFI.contentPath.commonPrefix(with: endCFI.contentPath)
|
|
)
|
|
return RDEPUBCFIRange(parent: parent, start: startCFI, end: endCFI)
|
|
}
|
|
return RDEPUBCFIGenerator.makeOffsetRangeCFI(
|
|
href: href,
|
|
fileIndex: rangeAnchor.start.fileIndex,
|
|
startOffset: startOffset,
|
|
endOffset: endOffset,
|
|
fragmentID: rangeAnchor.start.fragmentID,
|
|
startTextAssertion: textAssertion(
|
|
fileIndex: rangeAnchor.start.fileIndex,
|
|
offset: startOffset
|
|
),
|
|
endTextAssertion: textAssertion(
|
|
fileIndex: rangeAnchor.end.fileIndex,
|
|
offset: endOffset
|
|
)
|
|
)
|
|
}
|
|
|
|
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
|
|
guard let fileIndex = fileIndex(forCharacterPosition: index),
|
|
let href = href(for: fileIndex) else {
|
|
return nil
|
|
}
|
|
|
|
let chapterOffset = localOffsetInFile(at: fileIndex, forGlobalPosition: index) ?? 0
|
|
let fragmentID = nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
|
|
return RDEPUBTextAnchor(
|
|
fileIndex: fileIndex,
|
|
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
|
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
|
chapterOffset: chapterOffset,
|
|
fragmentID: fragmentID
|
|
)
|
|
}
|
|
|
|
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
|
|
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
|
|
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
|
|
let resolvedOffset = chapterOffset(for: anchor)
|
|
return chapter.pages.firstIndex { page in
|
|
NSLocationInRange(resolvedOffset, page.contentRange)
|
|
}.map { $0 + basePageIndex }
|
|
}
|
|
|
|
public func href(for fileIndex: Int) -> String? {
|
|
fileIndexToHref[fileIndex]
|
|
}
|
|
|
|
public func chapterIndex(for href: String) -> Int? {
|
|
hrefToChapterIndex[href]
|
|
}
|
|
|
|
public func chapterStartOffset(forFileIndex fileIndex: Int) -> Int? {
|
|
guard let href = href(for: fileIndex),
|
|
let chapterIndex = hrefToChapterIndex[href],
|
|
chapterStartOffsets.indices.contains(chapterIndex) else {
|
|
return nil
|
|
}
|
|
return chapterStartOffsets[chapterIndex]
|
|
}
|
|
|
|
public func chapterLength(forFileIndex fileIndex: Int) -> Int? {
|
|
guard let href = href(for: fileIndex),
|
|
let chapterIndex = hrefToChapterIndex[href],
|
|
chapterLengths.indices.contains(chapterIndex) else {
|
|
return nil
|
|
}
|
|
return chapterLengths[chapterIndex]
|
|
}
|
|
|
|
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
|
anchor.chapterOffset
|
|
}
|
|
|
|
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
|
let chapterOffset = chapterOffset(for: anchor)
|
|
let baseOffset = chapterStartOffset(forFileIndex: anchor.fileIndex) ?? 0
|
|
let chapterLength = max(chapterLength(forFileIndex: anchor.fileIndex) ?? 0, 0)
|
|
let lastOffset = max(chapterLength - 1, 0)
|
|
return baseOffset + min(max(chapterOffset, 0), lastOffset)
|
|
}
|
|
|
|
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
|
globalIndex(for: anchor)
|
|
}
|
|
|
|
public func chapterRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
|
let start = chapterOffset(for: rangeAnchor.start)
|
|
let end = max(start, chapterOffset(for: rangeAnchor.end))
|
|
return NSRange(location: start, length: max(end - start, 0))
|
|
}
|
|
|
|
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
|
let start = globalIndex(for: rangeAnchor.start)
|
|
let end = max(start, globalIndex(for: rangeAnchor.end))
|
|
return NSRange(location: start, length: max(end - start, 0))
|
|
}
|
|
|
|
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
|
globalRange(for: rangeAnchor)
|
|
}
|
|
|
|
public func location(
|
|
for anchor: RDEPUBTextAnchor,
|
|
in chapter: RDEPUBTextChapter,
|
|
bookIdentifier: String?
|
|
) -> RDEPUBLocation {
|
|
let chapterOffset = self.chapterOffset(for: anchor)
|
|
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
|
let progression = Double(min(max(chapterOffset, 0), totalLength)) / Double(totalLength)
|
|
return RDEPUBLocation(
|
|
bookIdentifier: bookIdentifier,
|
|
href: chapter.href,
|
|
progression: progression,
|
|
lastProgression: progression,
|
|
fragment: anchor.fragmentID,
|
|
rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor),
|
|
cfi: cfi(for: anchor)?.rawValue,
|
|
lastCFI: cfi(for: anchor)?.rawValue
|
|
)
|
|
}
|
|
|
|
public func location(
|
|
for rangeAnchor: RDEPUBTextRangeAnchor,
|
|
in chapter: RDEPUBTextChapter,
|
|
bookIdentifier: String?
|
|
) -> RDEPUBLocation {
|
|
let start = chapterOffset(for: rangeAnchor.start)
|
|
let end = max(start, chapterOffset(for: rangeAnchor.end))
|
|
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
|
let clampedStart = min(max(start, 0), totalLength)
|
|
let clampedEnd = min(max(end, clampedStart), totalLength)
|
|
return RDEPUBLocation(
|
|
bookIdentifier: bookIdentifier,
|
|
href: chapter.href,
|
|
progression: Double(clampedStart) / Double(totalLength),
|
|
lastProgression: Double(clampedEnd) / Double(totalLength),
|
|
fragment: rangeAnchor.start.fragmentID,
|
|
rangeAnchor: rangeAnchor,
|
|
cfi: cfi(for: rangeAnchor.start)?.rawValue,
|
|
lastCFI: cfi(for: rangeAnchor.end)?.rawValue,
|
|
rangeCFI: cfiRange(for: rangeAnchor)?.rawValue
|
|
)
|
|
}
|
|
|
|
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
|
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
|
if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
|
|
return rowIndex
|
|
}
|
|
return rows.last?.row ?? 0
|
|
}
|
|
|
|
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
|
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
|
if let rowEntry = rows.first(where: { $0.contains(index) }) {
|
|
return max(index - rowEntry.startOffset, 0)
|
|
}
|
|
guard let lastRow = rows.last else { return 0 }
|
|
return max(index - lastRow.startOffset, 0)
|
|
}
|
|
|
|
public func chapterOffset(fileIndex: Int, row: Int, column: Int) -> Int? {
|
|
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
|
|
let normalizedRow = min(max(row, 0), rows.count - 1)
|
|
let rowEntry = rows[normalizedRow]
|
|
let maxColumn = max(rowEntry.endOffset - rowEntry.startOffset, 0)
|
|
return rowEntry.startOffset + min(max(column, 0), maxColumn)
|
|
}
|
|
|
|
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
|
guard let chapterOffset = chapterOffset(fileIndex: fileIndex, row: row, column: column),
|
|
let baseOffset = chapterStartOffset(forFileIndex: fileIndex) else {
|
|
return nil
|
|
}
|
|
return baseOffset + chapterOffset
|
|
}
|
|
|
|
public func fileIndex(forCharacterPosition index: Int) -> Int? {
|
|
guard totalCharacterCount > 0 else { return nil }
|
|
let normalized = min(max(index, 0), max(totalCharacterCount - 1, 0))
|
|
for (href, chapterIndex) in hrefToChapterIndex {
|
|
guard chapterStartOffsets.indices.contains(chapterIndex),
|
|
chapterLengths.indices.contains(chapterIndex),
|
|
let fileIndex = hrefToFileIndex[href] else {
|
|
continue
|
|
}
|
|
|
|
let start = chapterStartOffsets[chapterIndex]
|
|
let length = chapterLengths[chapterIndex]
|
|
let endExclusive = start + max(length, 1)
|
|
if normalized >= start && normalized < endExclusive {
|
|
return fileIndex
|
|
}
|
|
}
|
|
return fileIndexToHref.keys.sorted().last
|
|
}
|
|
|
|
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
|
|
guard let start = chapterStartOffset(forFileIndex: fileIndex),
|
|
let chapterLength = chapterLength(forFileIndex: fileIndex) else {
|
|
return nil
|
|
}
|
|
let normalized = min(max(index, start), start + max(chapterLength - 1, 0))
|
|
return normalized - start
|
|
}
|
|
|
|
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
|
|
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
|
return min(max(index, 0), lastOffset)
|
|
}
|
|
|
|
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
|
|
var bestID: String?
|
|
var bestOffset = -1
|
|
|
|
for (id, fragOffset) in chapter.fragmentOffsets {
|
|
if fragOffset <= offset && fragOffset > bestOffset {
|
|
bestOffset = fragOffset
|
|
bestID = id
|
|
}
|
|
}
|
|
|
|
return bestID
|
|
}
|
|
|
|
private func nearestFragmentID(beforeOrAt offset: Int, inHref href: String) -> String? {
|
|
guard let fragmentOffsets = fragmentOffsetsByHref[href] else {
|
|
return nil
|
|
}
|
|
|
|
var bestID: String?
|
|
var bestOffset = -1
|
|
for (id, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
|
|
bestOffset = fragmentOffset
|
|
bestID = id
|
|
}
|
|
return bestID
|
|
}
|
|
|
|
private func preciseMarker(fileIndex: Int, chapterOffset: Int) -> RDEPUBCFIMarker? {
|
|
guard let markers = fileCFIMap[fileIndex]?.markers else { return nil }
|
|
return markers
|
|
.filter {
|
|
guard let start = $0.chapterOffset,
|
|
let length = $0.textNodeLength,
|
|
length > 0 else {
|
|
return false
|
|
}
|
|
return chapterOffset >= start && chapterOffset < start + length
|
|
}
|
|
.max { lhs, rhs in
|
|
let leftStart = lhs.chapterOffset ?? 0
|
|
let rightStart = rhs.chapterOffset ?? 0
|
|
if leftStart != rightStart {
|
|
return leftStart < rightStart
|
|
}
|
|
return (lhs.textNodeLength ?? 0) > (rhs.textNodeLength ?? 0)
|
|
}
|
|
}
|
|
|
|
private func textAssertion(fileIndex: Int, offset: Int) -> RDEPUBCFITextAssertion? {
|
|
guard let text = fileTextMap[fileIndex], !text.isEmpty else { return nil }
|
|
let nsText = text as NSString
|
|
let length = nsText.length
|
|
guard length > 0 else { return nil }
|
|
|
|
let location = min(max(offset, 0), max(length - 1, 0))
|
|
let exactLength = min(16, max(length - location, 0))
|
|
guard exactLength > 0 else { return nil }
|
|
|
|
let prefixStart = max(location - 8, 0)
|
|
let suffixStart = min(location + exactLength, length)
|
|
return RDEPUBCFITextAssertion(
|
|
prefix: prefixStart < location
|
|
? nsText.substring(with: NSRange(location: prefixStart, length: location - prefixStart))
|
|
: nil,
|
|
exact: nsText.substring(with: NSRange(location: location, length: exactLength)),
|
|
suffix: suffixStart < length
|
|
? nsText.substring(with: NSRange(location: suffixStart, length: min(8, length - suffixStart)))
|
|
: nil
|
|
)
|
|
}
|
|
|
|
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
|
|
let nsText = text as NSString
|
|
let length = nsText.length
|
|
guard length > 0 else {
|
|
return [RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: 0)]
|
|
}
|
|
|
|
var rows: [RDEPUBRowColumnIndex] = []
|
|
var rowNumber = 0
|
|
var lineStart = 0
|
|
|
|
nsText.enumerateSubstrings(
|
|
in: NSRange(location: 0, length: length),
|
|
options: [.byLines, .substringNotRequired]
|
|
) { _, substringRange, enclosingRange, _ in
|
|
let startOffset = enclosingRange.location
|
|
let lineLength = max(substringRange.length, 0)
|
|
let endOffset = max(startOffset + max(lineLength - 1, 0), startOffset)
|
|
rows.append(
|
|
RDEPUBRowColumnIndex(
|
|
row: rowNumber,
|
|
startOffset: startOffset,
|
|
endOffset: min(endOffset, max(length - 1, 0))
|
|
)
|
|
)
|
|
rowNumber += 1
|
|
lineStart = enclosingRange.location + enclosingRange.length
|
|
}
|
|
|
|
if rows.isEmpty {
|
|
rows.append(RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: max(length - 1, 0)))
|
|
} else if lineStart == length, text.hasSuffix("\n") {
|
|
rows.append(RDEPUBRowColumnIndex(row: rowNumber, startOffset: length, endOffset: length))
|
|
}
|
|
|
|
return rows
|
|
}
|
|
}
|