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
This commit is contained in:
shenlei
2026-07-10 19:44:53 +09:00
parent d5a7755702
commit d7fcda345d
460 changed files with 38358 additions and 2300 deletions
@@ -0,0 +1,210 @@
import Foundation
public final class RDEPUBParser {
public internal(set) var metadata = RDEPUBMetadata()
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
public internal(set) var spine: [RDEPUBSpineItem] = []
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
public internal(set) var extractionRootURL: URL?
public internal(set) var opfURL: URL?
public var opfDirectoryURL: URL? {
opfURL?.deletingLastPathComponent()
}
/// nil
/// `parse(epubURL:)` `RDEPUBReaderDependencies.live(resourceDataProvider:)`
public var resourceDataProvider: RDEPUBResourceDataProvider? {
didSet {
guard resourceDataProvider != nil else { return }
RDEPUBResourceAccessRegistry.register(self)
}
}
/// provider provider provider nil
public func providedResourceData(at fileURL: URL) -> Data? {
resourceDataProvider?.resourceData(at: fileURL)
}
/// provider
public func resourceData(at fileURL: URL) -> Data? {
if let provided = providedResourceData(at: fileURL) {
return provided
}
return try? Data(contentsOf: fileURL)
}
public init() {}
// MARK: - Cache Management
/// The base directory used for EPUB extraction caches.
public static var extractionCacheBaseDirectory: URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
}
/// Removes all EPUB extraction caches from disk.
/// Call this when the host app wants to free disk space, e.g. on didReceiveMemoryWarning
/// or during cleanup when the reader is no longer needed.
public static func cleanExtractionCache() throws {
let fileManager = FileManager.default
let baseURL = extractionCacheBaseDirectory
guard fileManager.fileExists(atPath: baseURL.path) else { return }
try fileManager.removeItem(at: baseURL)
}
/// Evicts extraction caches until total disk usage is below the given threshold,
/// removing least-recently-accessed directories first.
/// - Parameter maxBytes: Maximum total bytes for all extraction caches. Defaults to 500 MB.
public static func evictExtractionCache(maxBytes: UInt64 = 500 * 1024 * 1024) throws {
let fileManager = FileManager.default
let baseURL = extractionCacheBaseDirectory
guard fileManager.fileExists(atPath: baseURL.path) else { return }
let contents = try fileManager.contentsOfDirectory(
at: baseURL,
includingPropertiesForKeys: [.contentAccessDateKey, .isDirectoryKey],
options: .skipsHiddenFiles
)
var entries: [(url: URL, accessDate: Date, size: UInt64)] = []
var totalSize: UInt64 = 0
for directoryURL in contents {
guard let resourceValues = try? directoryURL.resourceValues(forKeys: [.isDirectoryKey, .contentAccessDateKey]),
resourceValues.isDirectory == true else {
continue
}
let directorySize = directoryURL.directorySize
let accessDate = resourceValues.contentAccessDate ?? Date.distantPast
entries.append((url: directoryURL, accessDate: accessDate, size: directorySize))
totalSize += directorySize
}
guard totalSize > maxBytes else { return }
entries.sort { $0.accessDate < $1.accessDate }
for entry in entries {
guard totalSize > maxBytes else { break }
try? fileManager.removeItem(at: entry.url)
totalSize -= entry.size
}
}
public func makePublication() -> RDEPUBPublication {
RDEPUBPublication(parser: self)
}
public func parse(epubURL: URL) throws {
reset()
let extractionURL = try extractArchiveIfNeeded(epubURL: epubURL)
extractionRootURL = extractionURL
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
guard FileManager.default.fileExists(atPath: containerURL.path) else {
throw RDEPUBParserError.missingContainerXML
}
let rootFilePath = try parseContainerRootFile(at: containerURL)
let packageURL = extractionURL.appendingPathComponent(rootFilePath)
guard FileManager.default.fileExists(atPath: packageURL.path) else {
throw RDEPUBParserError.invalidRootFilePath(rootFilePath)
}
try parseOPF(at: packageURL)
// OPF 退 iBooks display-options
if metadata.orientation == nil {
let displayOptionsURL = extractionURL
.appendingPathComponent("META-INF/com.apple.ibooks.display-options.xml")
if let orientation = parseIBooksOrientation(at: displayOptionsURL) {
metadata.orientation = orientation
}
}
}
/// `com.apple.ibooks.display-options.xml` orientation-lock
/// platform[@name] / option[@name="orientation-lock"]
private func parseIBooksOrientation(at url: URL) -> RDEPUBOrientation? {
guard FileManager.default.fileExists(atPath: url.path),
let parser = XMLParser(contentsOf: url) else {
return nil
}
let delegate = IBooksDisplayOptionsParserDelegate()
parser.delegate = delegate
guard parser.parse() else { return nil }
switch delegate.orientationLock?.lowercased() {
case "portrait-orientation", "portrait":
return .portrait
case "landscape-orientation", "landscape":
return .landscape
case "none":
return .auto
default:
return nil
}
}
public func parseOPF(at opfURL: URL) throws {
resetPublicationState()
guard let parser = XMLParser(contentsOf: opfURL) else {
throw RDEPUBParserError.invalidXML(opfURL)
}
let delegate = OPFPackageParserDelegate()
parser.shouldProcessNamespaces = false
parser.delegate = delegate
guard parser.parse() else {
throw parser.parserError ?? RDEPUBParserError.invalidXML(opfURL)
}
let packageDocument = delegate.packageDocument()
self.opfURL = opfURL
self.metadata = packageDocument.metadata
// packageDocument 使 manifest id
self.manifest = packageDocument.manifestByID
self.spine = try buildSpine(from: packageDocument, opfURL: opfURL)
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
}
public func parseTOC() -> [EPUBTableOfContentsItem] {
tableOfContents
}
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
}
}
// MARK: - URL Directory Size Extension
private extension URL {
var directorySize: UInt64 {
let fileManager = FileManager.default
guard let enumerator = fileManager.enumerator(at: self, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles], errorHandler: nil) else {
return 0
}
var totalSize: UInt64 = 0
for case let fileURL as URL in enumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
let fileSize = resourceValues.fileSize else {
continue
}
totalSize += UInt64(fileSize)
}
return totalSize
}
}