239 lines
10 KiB
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)
|
|
}
|
|
}
|