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[.. 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).. 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).. 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 } }