ReadViewSDK/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift
shenlei f50495ad91 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>
2026-06-18 13:25:17 +08:00

183 lines
6.3 KiB
Swift

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