- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
272 lines
9.4 KiB
Swift
272 lines
9.4 KiB
Swift
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]).rd_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
|
|
}
|
|
}
|