import Foundation import ZIPFoundation extension RDEPUBParser { 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 } func extractArchiveIfNeeded(epubURL: URL) throws -> URL { let fileManager = FileManager.default let extractionURL = temporaryExtractionDirectory(for: epubURL) if fileManager.fileExists(atPath: extractionURL.path) { let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml") if fileManager.fileExists(atPath: containerURL.path) { return extractionURL } try? fileManager.removeItem(at: extractionURL) } guard let archive = Archive(url: epubURL, accessMode: .read) else { throw RDEPUBParserError.archiveOpenFailed(epubURL) } try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true) do { for entry in archive { if shouldSkipEntry(entry.path) { continue } guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else { if isCriticalEntry(entry.path) { throw RDEPUBParserError.invalidArchiveEntryPath(entry.path) } continue } 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 } } } catch { try? fileManager.removeItem(at: extractionURL) throw error } return extractionURL } private func isCriticalEntry(_ entryPath: String) -> Bool { let lowercased = entryPath.lowercased() return lowercased == "mimetype" || lowercased.hasPrefix("meta-inf/") || lowercased.hasSuffix(".opf") } private func shouldSkipEntry(_ entryPath: String) -> Bool { let lowercased = entryPath.lowercased() if lowercased.hasPrefix("__macosx/") { return true } if lowercased.hasPrefix(".ds_store") { return true } if lowercased.contains("/.ds_store") { return true } if lowercased.hasSuffix("/thumbs.db") { return true } return false } 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 } 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() } func resetPublicationState() { metadata = RDEPUBMetadata() manifest = [:] spine = [] tableOfContents = [] } } private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate { 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) } } /// 解析 com.apple.ibooks.display-options.xml,提取 orientation-lock 选项值。 final class IBooksDisplayOptionsParserDelegate: NSObject, XMLParserDelegate { private(set) var orientationLock: String? private var isCapturingOrientationOption = false private var buffer = "" 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 == "option" else { return } let optionName = (attributeDict["name"] ?? "").lowercased() if optionName == "orientation-lock" { isCapturingOrientationOption = true buffer = "" } } func parser(_ parser: XMLParser, foundCharacters string: String) { guard isCapturingOrientationOption else { return } buffer += string } func parser( _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String? ) { let name = XMLName.localName(from: qName ?? elementName) guard name == "option", isCapturingOrientationOption else { return } isCapturingOrientationOption = false let value = buffer.trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty, orientationLock == nil { orientationLock = value } } }