- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
144 lines
5.9 KiB
Swift
144 lines
5.9 KiB
Swift
// RDEPUBParser+Archive.swift
|
||
// EPUB 归档解压与 container.xml 解析
|
||
// 负责将 EPUB ZIP 文件解压到缓存目录(~/Library/Caches/ssreaderview-epub/),
|
||
// 并解析 META-INF/container.xml 获取 OPF 文件路径。
|
||
|
||
import Foundation
|
||
import ZIPFoundation
|
||
|
||
extension RDEPUBParser {
|
||
/// 解析 container.xml,提取 OPF(Package Document)的相对路径
|
||
/// - Parameter containerURL: container.xml 的文件路径
|
||
/// - Returns: OPF 文件的相对路径(如 "OEBPS/content.opf")
|
||
func parseContainerRootFile(at containerURL: URL) throws -> String {
|
||
guard let parser = XMLParser(contentsOf: containerURL) else {
|
||
throw RDEPUBParserError.invalidXML(containerURL)
|
||
}
|
||
|
||
let delegate = ContainerXMLParserDelegate()
|
||
parser.shouldProcessNamespaces = false
|
||
parser.delegate = delegate
|
||
|
||
guard parser.parse() else {
|
||
throw parser.parserError ?? RDEPUBParserError.invalidXML(containerURL)
|
||
}
|
||
|
||
guard let rootFilePath = delegate.rootFilePath, !rootFilePath.isEmpty else {
|
||
throw RDEPUBParserError.missingRootFile
|
||
}
|
||
|
||
return rootFilePath
|
||
}
|
||
|
||
/// 解压 EPUB ZIP 文件到缓存目录(幂等:已存在则跳过)
|
||
/// 目录名由 slug + 文件大小 + 修改时间戳组成,避免冲突
|
||
/// - Parameter epubURL: EPUB 文件路径
|
||
/// - Returns: 解压目标目录 URL
|
||
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
|
||
let fileManager = FileManager.default
|
||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
||
|
||
if fileManager.fileExists(atPath: extractionURL.path) {
|
||
return extractionURL
|
||
}
|
||
|
||
guard let archive = Archive(url: epubURL, accessMode: .read) else {
|
||
throw RDEPUBParserError.archiveOpenFailed(epubURL)
|
||
}
|
||
|
||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
||
|
||
for entry in archive {
|
||
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
|
||
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
|
||
}
|
||
switch entry.type {
|
||
case .directory:
|
||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||
case .file:
|
||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||
_ = try archive.extract(entry, to: destinationURL)
|
||
case .symlink:
|
||
continue
|
||
}
|
||
}
|
||
|
||
return extractionURL
|
||
}
|
||
|
||
/// 校验归档条目路径,防止路径穿越攻击
|
||
/// - Parameters:
|
||
/// - entryPath: 归档中的原始条目路径
|
||
/// - extractionRoot: 解压根目录
|
||
/// - Returns: 校验通过的目标 URL,非法路径返回 nil
|
||
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
|
||
// 拒绝绝对路径
|
||
if entryPath.hasPrefix("/") {
|
||
return nil
|
||
}
|
||
// 拒绝包含 .. 的路径段
|
||
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
|
||
if components.contains(where: { $0 == ".." }) {
|
||
return nil
|
||
}
|
||
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
|
||
let standardizedDest = destinationURL.standardizedFileURL.path
|
||
let standardizedRoot = extractionRoot.standardizedFileURL.path
|
||
// 确保最终路径位于解压根目录下
|
||
guard standardizedDest.hasPrefix(standardizedRoot) else {
|
||
return nil
|
||
}
|
||
return destinationURL
|
||
}
|
||
|
||
/// 计算 EPUB 的临时解压目录路径
|
||
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
|
||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
|
||
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
|
||
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
||
let slug = epubURL.deletingPathExtension().lastPathComponent
|
||
.replacingOccurrences(of: " ", with: "-")
|
||
let signature = String(format: "%.0f", modifiedAt)
|
||
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
|
||
}
|
||
|
||
/// 重置解析器状态(清除解压路径和出版物数据)
|
||
func reset() {
|
||
extractionRootURL = nil
|
||
opfURL = nil
|
||
resetPublicationState()
|
||
}
|
||
|
||
/// 重置出版物相关状态(metadata、manifest、spine、目录)
|
||
func resetPublicationState() {
|
||
metadata = RDEPUBMetadata()
|
||
manifest = [:]
|
||
spine = []
|
||
tableOfContents = []
|
||
}
|
||
}
|
||
|
||
/// container.xml 的 SAX 解析代理,提取 <rootfile> 元素的 full-path 属性
|
||
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
||
/// 解析得到的 OPF 文件相对路径
|
||
private(set) var rootFilePath: String?
|
||
|
||
func parser(
|
||
_ parser: XMLParser,
|
||
didStartElement elementName: String,
|
||
namespaceURI: String?,
|
||
qualifiedName qName: String?,
|
||
attributes attributeDict: [String: String] = [:]
|
||
) {
|
||
let name = XMLName.localName(from: qName ?? elementName)
|
||
guard name == "rootfile", rootFilePath == nil else {
|
||
return
|
||
}
|
||
rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
}
|