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:
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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] = [
|
||||
" ": " ",
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": "\"",
|
||||
"'": "'"
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBCFIError: Error, Equatable {
|
||||
case empty
|
||||
case invalidWrapper(String)
|
||||
case invalidPath(String)
|
||||
case unsupportedRange(String)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
internal extension String {
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user