ReadViewSDK/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00

199 lines
7.1 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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