fix: CFI module — 11 issues from code review

Phase 1 (correctness):
- #1: rawValue changed from stored to computed property, eliminating stale cache risk
- #2: tokenSamples/textMarker unified to UTF-16 offsets, fixing emoji/CJK positioning
- #3: makeOffsetCFI now accepts optional contentPath parameter

Phase 2 (robustness):
- #4: Resolver uses fixed index access instead of last(where:) for manifest/spine steps
- #5: HTML comments and CDATA stripped before regex matching
- #6: Text assertion parser handles backslash escapes (\[ \] \)
- #7: Token matching uses prefix/suffix with length ratio constraints
- #8: makeOffsetRangeCFI validates startOffset <= endOffset

Phase 3 (code quality):
- #9: Shared internal nilIfEmpty extension in RDEPUBCFIUtilities.swift
- #10: RDEPUBCFIMap has markerByPath index dictionary for O(1) lookup
- #11: parseRange unified to use try (not try?) for parent parsing

Review fixes:
- Documented init(rawValue:) parameter is ignored (computed property)
- Fixed escape unescaping to handle \\ → \ correctly
- Lowered token minLength threshold from 4 to 2

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-06-18 13:25:17 +08:00
parent 6ed3fcb071
commit f50495ad91
15 changed files with 1959 additions and 49 deletions

View File

@ -0,0 +1,52 @@
import Foundation
public struct RDEPUBCFI: Codable, Equatable, Hashable {
public var packagePath: RDEPUBCFIPath
public var contentPath: RDEPUBCFIPath
public var characterOffset: Int?
public var sideBias: RDEPUBCFISideBias?
public var textAssertion: RDEPUBCFITextAssertion?
/// Always computed from components no stale cached value risk.
public var rawValue: String {
RDEPUBCFISerializer.serialize(self)
}
/// Initialize from components. The `rawValue` parameter is ignored rawValue is always
/// computed from the components. To parse a CFI string, use `RDEPUBCFIParser.parse(_:)`.
public init(
rawValue: String = "",
packagePath: RDEPUBCFIPath = RDEPUBCFIPath(),
contentPath: RDEPUBCFIPath = RDEPUBCFIPath(),
characterOffset: Int? = nil,
sideBias: RDEPUBCFISideBias? = nil,
textAssertion: RDEPUBCFITextAssertion? = nil
) {
self.packagePath = packagePath
self.contentPath = contentPath
self.characterOffset = characterOffset
self.sideBias = sideBias
self.textAssertion = textAssertion
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
let parsed = try RDEPUBCFIParser.parse(rawValue)
self.packagePath = parsed.packagePath
self.contentPath = parsed.contentPath
self.characterOffset = parsed.characterOffset
self.sideBias = parsed.sideBias
self.textAssertion = parsed.textAssertion
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(rawValue)
}
}
public enum RDEPUBCFISideBias: String, Codable, Equatable, Hashable {
case before = "b"
case after = "a"
}

View File

@ -0,0 +1,29 @@
import Foundation
public enum RDEPUBCFICompatibility {
public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI? {
try? RDEPUBCFIParser.parse(rawValue)
}
public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange? {
if let parsed = try? RDEPUBCFIParser.parseRange(rawValue) {
return parsed
}
guard let rawValue,
!rawValue.isEmpty else {
return nil
}
let separators = ["..", "-"]
let separator = separators.first(where: { rawValue.contains($0) })
guard let separator else { return nil }
let parts = rawValue.components(separatedBy: separator)
guard parts.count == 2,
let start = parseLossy(parts[0]),
let end = parseLossy(parts[1]) else {
return nil
}
return RDEPUBCFIRange(parent: nil, start: start, end: end)
}
}

View File

@ -0,0 +1,575 @@
import Foundation
public enum RDEPUBCFIDOMPathBuilder {
public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath] {
// Pre-strip HTML comments and CDATA to avoid false tag matches inside them
let cleaned = stripCommentsAndCDATA(html)
guard let regex = try? NSRegularExpression(
pattern: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
options: [.caseInsensitive]
) else {
return [:]
}
let nsHTML = cleaned as NSString
var stack: [RDEPUBCFIStep] = []
var childCountsByDepth: [Int: Int] = [:]
var paths: [String: RDEPUBCFIPath] = [:]
for match in regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)) {
let rawTag = nsHTML.substring(with: match.range)
guard match.numberOfRanges > 2 else { continue }
let tagName = nsHTML.substring(with: match.range(at: 1)).lowercased()
guard !isIgnorableTag(tagName) else { continue }
if rawTag.hasPrefix("</") {
if tagName == "html" {
continue
}
if !stack.isEmpty {
stack.removeLast()
childCountsByDepth[stack.count + 1] = nil
}
continue
}
if tagName == "html" {
childCountsByDepth[0] = nil
continue
}
let attributes = nsHTML.substring(with: match.range(at: 2))
let depth = stack.count
let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1
childCountsByDepth[depth] = nextChildIndex
let fragmentID = idAttribute(in: attributes)
let step = RDEPUBCFIStep(index: nextChildIndex * 2, idAssertion: fragmentID)
let currentPath = RDEPUBCFIPath(steps: stack + [step])
if let fragmentID, paths[fragmentID] == nil {
paths[fragmentID] = currentPath
}
if !rawTag.hasSuffix("/>"), !isVoidTag(tagName) {
stack.append(step)
childCountsByDepth[stack.count] = nil
}
}
return paths
}
static func idAttribute(in attributes: String) -> String? {
guard let regex = try? NSRegularExpression(
pattern: #"(?:^|\s)(?:id|xml:id)\s*=\s*(['"])(.*?)\1"#,
options: [.caseInsensitive]
) else {
return nil
}
let nsAttributes = attributes as NSString
guard let match = regex.firstMatch(
in: attributes,
range: NSRange(location: 0, length: nsAttributes.length)
),
match.numberOfRanges > 2 else {
return nil
}
return nsAttributes.substring(with: match.range(at: 2)).nilIfEmpty
}
static func isIgnorableTag(_ tagName: String) -> Bool {
tagName == "!doctype" || tagName.hasPrefix("?")
}
/// Strip HTML comments `<!--...-->` and CDATA sections `<![CDATA[...]]>` from HTML
/// to prevent the tag regex from matching false tags inside them.
static func stripCommentsAndCDATA(_ html: String) -> String {
var result = html
// Strip comments: <!--...-->
if let commentRegex = try? NSRegularExpression(pattern: #"<!--[\s\S]*?-->"#, options: []) {
let nsResult = result as NSString
let matches = commentRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length))
for match in matches.reversed() {
result = nsResult.replacingCharacters(in: match.range, with: "")
}
}
// Strip CDATA: <![CDATA[...]]>
if let cdataRegex = try? NSRegularExpression(pattern: #"<!\[CDATA\[[\s\S]*?\]\]>"#, options: []) {
let nsResult = result as NSString
let matches = cdataRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length))
for match in matches.reversed() {
result = nsResult.replacingCharacters(in: match.range, with: "")
}
}
return result
}
static func isVoidTag(_ tagName: String) -> Bool {
[
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr"
].contains(tagName)
}
}
enum RDEPUBCFITextNodeMapBuilder {
static func normalizedText(from source: String) -> String {
RDEPUBNormalizedTextIndex.normalize(source)
}
static func makeMap(
href: String,
rawHTML: String,
chapterText: String,
fragmentOffsets: [String: Int]
) -> RDEPUBCFIMap {
let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText)
let fragmentPaths = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
let markers = makeMarkers(
href: href,
rawHTML: rawHTML,
chapterText: chapterText,
fragmentOffsets: fragmentOffsets
)
let pathRanges = makePathRanges(from: markers)
return RDEPUBCFIMap(
href: href,
markers: markers,
pathRanges: pathRanges,
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
domFingerprint: rawHTML.sha256Hex,
normalizedTextChecksum: normalizedChapter.normalizedText.sha256Hex,
tokenIndex: makeTokenAnchors(
normalizedChapter: normalizedChapter,
markers: markers,
pathRanges: pathRanges
),
fragmentPathMap: fragmentPaths
)
)
}
static func makeMarkers(
href: String,
rawHTML: String,
chapterText: String,
fragmentOffsets: [String: Int]
) -> [RDEPUBCFIMarker] {
let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText)
guard !normalizedChapter.normalizedText.isEmpty else {
return fragmentMarkers(fragmentOffsets: fragmentOffsets)
}
// Use comment/CDATA-stripped HTML for both path building and tag matching
let cleanedHTML = stripCommentsAndCDATA(rawHTML)
let fragments = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
var markers: [RDEPUBCFIMarker] = fragmentMarkers(fragmentOffsets: fragmentOffsets, paths: fragments)
var searchLocation = 0
var stack: [RDEPUBCFIStep] = []
var childCountsByDepth: [Int: Int] = [:]
var lastTagEnd = cleanedHTML.startIndex
for tagMatch in RDEPUBHTMLTagMatch.matches(in: cleanedHTML) {
let textChunk = String(cleanedHTML[lastTagEnd..<tagMatch.range.lowerBound])
if let marker = textMarker(
for: textChunk,
stack: stack,
childCountsByDepth: &childCountsByDepth,
normalizedChapter: normalizedChapter,
searchLocation: &searchLocation
) {
markers.append(marker)
}
processTag(
tagMatch,
stack: &stack,
childCountsByDepth: &childCountsByDepth
)
lastTagEnd = tagMatch.range.upperBound
}
if lastTagEnd < cleanedHTML.endIndex {
let trailingText = String(cleanedHTML[lastTagEnd..<cleanedHTML.endIndex])
if let marker = textMarker(
for: trailingText,
stack: stack,
childCountsByDepth: &childCountsByDepth,
normalizedChapter: normalizedChapter,
searchLocation: &searchLocation
) {
markers.append(marker)
}
}
return markers
.filter { $0.chapterOffset != nil }
.sorted { lhs, rhs in
let leftOffset = lhs.chapterOffset ?? Int.max
let rightOffset = rhs.chapterOffset ?? Int.max
if leftOffset != rightOffset {
return leftOffset < rightOffset
}
return lhs.cfiPath.steps.count < rhs.cfiPath.steps.count
}
}
private static func fragmentMarkers(
fragmentOffsets: [String: Int],
paths: [String: RDEPUBCFIPath] = [:]
) -> [RDEPUBCFIMarker] {
fragmentOffsets
.sorted { $0.value < $1.value }
.map { fragmentID, offset in
RDEPUBCFIMarker(
cfiPath: paths[fragmentID] ?? RDEPUBCFIPath(),
chapterOffset: offset,
fragmentID: fragmentID
)
}
}
private static func textMarker(
for rawText: String,
stack: [RDEPUBCFIStep],
childCountsByDepth: inout [Int: Int],
normalizedChapter: RDEPUBNormalizedTextIndex,
searchLocation: inout Int
) -> RDEPUBCFIMarker? {
let normalizedNodeText = RDEPUBNormalizedTextIndex.normalize(rawText)
guard !normalizedNodeText.isEmpty else { return nil }
let depth = stack.count
let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1
childCountsByDepth[depth] = nextChildIndex
let textStep = RDEPUBCFIStep(index: nextChildIndex * 2 - 1)
let path = RDEPUBCFIPath(steps: stack + [textStep])
// Search using UTF-16 offsets to match normalizedToChapterOffsets indices
let nsNormalized = normalizedChapter.normalizedText as NSString
let searchRange = NSRange(location: searchLocation, length: nsNormalized.length - searchLocation)
guard let foundRange = nsNormalized.range(of: normalizedNodeText, options: [], range: searchRange).toRange(),
foundRange.lowerBound >= searchLocation else {
return nil
}
searchLocation = foundRange.upperBound
let normalizedOffset = foundRange.lowerBound
guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: normalizedOffset) else {
return nil
}
return RDEPUBCFIMarker(
cfiPath: path,
chapterOffset: chapterOffset,
textNodeLength: normalizedNodeText.utf16.count,
textNodeChecksum: normalizedNodeText.fnv1a64,
normalizedTextPreview: previewText(for: normalizedNodeText),
domSiblingSignature: siblingSignature(stack: stack, childIndex: nextChildIndex)
)
}
private static func processTag(
_ match: RDEPUBHTMLTagMatch,
stack: inout [RDEPUBCFIStep],
childCountsByDepth: inout [Int: Int]
) {
let tagName = match.name.lowercased()
guard !RDEPUBCFIDOMPathBuilder.isIgnorableTag(tagName) else { return }
if match.isClosing {
if tagName == "html" {
return
}
if !stack.isEmpty {
stack.removeLast()
childCountsByDepth[stack.count + 1] = nil
}
return
}
if tagName == "html" {
childCountsByDepth[0] = nil
return
}
let depth = stack.count
let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1
childCountsByDepth[depth] = nextChildIndex
let fragmentID = RDEPUBCFIDOMPathBuilder.idAttribute(in: match.attributes)
let step = RDEPUBCFIStep(index: nextChildIndex * 2, idAssertion: fragmentID)
if !match.isSelfClosing && !RDEPUBCFIDOMPathBuilder.isVoidTag(tagName) {
stack.append(step)
childCountsByDepth[stack.count] = nil
}
}
private static func makePathRanges(from markers: [RDEPUBCFIMarker]) -> [RDEPUBCFIPathRange] {
markers.compactMap { marker in
guard let startOffset = marker.chapterOffset,
let textNodeLength = marker.textNodeLength,
textNodeLength > 0 else {
return nil
}
return RDEPUBCFIPathRange(
cfiPath: marker.cfiPath,
startOffset: startOffset,
endOffset: startOffset + max(textNodeLength - 1, 0),
textNodeLength: textNodeLength
)
}
}
private static func makeTokenAnchors(
normalizedChapter: RDEPUBNormalizedTextIndex,
markers: [RDEPUBCFIMarker],
pathRanges: [RDEPUBCFIPathRange]
) -> [RDEPUBCFITokenAnchor] {
guard !normalizedChapter.normalizedText.isEmpty else { return [] }
let sortedRanges = pathRanges.sorted { $0.startOffset < $1.startOffset }
var occurrenceByToken: [String: Int] = [:]
var anchors: [RDEPUBCFITokenAnchor] = []
let samples = normalizedChapter.tokenSamples(maxSamples: 64, stride: 96, window: 12)
for sample in samples {
guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: sample.offset),
let path = path(for: chapterOffset, ranges: sortedRanges, markers: markers) else {
continue
}
let occurrence = (occurrenceByToken[sample.token] ?? 0) + 1
occurrenceByToken[sample.token] = occurrence
anchors.append(
RDEPUBCFITokenAnchor(
token: sample.token,
occurrence: occurrence,
chapterOffset: chapterOffset,
cfiPath: path
)
)
}
return anchors
}
private static func path(
for chapterOffset: Int,
ranges: [RDEPUBCFIPathRange],
markers: [RDEPUBCFIMarker]
) -> RDEPUBCFIPath? {
if let range = ranges.first(where: { chapterOffset >= $0.startOffset && chapterOffset <= $0.endOffset }) {
return range.cfiPath
}
return markers
.filter { $0.chapterOffset != nil }
.min(by: {
abs(($0.chapterOffset ?? 0) - chapterOffset) < abs(($1.chapterOffset ?? 0) - chapterOffset)
})?
.cfiPath
}
private static func previewText(for normalizedText: String, limit: Int = 24) -> String {
String(normalizedText.prefix(limit))
}
private static func siblingSignature(stack: [RDEPUBCFIStep], childIndex: Int) -> String {
let parent = stack.map { String($0.index) }.joined(separator: "/")
return "\(parent)#\(childIndex)"
}
}
private struct RDEPUBHTMLTagMatch {
let range: Range<String.Index>
let name: String
let attributes: String
let isClosing: Bool
let isSelfClosing: Bool
static func matches(in html: String) -> [RDEPUBHTMLTagMatch] {
let cleaned = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(html)
guard let regex = try? NSRegularExpression(
pattern: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
options: [.caseInsensitive]
) else {
return []
}
let nsHTML = cleaned as NSString
return regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in
guard match.numberOfRanges > 2,
let range = Range(match.range, in: cleaned),
let nameRange = Range(match.range(at: 1), in: cleaned),
let attributesRange = Range(match.range(at: 2), in: cleaned) else {
return nil
}
let rawTag = String(cleaned[range])
return RDEPUBHTMLTagMatch(
range: range,
name: String(cleaned[nameRange]),
attributes: String(cleaned[attributesRange]),
isClosing: rawTag.hasPrefix("</"),
isSelfClosing: rawTag.hasSuffix("/>")
)
}
}
}
private struct RDEPUBNormalizedTextIndex {
let normalizedText: String
private let normalizedToChapterOffsets: [Int]
init(source: String) {
var text = ""
var offsets: [Int] = []
let nsSource = source as NSString
var lastWasWhitespace = false
for index in 0..<nsSource.length {
let scalar = nsSource.substring(with: NSRange(location: index, length: 1))
let normalizedScalar = Self.normalizeCharacter(scalar)
if normalizedScalar == " " {
if lastWasWhitespace {
continue
}
lastWasWhitespace = true
} else {
lastWasWhitespace = false
}
text.append(normalizedScalar)
offsets.append(index)
}
self.normalizedText = text
self.normalizedToChapterOffsets = offsets
}
func chapterOffset(forNormalizedOffset offset: Int) -> Int? {
guard normalizedToChapterOffsets.indices.contains(offset) else { return nil }
return normalizedToChapterOffsets[offset]
}
func tokenSamples(maxSamples: Int, stride: Int, window: Int) -> [(token: String, offset: Int)] {
guard !normalizedText.isEmpty, stride > 0, window > 0 else { return [] }
// Use NSString-based (UTF-16) iteration to match normalizedToChapterOffsets indices
let nsText = normalizedText as NSString
let length = nsText.length
var samples: [(token: String, offset: Int)] = []
var index = 0
while index < length, samples.count < maxSamples {
let end = min(index + window, length)
let token = nsText.substring(with: NSRange(location: index, length: end - index))
.trimmingCharacters(in: .whitespacesAndNewlines)
if token.utf16.count >= min(window, 4) {
samples.append((token, index))
}
index += stride
}
if samples.isEmpty {
let token = nsText.substring(with: NSRange(location: 0, length: min(window, length)))
.trimmingCharacters(in: .whitespacesAndNewlines)
if !token.isEmpty {
samples.append((token, 0))
}
}
return samples
}
static func normalize(_ source: String) -> String {
var result = ""
var lastWasWhitespace = false
let decodedSource = RDEPUBHTMLEntityDecoder.decodeEntities(in: source)
let nsSource = decodedSource as NSString
for index in 0..<nsSource.length {
let scalar = nsSource.substring(with: NSRange(location: index, length: 1))
let normalizedScalar = normalizeCharacter(scalar)
if normalizedScalar == " " {
if lastWasWhitespace {
continue
}
lastWasWhitespace = true
} else {
lastWasWhitespace = false
}
result.append(normalizedScalar)
}
return result
}
private static func normalizeCharacter(_ scalar: String) -> Character {
if scalar == "\u{00A0}" {
return " "
}
if scalar.unicodeScalars.allSatisfy(\.properties.isWhitespace) {
return " "
}
return scalar.first ?? " "
}
}
private extension String {
var fnv1a64: UInt64 {
let offsetBasis: UInt64 = 14695981039346656037
let prime: UInt64 = 1099511628211
return utf8.reduce(offsetBasis) { partialResult, byte in
(partialResult ^ UInt64(byte)) &* prime
}
}
}
private enum RDEPUBHTMLEntityDecoder {
private static let namedEntities: [String: String] = [
"&nbsp;": " ",
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&apos;": "'"
]
static func decode(_ value: String) -> String {
if let mapped = namedEntities[value] {
return mapped
}
guard value.hasPrefix("&"), value.hasSuffix(";") else {
return value
}
let body = String(value.dropFirst().dropLast())
if body.hasPrefix("#x") || body.hasPrefix("#X"),
let scalar = UInt32(body.dropFirst(2), radix: 16).flatMap(UnicodeScalar.init) {
return String(Character(scalar))
}
if body.hasPrefix("#"),
let scalar = UInt32(body.dropFirst(), radix: 10).flatMap(UnicodeScalar.init) {
return String(Character(scalar))
}
return value
}
static func decodeEntities(in value: String) -> String {
guard value.contains("&"),
let regex = try? NSRegularExpression(pattern: #"&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);"#) else {
return value
}
let nsValue = value as NSString
var decoded = value
for match in regex.matches(in: value, range: NSRange(location: 0, length: nsValue.length)).reversed() {
let entity = nsValue.substring(with: match.range)
let replacement = decode(entity)
if let range = Range(match.range, in: decoded) {
decoded.replaceSubrange(range, with: replacement)
}
}
return decoded
}
}

View File

@ -0,0 +1,8 @@
import Foundation
public enum RDEPUBCFIError: Error, Equatable {
case empty
case invalidWrapper(String)
case invalidPath(String)
case unsupportedRange(String)
}

View File

@ -0,0 +1,87 @@
import Foundation
public enum RDEPUBCFIGenerator {
public static func makeOffsetCFI(
href: String,
fileIndex: Int,
chapterOffset: Int,
fragmentID: String? = nil,
sideBias: RDEPUBCFISideBias? = nil,
textAssertion: RDEPUBCFITextAssertion? = nil,
contentPath: RDEPUBCFIPath? = nil
) -> RDEPUBCFI {
let packagePath = RDEPUBCFIPath(steps: [
RDEPUBCFIStep(index: 6),
RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href)
])
let resolvedContentPath = contentPath ?? RDEPUBCFIPath(steps: [
RDEPUBCFIStep(index: 4),
RDEPUBCFIStep(index: 2, idAssertion: fragmentID)
])
return RDEPUBCFI(
packagePath: packagePath,
contentPath: resolvedContentPath,
characterOffset: max(chapterOffset, 0),
sideBias: sideBias,
textAssertion: textAssertion
)
}
public static func makeCFI(
href: String,
fileIndex: Int,
contentPath: RDEPUBCFIPath,
characterOffset: Int,
sideBias: RDEPUBCFISideBias? = nil,
textAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFI {
let packagePath = RDEPUBCFIPath(steps: [
RDEPUBCFIStep(index: 6),
RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href)
])
return RDEPUBCFI(
packagePath: packagePath,
contentPath: contentPath,
characterOffset: max(characterOffset, 0),
sideBias: sideBias,
textAssertion: textAssertion
)
}
public static func makeOffsetRangeCFI(
href: String,
fileIndex: Int,
startOffset: Int,
endOffset: Int,
fragmentID: String? = nil,
startTextAssertion: RDEPUBCFITextAssertion? = nil,
endTextAssertion: RDEPUBCFITextAssertion? = nil,
contentPath: RDEPUBCFIPath? = nil
) -> RDEPUBCFIRange {
precondition(startOffset <= endOffset, "startOffset (\(startOffset)) must be <= endOffset (\(endOffset))")
let start = makeOffsetCFI(
href: href,
fileIndex: fileIndex,
chapterOffset: startOffset,
fragmentID: fragmentID,
sideBias: .before,
textAssertion: startTextAssertion,
contentPath: contentPath
)
let end = makeOffsetCFI(
href: href,
fileIndex: fileIndex,
chapterOffset: endOffset,
fragmentID: fragmentID,
sideBias: .after,
textAssertion: endTextAssertion,
contentPath: contentPath
)
let parent = RDEPUBCFI(
packagePath: start.packagePath.commonPrefix(with: end.packagePath),
contentPath: start.contentPath.commonPrefix(with: end.contentPath)
)
return RDEPUBCFIRange(parent: parent, start: start, end: end)
}
}

View File

@ -0,0 +1,182 @@
import Foundation
public struct RDEPUBCFIMap: Codable, Equatable {
public var href: String
public var renderVersion: Int
public var domVersion: Int
public var markers: [RDEPUBCFIMarker]
public var textAssertions: [String: RDEPUBCFITextAssertion]
public var pathRanges: [RDEPUBCFIPathRange]
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata
/// Lazy O(1) lookup index for markers by cfiPath
private var markerByPath: [RDEPUBCFIPath: Int]?
public init(
href: String,
renderVersion: Int = 1,
domVersion: Int = 1,
markers: [RDEPUBCFIMarker] = [],
textAssertions: [String: RDEPUBCFITextAssertion] = [:],
pathRanges: [RDEPUBCFIPathRange] = [],
recoveryMetadata: RDEPUBCFIRecoveryMetadata = .empty
) {
self.href = href
self.renderVersion = renderVersion
self.domVersion = domVersion
self.markers = markers
self.textAssertions = textAssertions
self.pathRanges = pathRanges
self.recoveryMetadata = recoveryMetadata
}
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker? {
if let index = markerByPath?[path] {
return markers[index]
}
return markers.first { $0.cfiPath == path }
}
/// Build or rebuild the marker lookup index. Call after mutating `markers`.
public mutating func rebuildMarkerIndex() {
markerByPath = Dictionary(
markers.enumerated().map { ($0.element.cfiPath, $0.offset) },
uniquingKeysWith: { _, last in last }
)
}
enum CodingKeys: String, CodingKey {
case href
case renderVersion
case domVersion
case markers
case textAssertions
case pathRanges
case recoveryMetadata
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
href = try container.decode(String.self, forKey: .href)
renderVersion = try container.decodeIfPresent(Int.self, forKey: .renderVersion) ?? 1
domVersion = try container.decodeIfPresent(Int.self, forKey: .domVersion) ?? 1
markers = try container.decodeIfPresent([RDEPUBCFIMarker].self, forKey: .markers) ?? []
textAssertions = try container.decodeIfPresent([String: RDEPUBCFITextAssertion].self, forKey: .textAssertions) ?? [:]
pathRanges = try container.decodeIfPresent([RDEPUBCFIPathRange].self, forKey: .pathRanges) ?? []
recoveryMetadata = try container.decodeIfPresent(RDEPUBCFIRecoveryMetadata.self, forKey: .recoveryMetadata) ?? .empty
markerByPath = nil
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(href, forKey: .href)
try container.encode(renderVersion, forKey: .renderVersion)
try container.encode(domVersion, forKey: .domVersion)
try container.encode(markers, forKey: .markers)
try container.encode(textAssertions, forKey: .textAssertions)
try container.encode(pathRanges, forKey: .pathRanges)
try container.encode(recoveryMetadata, forKey: .recoveryMetadata)
}
public static func == (lhs: RDEPUBCFIMap, rhs: RDEPUBCFIMap) -> Bool {
lhs.href == rhs.href
&& lhs.renderVersion == rhs.renderVersion
&& lhs.domVersion == rhs.domVersion
&& lhs.markers == rhs.markers
&& lhs.textAssertions == rhs.textAssertions
&& lhs.pathRanges == rhs.pathRanges
&& lhs.recoveryMetadata == rhs.recoveryMetadata
}
}
public struct RDEPUBCFIMarker: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath
public var chapterOffset: Int?
public var fragmentID: String?
public var textNodeLength: Int?
public var textNodeChecksum: UInt64?
public var normalizedTextPreview: String?
public var domSiblingSignature: String?
public init(
cfiPath: RDEPUBCFIPath,
chapterOffset: Int? = nil,
fragmentID: String? = nil,
textNodeLength: Int? = nil,
textNodeChecksum: UInt64? = nil,
normalizedTextPreview: String? = nil,
domSiblingSignature: String? = nil
) {
self.cfiPath = cfiPath
self.chapterOffset = chapterOffset
self.fragmentID = fragmentID
self.textNodeLength = textNodeLength
self.textNodeChecksum = textNodeChecksum
self.normalizedTextPreview = normalizedTextPreview
self.domSiblingSignature = domSiblingSignature
}
}
public struct RDEPUBCFIPathRange: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath
public var startOffset: Int
public var endOffset: Int
public var textNodeLength: Int
public init(
cfiPath: RDEPUBCFIPath,
startOffset: Int,
endOffset: Int,
textNodeLength: Int
) {
self.cfiPath = cfiPath
self.startOffset = startOffset
self.endOffset = endOffset
self.textNodeLength = textNodeLength
}
}
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
public var domFingerprint: String
public var normalizedTextChecksum: String
public var tokenIndex: [RDEPUBCFITokenAnchor]
public var fragmentPathMap: [String: RDEPUBCFIPath]
public init(
domFingerprint: String,
normalizedTextChecksum: String,
tokenIndex: [RDEPUBCFITokenAnchor] = [],
fragmentPathMap: [String: RDEPUBCFIPath] = [:]
) {
self.domFingerprint = domFingerprint
self.normalizedTextChecksum = normalizedTextChecksum
self.tokenIndex = tokenIndex
self.fragmentPathMap = fragmentPathMap
}
public static let empty = RDEPUBCFIRecoveryMetadata(
domFingerprint: "",
normalizedTextChecksum: "",
tokenIndex: [],
fragmentPathMap: [:]
)
}
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
public var token: String
public var occurrence: Int
public var chapterOffset: Int
public var cfiPath: RDEPUBCFIPath
public init(
token: String,
occurrence: Int,
chapterOffset: Int,
cfiPath: RDEPUBCFIPath
) {
self.token = token
self.occurrence = occurrence
self.chapterOffset = chapterOffset
self.cfiPath = cfiPath
}
}

View File

@ -0,0 +1,271 @@
import Foundation
public enum RDEPUBCFIParser {
public static func parse(_ rawValue: String?) throws -> RDEPUBCFI {
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!rawValue.isEmpty else {
throw RDEPUBCFIError.empty
}
let body = try unwrap(rawValue)
let parts = body.split(separator: "!", maxSplits: 1, omittingEmptySubsequences: false)
let packagePath = try parsePath(String(parts.first ?? ""))
let contentBody = parts.count > 1 ? String(parts[1]) : ""
let parsedContent = try parsePathWithOffset(contentBody)
return RDEPUBCFI(
packagePath: packagePath,
contentPath: parsedContent.path,
characterOffset: parsedContent.characterOffset,
sideBias: parsedContent.sideBias,
textAssertion: parsedContent.textAssertion
)
}
public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange {
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!rawValue.isEmpty else {
throw RDEPUBCFIError.empty
}
let body = try unwrap(rawValue)
let rangeParts = body.split(separator: ",", omittingEmptySubsequences: false)
guard rangeParts.count == 3 else {
throw RDEPUBCFIError.unsupportedRange(rawValue)
}
let parentRaw = "epubcfi(\(rangeParts[0]))"
let startRaw = "epubcfi(\(rangeParts[0])\(rangeParts[1]))"
let endRaw = "epubcfi(\(rangeParts[0])\(rangeParts[2]))"
return RDEPUBCFIRange(
parent: try parse(parentRaw),
start: try parse(startRaw),
end: try parse(endRaw)
)
}
private static func unwrap(_ rawValue: String) throws -> String {
guard rawValue.hasPrefix("epubcfi("), rawValue.hasSuffix(")") else {
throw RDEPUBCFIError.invalidWrapper(rawValue)
}
return String(rawValue.dropFirst("epubcfi(".count).dropLast())
}
private static func parsePathWithOffset(_ body: String) throws -> (
path: RDEPUBCFIPath,
characterOffset: Int?,
sideBias: RDEPUBCFISideBias?,
textAssertion: RDEPUBCFITextAssertion?
) {
guard let offsetSeparator = firstIndexOutsideBrackets(of: ":", in: body) else {
return (try parsePath(body), nil, nil, nil)
}
let pathPart = String(body[..<offsetSeparator])
let offsetAndQualifiers = String(body[body.index(after: offsetSeparator)...])
let path = try parsePath(pathPart)
let offsetDigits = offsetAndQualifiers.prefix { $0.isNumber }
let offset = Int(offsetDigits)
let qualifierStart = offsetAndQualifiers.index(
offsetAndQualifiers.startIndex,
offsetBy: offsetDigits.count
)
let qualifiers = String(offsetAndQualifiers[qualifierStart...])
return (
path,
offset,
parseSideBias(from: qualifiers),
parseTextAssertion(from: qualifiers)
)
}
private static func parsePath(_ path: String) throws -> RDEPUBCFIPath {
guard path.isEmpty || path.hasPrefix("/") else {
throw RDEPUBCFIError.invalidPath(path)
}
let steps = path
.split(separator: "/", omittingEmptySubsequences: true)
.compactMap { parseStep(String($0)) }
return RDEPUBCFIPath(steps: steps)
}
private static func parseStep(_ rawStep: String) -> RDEPUBCFIStep? {
let indexPart = rawStep.split(separator: "[", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? rawStep
guard let index = Int(indexPart) else { return nil }
let idAssertion: String?
if let open = rawStep.firstIndex(of: "["),
let close = rawStep.lastIndex(of: "]"),
open < close {
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).nilIfEmpty
} else {
idAssertion = nil
}
return RDEPUBCFIStep(index: index, idAssertion: idAssertion)
}
private static func parseSideBias(from body: String) -> RDEPUBCFISideBias? {
guard let markerRange = body.range(of: ";s=") else { return nil }
let value = body[markerRange.upperBound...].prefix(1)
return RDEPUBCFISideBias(rawValue: String(value))
}
private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? {
// Scan for the opening bracket, respecting backslash escapes
guard let open = firstUnescapedIndex(of: "[", in: body) else { return nil }
// Find the matching closing bracket, respecting backslash escapes and nesting
var depth = 1
var current = body.index(after: open)
while current < body.endIndex && depth > 0 {
if body[current] == "\\" {
// Skip escaped character
current = body.index(after: current)
if current < body.endIndex {
current = body.index(after: current)
}
continue
}
if body[current] == "[" {
depth += 1
} else if body[current] == "]" {
depth -= 1
}
if depth > 0 {
current = body.index(after: current)
}
}
guard depth == 0, current <= body.endIndex else { return nil }
let close = current
// Extract content, processing escape sequences left-to-right
let raw = String(body[body.index(after: open)..<close])
let unescaped = unescapeCFIText(raw)
// Split by comma, but only commas that are not inside nested brackets
let components = splitAssertionComponents(unescaped)
guard components.count > 1 else {
return RDEPUBCFITextAssertion(exact: unescaped)
}
return RDEPUBCFITextAssertion(
prefix: components.first,
exact: components.count > 1 ? components[1] : nil,
suffix: components.count > 2 ? components[2] : nil
)
}
/// Find the first occurrence of a character not preceded by a backslash
private static func firstUnescapedIndex(of character: Character, in string: String) -> String.Index? {
var index = string.startIndex
while index < string.endIndex {
if string[index] == "\\" {
// Skip escaped character
index = string.index(after: index)
if index < string.endIndex {
index = string.index(after: index)
}
continue
}
if string[index] == character {
return index
}
index = string.index(after: index)
}
return nil
}
/// Split assertion text by commas, but only those at the top level (not inside brackets)
private static func splitAssertionComponents(_ text: String) -> [String] {
var components: [String] = []
var current = ""
var depth = 0
var index = text.startIndex
while index < text.endIndex {
let char = text[index]
if char == "\\" {
current.append(char)
index = text.index(after: index)
if index < text.endIndex {
current.append(text[index])
index = text.index(after: index)
}
continue
}
if char == "[" {
depth += 1
} else if char == "]" {
depth = max(depth - 1, 0)
} else if char == "," && depth == 0 {
components.append(current)
current = ""
index = text.index(after: index)
continue
}
current.append(char)
index = text.index(after: index)
}
components.append(current)
return components
}
/// Unescape CFI text assertion content: \[ [, \] ], \\ \
private static func unescapeCFIText(_ text: String) -> String {
var result = ""
var index = text.startIndex
while index < text.endIndex {
if text[index] == "\\" {
let nextIndex = text.index(after: index)
if nextIndex < text.endIndex {
let nextChar = text[nextIndex]
if nextChar == "[" || nextChar == "]" || nextChar == "\\" {
result.append(nextChar)
index = text.index(after: nextIndex)
continue
}
}
// Not a recognized escape keep the backslash
result.append(text[index])
} else {
result.append(text[index])
}
index = text.index(after: index)
}
return result
}
private static func firstIndexOutsideBrackets(of character: Character, in body: String) -> String.Index? {
var bracketDepth = 0
for index in body.indices {
let current = body[index]
if current == "[" {
bracketDepth += 1
} else if current == "]" {
bracketDepth = max(bracketDepth - 1, 0)
} else if current == character, bracketDepth == 0 {
return index
}
}
return nil
}
}

View File

@ -0,0 +1,39 @@
import Foundation
public struct RDEPUBCFIPath: Codable, Equatable, Hashable {
public var steps: [RDEPUBCFIStep]
public init(steps: [RDEPUBCFIStep] = []) {
self.steps = steps
}
public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath {
var prefix: [RDEPUBCFIStep] = []
let upperBound = min(steps.count, other.steps.count)
for index in 0..<upperBound {
guard steps[index] == other.steps[index] else { break }
prefix.append(steps[index])
}
return RDEPUBCFIPath(steps: prefix)
}
public func droppingPrefix(_ prefix: RDEPUBCFIPath) -> RDEPUBCFIPath {
guard prefix.steps.count <= steps.count else { return self }
let candidate = Array(steps.prefix(prefix.steps.count))
guard candidate == prefix.steps else { return self }
return RDEPUBCFIPath(steps: Array(steps.dropFirst(prefix.steps.count)))
}
}
public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
public var index: Int
public var idAssertion: String?
public init(index: Int, idAssertion: String? = nil) {
self.index = index
self.idAssertion = idAssertion?.nilIfEmpty
}
}

View File

@ -0,0 +1,34 @@
import Foundation
public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
public var parent: RDEPUBCFI?
public var start: RDEPUBCFI
public var end: RDEPUBCFI
/// Always computed from components no stale cached value risk.
public var rawValue: String {
RDEPUBCFISerializer.serializeRange(self)
}
/// Initialize from components. The `rawValue` parameter is ignored rawValue is always
/// computed from the components. To parse a CFI range string, use `RDEPUBCFIParser.parseRange(_:)`.
public init(rawValue: String = "", parent: RDEPUBCFI? = nil, start: RDEPUBCFI, end: RDEPUBCFI) {
self.parent = parent
self.start = start
self.end = end
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
let parsed = try RDEPUBCFIParser.parseRange(rawValue)
self.parent = parsed.parent
self.start = parsed.start
self.end = parsed.end
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(rawValue)
}
}

View File

@ -0,0 +1,320 @@
import Foundation
public struct RDEPUBCFIRecoveryResult: Equatable {
public enum Confidence: Int, Codable {
case exactPath
case assertionCalibrated
case siblingRecovered
case tokenRecovered
case fragmentFallback
case offsetFallback
}
public var chapterOffset: Int
public var confidence: Confidence
public init(chapterOffset: Int, confidence: Confidence) {
self.chapterOffset = chapterOffset
self.confidence = confidence
}
}
enum RDEPUBCFIRecoveryEngine {
static func recover(
cfi: RDEPUBCFI,
cfiMap: RDEPUBCFIMap?,
chapterText: String?,
fragmentOffsets: [String: Int],
fallbackOffset: Int?,
lastOffset: Int
) -> RDEPUBCFIRecoveryResult? {
let resolved = RDEPUBCFIResolver.resolve(cfi)
if let exact = exactPathResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) {
return calibrateIfNeeded(
exact,
cfi: cfi,
chapterText: chapterText,
lastOffset: lastOffset
)
}
if let sibling = siblingRecoveredResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) {
return calibrateIfNeeded(
sibling,
cfi: cfi,
chapterText: chapterText,
lastOffset: lastOffset
)
}
if let token = tokenRecoveredResult(cfi: cfi, cfiMap: cfiMap, chapterText: chapterText, lastOffset: lastOffset) {
return token
}
if let fragmentID = resolved.fragmentID,
let fragmentOffset = fragmentOffsets[fragmentID]
?? cfiMap?.markers.first(where: { $0.fragmentID == fragmentID })?.chapterOffset {
return RDEPUBCFIRecoveryResult(
chapterOffset: clamp(fragmentOffset, lastOffset: lastOffset),
confidence: .fragmentFallback
)
}
if let fallbackOffset {
return RDEPUBCFIRecoveryResult(
chapterOffset: clamp(fallbackOffset, lastOffset: lastOffset),
confidence: .offsetFallback
)
}
return nil
}
private static func exactPathResult(
cfi: RDEPUBCFI,
cfiMap: RDEPUBCFIMap?,
lastOffset: Int
) -> RDEPUBCFIRecoveryResult? {
guard let cfiMap else { return nil }
if let marker = cfiMap.marker(matching: cfi.contentPath),
let chapterOffset = chapterOffset(for: cfi, marker: marker, lastOffset: lastOffset) {
return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath)
}
if let pathRange = cfiMap.pathRanges.first(where: { $0.cfiPath == cfi.contentPath }) {
let localOffset = max(cfi.characterOffset ?? 0, 0)
let chapterOffset = clamp(pathRange.startOffset + min(localOffset, max(pathRange.textNodeLength - 1, 0)), lastOffset: lastOffset)
return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath)
}
return nil
}
private static func siblingRecoveredResult(
cfi: RDEPUBCFI,
cfiMap: RDEPUBCFIMap?,
lastOffset: Int
) -> RDEPUBCFIRecoveryResult? {
guard let cfiMap,
let targetStep = cfi.contentPath.steps.last else { return nil }
let targetParent = cfi.contentPath.steps.dropLast()
let candidates = cfiMap.markers.filter { marker in
marker.cfiPath.steps.count == cfi.contentPath.steps.count
&& Array(marker.cfiPath.steps.dropLast()) == Array(targetParent)
&& marker.chapterOffset != nil
}
guard !candidates.isEmpty else { return nil }
let siblingSignature = siblingSignature(for: cfi.contentPath)
let best = candidates.min { lhs, rhs in
siblingScore(for: lhs, targetStep: targetStep, siblingSignature: siblingSignature)
< siblingScore(for: rhs, targetStep: targetStep, siblingSignature: siblingSignature)
}
guard let best,
let chapterOffset = chapterOffset(for: cfi, marker: best, lastOffset: lastOffset) else {
return nil
}
return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .siblingRecovered)
}
private static func tokenRecoveredResult(
cfi: RDEPUBCFI,
cfiMap: RDEPUBCFIMap?,
chapterText: String?,
lastOffset: Int
) -> RDEPUBCFIRecoveryResult? {
guard let cfiMap,
let chapterText,
!chapterText.isEmpty,
let exact = cfi.textAssertion?.exact,
!exact.isEmpty else {
return nil
}
let minLength = 2
let exactCount = Double(max(exact.count, 1))
let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in
let tokenCount = anchor.token.count
guard tokenCount >= minLength else { return false }
let ratio = Double(tokenCount) / exactCount
guard ratio >= 0.3 && ratio <= 3.0 else { return false }
let hasPrefixMatch = anchor.token.hasPrefix(exact) || exact.hasPrefix(anchor.token)
let hasSuffixMatch = anchor.token.hasSuffix(exact) || exact.hasSuffix(anchor.token)
return hasPrefixMatch || hasSuffixMatch
}
guard !candidateAnchors.isEmpty else { return nil }
let normalizedText = RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText)
guard !normalizedText.isEmpty else { return nil }
var bestOffset: Int?
var bestScore = Int.max
for anchor in candidateAnchors {
let approximateOffset = clamp(anchor.chapterOffset, lastOffset: lastOffset)
let calibrated = calibratedOffset(
approximateOffset,
assertion: cfi.textAssertion,
text: chapterText,
lastOffset: lastOffset,
windowRadius: 768
)
let score = abs(calibrated - approximateOffset)
if score < bestScore {
bestScore = score
bestOffset = calibrated
}
}
guard let bestOffset else { return nil }
return RDEPUBCFIRecoveryResult(chapterOffset: bestOffset, confidence: .tokenRecovered)
}
private static func calibrateIfNeeded(
_ result: RDEPUBCFIRecoveryResult,
cfi: RDEPUBCFI,
chapterText: String?,
lastOffset: Int
) -> RDEPUBCFIRecoveryResult {
guard let chapterText,
let assertion = cfi.textAssertion,
assertion.exact?.isEmpty == false else {
return result
}
let calibrated = calibratedOffset(
result.chapterOffset,
assertion: assertion,
text: chapterText,
lastOffset: lastOffset
)
if calibrated != result.chapterOffset {
return RDEPUBCFIRecoveryResult(chapterOffset: calibrated, confidence: .assertionCalibrated)
}
return result
}
private static func chapterOffset(
for cfi: RDEPUBCFI,
marker: RDEPUBCFIMarker,
lastOffset: Int
) -> Int? {
guard let baseOffset = marker.chapterOffset else { return nil }
let localOffset = max(cfi.characterOffset ?? 0, 0)
if let textNodeLength = marker.textNodeLength, textNodeLength > 0 {
return clamp(baseOffset + min(localOffset, max(textNodeLength - 1, 0)), lastOffset: lastOffset)
}
return clamp(baseOffset, lastOffset: lastOffset)
}
private static func siblingSignature(for path: RDEPUBCFIPath) -> String {
guard let last = path.steps.last else { return "" }
let parent = path.steps.dropLast().map { String($0.index) }.joined(separator: "/")
let childIndex = max(last.index / 2, 0)
return "\(parent)#\(childIndex)"
}
private static func siblingScore(
for marker: RDEPUBCFIMarker,
targetStep: RDEPUBCFIStep,
siblingSignature: String
) -> Int {
guard let candidateStep = marker.cfiPath.steps.last else { return Int.max }
var score = abs(candidateStep.index - targetStep.index)
if marker.domSiblingSignature != siblingSignature {
score += 1_000
}
if marker.fragmentID != nil {
score += 100
}
return score
}
private static func calibratedOffset(
_ offset: Int,
assertion: RDEPUBCFITextAssertion?,
text: String,
lastOffset: Int,
windowRadius: Int = 512
) -> Int {
let clampedOffset = clamp(offset, lastOffset: lastOffset)
guard let assertion,
let exact = assertion.exact,
!exact.isEmpty else {
return clampedOffset
}
let nsText = text as NSString
let length = nsText.length
guard length > 0 else { return clampedOffset }
let searchStart = max(clampedOffset - windowRadius, 0)
let searchEnd = min(clampedOffset + windowRadius + exact.utf16.count, length)
guard searchEnd > searchStart else { return clampedOffset }
let searchRange = NSRange(location: searchStart, length: searchEnd - searchStart)
var candidateRange = nsText.range(of: exact, options: [], range: searchRange)
var bestOffset: Int?
var bestScore = Int.max
while candidateRange.location != NSNotFound {
let score = assertionScore(
assertion,
candidateLocation: candidateRange.location,
exactLength: candidateRange.length,
in: nsText,
preferredOffset: clampedOffset
)
if score < bestScore {
bestScore = score
bestOffset = candidateRange.location
}
let nextLocation = candidateRange.location + max(candidateRange.length, 1)
let upperBound = searchRange.location + searchRange.length
guard nextLocation < upperBound else { break }
candidateRange = nsText.range(
of: exact,
options: [],
range: NSRange(location: nextLocation, length: upperBound - nextLocation)
)
}
guard let bestOffset, bestScore < Int.max else {
return clampedOffset
}
return clamp(bestOffset, lastOffset: lastOffset)
}
private static func assertionScore(
_ assertion: RDEPUBCFITextAssertion,
candidateLocation: Int,
exactLength: Int,
in text: NSString,
preferredOffset: Int
) -> Int {
var score = abs(candidateLocation - preferredOffset)
if let prefix = assertion.prefix, !prefix.isEmpty {
let prefixStart = max(candidateLocation - prefix.utf16.count, 0)
let availableLength = candidateLocation - prefixStart
let candidatePrefix = availableLength > 0
? text.substring(with: NSRange(location: prefixStart, length: availableLength))
: ""
score += candidatePrefix.hasSuffix(prefix) ? 0 : 10_000
}
if let suffix = assertion.suffix, !suffix.isEmpty {
let suffixStart = min(candidateLocation + exactLength, text.length)
let suffixLength = min(suffix.utf16.count, text.length - suffixStart)
let candidateSuffix = suffixLength > 0
? text.substring(with: NSRange(location: suffixStart, length: suffixLength))
: ""
score += candidateSuffix == suffix ? 0 : 10_000
}
return score
}
private static func clamp(_ offset: Int, lastOffset: Int) -> Int {
min(max(offset, 0), max(lastOffset, 0))
}
}

View File

@ -0,0 +1,51 @@
import Foundation
public struct RDEPUBCFIResolverResult: Equatable {
public var href: String?
public var fileIndex: Int?
public var chapterOffset: Int?
public var fragmentID: String?
public init(href: String? = nil, fileIndex: Int? = nil, chapterOffset: Int? = nil, fragmentID: String? = nil) {
self.href = href
self.fileIndex = fileIndex
self.chapterOffset = chapterOffset
self.fragmentID = fragmentID
}
}
public enum RDEPUBCFIResolver {
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult {
// EPUB CFI spec: package path structure is /6/2[spine]/2n[manifest-id]
// Manifest step is always the 3rd step (index 2) if present, or the last step with an id assertion.
let manifestStep: RDEPUBCFIStep?
if cfi.packagePath.steps.count >= 3 {
manifestStep = cfi.packagePath.steps[2]
} else {
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })
}
let href = manifestStep?.idAssertion
// Spine step is the 2nd step (index 1) in the package path
let fileIndex: Int?
if cfi.packagePath.steps.count >= 2 {
fileIndex = max((cfi.packagePath.steps[1].index / 2) - 1, 0)
} else {
fileIndex = nil
}
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })?.idAssertion
return RDEPUBCFIResolverResult(
href: href,
fileIndex: fileIndex,
chapterOffset: cfi.characterOffset,
fragmentID: fragmentID
)
}
}

View File

@ -0,0 +1,109 @@
import Foundation
public enum RDEPUBCFISerializer {
public static func serialize(_ cfi: RDEPUBCFI) -> String {
var body = serializePath(cfi.packagePath)
let content = serializePath(cfi.contentPath)
if !content.isEmpty || cfi.characterOffset != nil {
body += "!" + content
}
if let characterOffset = cfi.characterOffset {
body += ":\(max(characterOffset, 0))"
}
if let sideBias = cfi.sideBias {
body += ";s=\(sideBias.rawValue)"
}
if let assertion = cfi.textAssertion {
body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]"
}
return "epubcfi(\(body))"
}
public static func serializeRange(_ range: RDEPUBCFIRange) -> String {
let canonical = canonicalRangeComponents(for: range)
let parentBody = serializeRangeParent(canonical.parent)
let startBody = serializeRangeTerminal(
canonical.start,
relativeToPackagePath: canonical.parent.packagePath,
contentPrefix: canonical.parent.contentPath
)
let endBody = serializeRangeTerminal(
canonical.end,
relativeToPackagePath: canonical.parent.packagePath,
contentPrefix: canonical.parent.contentPath
)
return "epubcfi(\(parentBody),\(startBody),\(endBody))"
}
public static func serializePath(_ path: RDEPUBCFIPath) -> String {
guard !path.steps.isEmpty else { return "" }
return path.steps.map { step in
if let idAssertion = step.idAssertion {
return "/\(step.index)[\(idAssertion)]"
}
return "/\(step.index)"
}.joined()
}
private static func canonicalRangeComponents(for range: RDEPUBCFIRange) -> (
parent: RDEPUBCFI,
start: RDEPUBCFI,
end: RDEPUBCFI
) {
if let parent = range.parent {
return (parent, range.start, range.end)
}
let sharedPackage = range.start.packagePath.commonPrefix(with: range.end.packagePath)
let sharedContent = range.start.contentPath.commonPrefix(with: range.end.contentPath)
let parent = RDEPUBCFI(
packagePath: sharedPackage,
contentPath: sharedContent,
characterOffset: nil,
sideBias: nil,
textAssertion: nil
)
return (parent, range.start, range.end)
}
private static func serializeRangeParent(_ parent: RDEPUBCFI) -> String {
var body = serializePath(parent.packagePath)
let content = serializePath(parent.contentPath)
if !content.isEmpty {
body += "!" + content
}
return body
}
private static func serializeRangeTerminal(
_ cfi: RDEPUBCFI,
relativeToPackagePath parentPackagePath: RDEPUBCFIPath,
contentPrefix: RDEPUBCFIPath
) -> String {
let relativePackage = cfi.packagePath.droppingPrefix(parentPackagePath)
let relativeContent = cfi.contentPath.droppingPrefix(contentPrefix)
var body = serializePath(relativePackage)
let content = serializePath(relativeContent)
if !content.isEmpty || cfi.characterOffset != nil || !relativePackage.steps.isEmpty {
body += content
}
if let characterOffset = cfi.characterOffset {
body += ":\(max(characterOffset, 0))"
}
if let sideBias = cfi.sideBias {
body += ";s=\(sideBias.rawValue)"
}
if let assertion = cfi.textAssertion {
body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]"
}
return body
}
}

View File

@ -0,0 +1,16 @@
import Foundation
public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
public var prefix: String?
public var exact: String?
public var suffix: String?
public init(prefix: String? = nil, exact: String? = nil, suffix: String? = nil) {
self.prefix = prefix?.nilIfEmpty
self.exact = exact?.nilIfEmpty
self.suffix = suffix?.nilIfEmpty
}
}

View File

@ -0,0 +1,8 @@
import Foundation
internal extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -1,18 +1,12 @@
// RDEPUBTextIndexTable.swift
// EPUB
// href /spine
// fragment
//
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) {
@ -21,30 +15,31 @@ public struct RDEPUBRowColumnIndex: Codable, Equatable {
self.endOffset = endOffset
}
///
public func contains(_ offset: Int) -> Bool {
offset >= startOffset && offset <= endOffset
}
}
///
public struct RDEPUBTextIndexTable {
///
public let chapterStartOffsets: [Int]
///
public let chapterLengths: [Int]
/// href
public let hrefToChapterIndex: [String: Int]
/// href spine
public let hrefToFileIndex: [String: Int]
/// href (fragment ID )
public let fragmentOffsetsByHref: [String: [String: Int]]
/// spine href
public let fileIndexToHref: [Int: String]
/// spine
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 {
@ -53,7 +48,6 @@ public struct RDEPUBTextIndexTable {
return chapterStartOffsets[lastIndex] + chapterLengths[lastIndex]
}
///
public init(chapters: [RDEPUBTextChapter]) {
var offsets: [Int] = []
var lengths: [Int] = []
@ -62,6 +56,8 @@ public struct RDEPUBTextIndexTable {
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() {
@ -72,7 +68,12 @@ public struct RDEPUBTextIndexTable {
running += chapter.attributedContent.length
fragmentMap[chapter.href] = chapter.fragmentOffsets
fileIndexMap[chapter.spineIndex] = chapter.href
rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapter.attributedContent.string)
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
@ -82,9 +83,10 @@ public struct RDEPUBTextIndexTable {
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)
@ -99,8 +101,12 @@ public struct RDEPUBTextIndexTable {
)
}
/// 使 rangeAnchor fragment progression
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let anchor = anchor(for: cfi) {
return anchor
}
if let anchor = location.rangeAnchor?.start {
return anchor
}
@ -127,7 +133,104 @@ public struct RDEPUBTextIndexTable {
)
}
///
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 {
@ -145,7 +248,6 @@ public struct RDEPUBTextIndexTable {
)
}
///
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
@ -155,17 +257,14 @@ public struct RDEPUBTextIndexTable {
}.map { $0 + basePageIndex }
}
/// href
public func href(for fileIndex: Int) -> String? {
fileIndexToHref[fileIndex]
}
/// href
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],
@ -175,7 +274,6 @@ public struct RDEPUBTextIndexTable {
return chapterStartOffsets[chapterIndex]
}
///
public func chapterLength(forFileIndex fileIndex: Int) -> Int? {
guard let href = href(for: fileIndex),
let chapterIndex = hrefToChapterIndex[href],
@ -185,12 +283,10 @@ public struct RDEPUBTextIndexTable {
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
@ -199,31 +295,26 @@ public struct RDEPUBTextIndexTable {
return baseOffset + min(max(chapterOffset, 0), lastOffset)
}
///
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
globalIndex(for: anchor)
}
/// NSRange
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))
}
/// NSRange
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)
}
/// progression
public func location(
for anchor: RDEPUBTextAnchor,
in chapter: RDEPUBTextChapter,
@ -238,11 +329,12 @@ public struct RDEPUBTextIndexTable {
progression: progression,
lastProgression: progression,
fragment: anchor.fragmentID,
rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor)
rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor),
cfi: cfi(for: anchor)?.rawValue,
lastCFI: cfi(for: anchor)?.rawValue
)
}
/// progression
public func location(
for rangeAnchor: RDEPUBTextRangeAnchor,
in chapter: RDEPUBTextChapter,
@ -259,11 +351,13 @@ public struct RDEPUBTextIndexTable {
progression: Double(clampedStart) / Double(totalLength),
lastProgression: Double(clampedEnd) / Double(totalLength),
fragment: rangeAnchor.start.fragmentID,
rangeAnchor: rangeAnchor
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 {
@ -272,7 +366,6 @@ public struct RDEPUBTextIndexTable {
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) }) {
@ -282,7 +375,6 @@ public struct RDEPUBTextIndexTable {
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)
@ -291,7 +383,6 @@ public struct RDEPUBTextIndexTable {
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 {
@ -300,7 +391,6 @@ public struct RDEPUBTextIndexTable {
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))
@ -321,7 +411,6 @@ public struct RDEPUBTextIndexTable {
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 {
@ -331,13 +420,11 @@ public struct RDEPUBTextIndexTable {
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)
}
/// fragment ID
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
var bestID: String?
var bestOffset = -1
@ -352,7 +439,6 @@ public struct RDEPUBTextIndexTable {
return bestID
}
/// href fragment ID
private func nearestFragmentID(beforeOrAt offset: Int, inHref href: String) -> String? {
guard let fragmentOffsets = fragmentOffsetsByHref[href] else {
return nil
@ -367,7 +453,50 @@ public struct RDEPUBTextIndexTable {
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