ReadViewSDK/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00

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_cfiNilIfEmpty
} 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
}
}