// RDEPUBParser+Resources.swift // EPUB 资源查询与 URL 解析 // 提供按 ID、href 查询 manifest 项,spine 索引与 href 互转, // 文件路径与 ss-reader:// 协议 URL 互转,封面图提取,HTML 内容读取等能力。 import Foundation import UIKit extension RDEPUBParser { /// 按 ID 查询 manifest 项 public func manifestItem(for id: String) -> RDEPUBManifestItem? { manifest[id] } /// 按 href 查询 manifest 项(忽略 fragment) public func manifestItem(forHref href: String) -> RDEPUBManifestItem? { let target = href.components(separatedBy: "#").first ?? href return manifest.values.first { item in normalize(href: item.href, relativeTo: opfDirectoryURL ?? URL(fileURLWithPath: "/")) == target } } /// 通过 spine 索引获取 href public func href(forSpineIndex index: Int) -> String? { guard spine.indices.contains(index) else { return nil } return spine[index].href } /// 将相对路径转换为本地文件 URL,并校验是否在解压根目录内(防路径遍历攻击) public func fileURL(forRelativePath relativePath: String) -> URL? { guard let opfDirectoryURL else { return nil } let path = relativePath.components(separatedBy: "#").first ?? relativePath guard !path.isEmpty else { return nil } let resolvedURL = URL(fileURLWithPath: path, relativeTo: opfDirectoryURL).standardizedFileURL guard let rootURL = extractionRootURL?.standardizedFileURL else { return resolvedURL } guard resolvedURL.path.hasPrefix(rootURL.path) else { return nil } return resolvedURL } /// 将相对路径转换为 ss-reader://book/ 协议的 URL(用于 WebView 加载资源) public func resourceURL(forRelativePath relativePath: String) -> URL? { let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard !normalizedPath.isEmpty else { return nil } guard let encodedPath = normalizedPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { return nil } var components = URLComponents() components.scheme = RDEPUBResourceURLSchemeHandler.scheme components.host = RDEPUBResourceURLSchemeHandler.host components.percentEncodedPath = "/" + encodedPath return components.url } /// 将 ss-reader://book/ 协议的 URL 还原为本地文件 URL public func fileURL(forResourceURL resourceURL: URL) -> URL? { guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme, resourceURL.host == RDEPUBResourceURLSchemeHandler.host else { return nil } let relativePath = resourceURL.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" return fileURL(forRelativePath: relativePath) } /// 提取封面图片(优先查找含 "cover-image" 属性或 ID 含 "cover" 的 manifest 项) public func coverImage() -> UIImage? { let coverCandidates = manifest.values.filter { item in item.properties.contains("cover-image") || item.id.lowercased().contains("cover") } for item in coverCandidates { if let fileURL = fileURL(forRelativePath: item.href), let image = UIImage(contentsOfFile: fileURL.path) { return image } } return nil } /// 通过 spine 索引读取 HTML 内容 public func htmlString(forSpineIndex index: Int) -> String? { guard spine.indices.contains(index) else { return nil } return htmlString(forRelativePath: spine[index].href) } /// 通过相对路径读取 HTML 内容 public func htmlString(forRelativePath relativePath: String) -> String? { guard let fileURL = fileURL(forRelativePath: relativePath) else { return nil } return try? String(contentsOf: fileURL) } /// 将 href 相对于目录 URL 解析为标准化的相对路径 func normalize(href: String, relativeTo directoryURL: URL) -> String { guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else { return href } let prefix = directoryURL.standardizedFileURL.path + "/" if resolvedURL.path.hasPrefix(prefix) { return String(resolvedURL.path.dropFirst(prefix.count)) } return href } }