- 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
199 lines
7.1 KiB
Swift
199 lines
7.1 KiB
Swift
|
||
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
|
||
}
|
||
}
|
||
}
|