ReadViewSDK/Sources/RDEpubReaderView/DocumentFormats/RDCBZReaderController.swift

239 lines
10 KiB
Swift

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<String> = ["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)
}
}