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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user