import UIKit import ZIPFoundation public enum RDCBZReaderError: LocalizedError { case archiveOpenFailed case noImages case tooManyPages case archiveTooLarge public var errorDescription: String? { switch self { case .archiveOpenFailed: return "无法打开 CBZ 文件" case .noImages: return "CBZ 文件中没有可显示的图片" case .tooManyPages: return "CBZ 图片数量超过安全限制" case .archiveTooLarge: return "CBZ 解压后的内容超过安全限制" } } } /// Image-based comic reader for CBZ archives. Pages use the same paging engine /// as the EPUB reader and are extracted to a bounded disk cache on demand at /// controller creation time, avoiding retention of all decoded images. public final class RDCBZReaderController: UIViewController { public let readerView = RDEpubReaderView(frame: .zero) private let bookURL: URL private let configuration: RDEPUBReaderConfiguration private var pageURLs: [URL] = [] private let loadingIndicator = UIActivityIndicatorView(style: .large) public init(bookURL: URL, configuration: RDEPUBReaderConfiguration = .default) { self.bookURL = bookURL self.configuration = configuration super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = configuration.theme.contentBackgroundColor title = bookURL.deletingPathExtension().lastPathComponent loadingIndicator.translatesAutoresizingMaskIntoConstraints = false view.addSubview(loadingIndicator) NSLayoutConstraint.activate([ loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) loadingIndicator.startAnimating() let bookURL = self.bookURL DispatchQueue.global(qos: .userInitiated).async { [weak self] in let result = Result { try RDCBZArchiveExtractor().extractPages(from: bookURL) } DispatchQueue.main.async { guard let self else { return } self.loadingIndicator.stopAnimating() switch result { case let .success(pageURLs): self.pageURLs = pageURLs self.configureReaderView() case let .failure(error): self.show(error: error) } } } } private func configureReaderView() { readerView.pageProvider = self readerView.delegate = self readerView.currentDisplayType = configuration.displayType readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled readerView.coverPageIndex = pageURLs.isEmpty ? nil : 0 readerView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(readerView) readerView.register( contentView: RDCBZPageView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDCBZPageView.self) ) NSLayoutConstraint.activate([ readerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), readerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), readerView.topAnchor.constraint(equalTo: view.topAnchor), readerView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) readerView.reloadData() readerView.transitionToPage(pageNum: restoredPageIndex(), animated: false) } public var currentPageNumber: Int? { readerView.currentPage >= 0 ? readerView.currentPage + 1 : nil } public func setDisplayType(_ displayType: RDEpubReaderView.DisplayType) { readerView.switchReaderDisplayType(displayType) } @discardableResult public func goToPage(_ pageNumber: Int, animated: Bool = false) -> Bool { guard pageNumber > 0, pageNumber <= pageURLs.count else { return false } readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) return true } private func restoredPageIndex() -> Int { min(max(UserDefaults.standard.integer(forKey: persistenceKey), 0), max(pageURLs.count - 1, 0)) } private var persistenceKey: String { "ssreader.cbz.page.\(bookURL.lastPathComponent)" } private func show(error: Error) { let label = UILabel() label.numberOfLines = 0 label.textAlignment = .center label.textColor = .secondaryLabel label.text = error.localizedDescription label.translatesAutoresizingMaskIntoConstraints = false view.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), label.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) } } extension RDCBZReaderController: RDEpubReaderPageProvider, RDEpubReaderDelegate { public func numberOfPages(in readerView: RDEpubReaderView) -> Int { pageURLs.count } public func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView { let pageView = (reusableView as? RDCBZPageView) ?? RDCBZPageView() pageView.configure(imageURL: pageURLs[index], backgroundColor: configuration.theme.contentBackgroundColor) return pageView } public func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? { NSStringFromClass(RDCBZPageView.self) } public func pageNum(readerView: RDEpubReaderView, pageNum: Int) { UserDefaults.standard.set(pageNum, forKey: persistenceKey) } } private final class RDCBZPageView: UIView { private let imageView = UIImageView() override init(frame: CGRect) { super.init(frame: frame) imageView.contentMode = .scaleAspectFit imageView.clipsToBounds = true imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) NSLayoutConstraint.activate([ imageView.leadingAnchor.constraint(equalTo: leadingAnchor), imageView.trailingAnchor.constraint(equalTo: trailingAnchor), imageView.topAnchor.constraint(equalTo: topAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(imageURL: URL, backgroundColor: UIColor) { self.backgroundColor = backgroundColor imageView.image = UIImage(contentsOfFile: imageURL.path) accessibilityLabel = imageURL.deletingPathExtension().lastPathComponent } } private struct RDCBZArchiveExtractor { private let maximumPageCount = 10_000 private let maximumUncompressedSize: UInt64 = 2 * 1024 * 1024 * 1024 private let imageExtensions: Set = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "bmp", "tif", "tiff"] func extractPages(from archiveURL: URL) throws -> [URL] { let archive: Archive do { archive = try Archive(url: archiveURL, accessMode: .read) } catch { throw RDCBZReaderError.archiveOpenFailed } let entries = archive.filter { entry in entry.type == .file && imageExtensions.contains(URL(fileURLWithPath: entry.path).pathExtension.lowercased()) }.sorted { lhs, rhs in lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending } guard entries.isEmpty == false else { throw RDCBZReaderError.noImages } guard entries.count <= maximumPageCount else { throw RDCBZReaderError.tooManyPages } let totalSize = entries.reduce(UInt64(0)) { partial, entry in let (sum, overflow) = partial.addingReportingOverflow(UInt64(entry.uncompressedSize)) return overflow ? UInt64.max : sum } guard totalSize <= maximumUncompressedSize else { throw RDCBZReaderError.archiveTooLarge } let directory = cacheDirectory(for: archiveURL) let fileManager = FileManager.default let existing = existingPages(in: directory) if existing.count == entries.count { return existing } try? fileManager.removeItem(at: directory) try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) do { var output: [URL] = [] output.reserveCapacity(entries.count) for (index, entry) in entries.enumerated() { let ext = URL(fileURLWithPath: entry.path).pathExtension.lowercased() let destination = directory.appendingPathComponent(String(format: "%06d.%@", index, ext)) _ = try archive.extract(entry, to: destination) output.append(destination) } return output } catch { try? fileManager.removeItem(at: directory) throw error } } private func existingPages(in directory: URL) -> [URL] { ((try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])) ?? []) .filter { imageExtensions.contains($0.pathExtension.lowercased()) } .sorted { $0.lastPathComponent < $1.lastPathComponent } } private func cacheDirectory(for archiveURL: URL) -> URL { let attributes = try? FileManager.default.attributesOfItem(atPath: archiveURL.path) let size = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 let modified = (attributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 let rawName = archiveURL.deletingPathExtension().lastPathComponent let safeName = rawName.replacingOccurrences(of: #"[^A-Za-z0-9._-]"#, with: "_", options: .regularExpression) let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory return base.appendingPathComponent("ssreaderview-cbz", isDirectory: true) .appendingPathComponent("\(safeName)-\(size)-\(Int64(modified))", isDirectory: true) } }