feat: optimize reader caching and document formats
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDMOBIDocument: Equatable, Sendable {
|
||||
public let title: String
|
||||
public let text: String
|
||||
|
||||
public init(title: String, text: String) {
|
||||
self.title = title
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDMOBIParserError: LocalizedError, Equatable {
|
||||
case invalidHeader
|
||||
case invalidRecordTable
|
||||
case encryptedBook
|
||||
case unsupportedCompression(UInt16)
|
||||
case emptyText
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidHeader:
|
||||
return "MOBI 文件头无效"
|
||||
case .invalidRecordTable:
|
||||
return "MOBI 记录表损坏"
|
||||
case .encryptedBook:
|
||||
return "暂不支持受 DRM 保护的 MOBI 文件"
|
||||
case let .unsupportedCompression(value):
|
||||
return "暂不支持此 MOBI 压缩方式(\(value))"
|
||||
case .emptyText:
|
||||
return "MOBI 文件中没有可阅读的正文"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reader for classic PalmDOC/MOBI6 books. Uncompressed and PalmDOC-compressed
|
||||
/// text records are supported; DRM and HUFF/CDIC books fail with a clear error.
|
||||
public final class RDMOBIParser {
|
||||
public init() {}
|
||||
|
||||
public func parse(fileURL: URL) throws -> RDMOBIDocument {
|
||||
try parse(data: Data(contentsOf: fileURL), fallbackTitle: fileURL.deletingPathExtension().lastPathComponent)
|
||||
}
|
||||
|
||||
public func parse(data: Data, fallbackTitle: String = "MOBI") throws -> RDMOBIDocument {
|
||||
guard data.count >= 86,
|
||||
let recordCount = data.rdUInt16BE(at: 76),
|
||||
recordCount > 0 else {
|
||||
throw RDMOBIParserError.invalidHeader
|
||||
}
|
||||
|
||||
let tableEnd = 78 + Int(recordCount) * 8
|
||||
guard tableEnd <= data.count else {
|
||||
throw RDMOBIParserError.invalidRecordTable
|
||||
}
|
||||
|
||||
var offsets: [Int] = []
|
||||
offsets.reserveCapacity(Int(recordCount) + 1)
|
||||
for index in 0..<Int(recordCount) {
|
||||
guard let offset = data.rdUInt32BE(at: 78 + index * 8),
|
||||
Int(offset) >= tableEnd,
|
||||
Int(offset) < data.count,
|
||||
offsets.last.map({ Int(offset) > $0 }) ?? true else {
|
||||
throw RDMOBIParserError.invalidRecordTable
|
||||
}
|
||||
offsets.append(Int(offset))
|
||||
}
|
||||
offsets.append(data.count)
|
||||
|
||||
let recordZero = data.subdata(in: offsets[0]..<offsets[1])
|
||||
guard recordZero.count >= 16,
|
||||
let compression = recordZero.rdUInt16BE(at: 0),
|
||||
let textLengthValue = recordZero.rdUInt32BE(at: 4),
|
||||
let textRecordCount = recordZero.rdUInt16BE(at: 8),
|
||||
let encryption = recordZero.rdUInt16BE(at: 12) else {
|
||||
throw RDMOBIParserError.invalidHeader
|
||||
}
|
||||
guard encryption == 0 else { throw RDMOBIParserError.encryptedBook }
|
||||
guard compression == 1 || compression == 2 else {
|
||||
throw RDMOBIParserError.unsupportedCompression(compression)
|
||||
}
|
||||
|
||||
let availableTextRecords = min(Int(textRecordCount), max(0, offsets.count - 2))
|
||||
guard availableTextRecords > 0 else { throw RDMOBIParserError.emptyText }
|
||||
|
||||
var decoded = Data()
|
||||
decoded.reserveCapacity(min(Int(textLengthValue), 16 * 1024 * 1024))
|
||||
for recordIndex in 1...availableTextRecords {
|
||||
let record = data.subdata(in: offsets[recordIndex]..<offsets[recordIndex + 1])
|
||||
if compression == 1 {
|
||||
decoded.append(record)
|
||||
} else {
|
||||
decoded.append(try decompressPalmDOC(record))
|
||||
}
|
||||
if decoded.count >= Int(textLengthValue) { break }
|
||||
}
|
||||
if decoded.count > Int(textLengthValue) {
|
||||
decoded = decoded.prefix(Int(textLengthValue))
|
||||
}
|
||||
|
||||
let encodingCode = recordZero.count >= 32 ? recordZero.rdUInt32BE(at: 28) : nil
|
||||
let rawText = decodeText(decoded, encodingCode: encodingCode)
|
||||
let text = normalizeMarkup(rawText)
|
||||
guard text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
|
||||
throw RDMOBIParserError.emptyText
|
||||
}
|
||||
|
||||
let pdbTitle = decodePDBTitle(data.prefix(32))
|
||||
return RDMOBIDocument(title: pdbTitle.isEmpty ? fallbackTitle : pdbTitle, text: text)
|
||||
}
|
||||
|
||||
private func decompressPalmDOC(_ input: Data) throws -> Data {
|
||||
let bytes = [UInt8](input)
|
||||
var output: [UInt8] = []
|
||||
output.reserveCapacity(bytes.count * 2)
|
||||
var index = 0
|
||||
|
||||
while index < bytes.count {
|
||||
let byte = bytes[index]
|
||||
index += 1
|
||||
switch byte {
|
||||
case 0:
|
||||
output.append(0)
|
||||
case 1...8:
|
||||
let count = Int(byte)
|
||||
guard index + count <= bytes.count else { throw RDMOBIParserError.invalidRecordTable }
|
||||
output.append(contentsOf: bytes[index..<(index + count)])
|
||||
index += count
|
||||
case 9...0x7f:
|
||||
output.append(byte)
|
||||
case 0x80...0xbf:
|
||||
guard index < bytes.count else { throw RDMOBIParserError.invalidRecordTable }
|
||||
let pair = (UInt16(byte) << 8) | UInt16(bytes[index])
|
||||
index += 1
|
||||
let distance = Int((pair & 0x3fff) >> 3)
|
||||
let count = Int(pair & 0x0007) + 3
|
||||
guard distance > 0, distance <= output.count else { throw RDMOBIParserError.invalidRecordTable }
|
||||
for _ in 0..<count {
|
||||
output.append(output[output.count - distance])
|
||||
}
|
||||
default:
|
||||
output.append(0x20)
|
||||
output.append(byte ^ 0x80)
|
||||
}
|
||||
}
|
||||
return Data(output)
|
||||
}
|
||||
|
||||
private func decodeText(_ data: Data, encodingCode: UInt32?) -> String {
|
||||
if encodingCode == 65001, let value = String(data: data, encoding: .utf8) { return value }
|
||||
if encodingCode == 1252, let value = String(data: data, encoding: .windowsCP1252) { return value }
|
||||
if let value = String(data: data, encoding: .utf8) { return value }
|
||||
if let value = String(data: data, encoding: .windowsCP1252) { return value }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
private func decodePDBTitle(_ data: Data.SubSequence) -> String {
|
||||
let titleBytes = data.prefix { $0 != 0 }
|
||||
return String(data: Data(titleBytes), encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
private func normalizeMarkup(_ source: String) -> String {
|
||||
var text = source
|
||||
text = text.replacingOccurrences(
|
||||
of: #"(?is)<(br\s*/?|/p|/div|/h[1-6]|/li|/blockquote|/tr)>"#,
|
||||
with: "\n",
|
||||
options: .regularExpression
|
||||
)
|
||||
text = text.replacingOccurrences(of: #"(?is)<[^>]+>"#, with: "", options: .regularExpression)
|
||||
let entities: [(String, String)] = [
|
||||
(" ", " "), ("&", "&"), ("<", "<"),
|
||||
(">", ">"), (""", "\""), ("'", "'"), ("'", "'")
|
||||
]
|
||||
for (entity, value) in entities {
|
||||
text = text.replacingOccurrences(of: entity, with: value, options: .caseInsensitive)
|
||||
}
|
||||
text = decodeNumericEntities(text)
|
||||
text = text.replacingOccurrences(of: #"[\t\u{00a0}]+"#, with: " ", options: .regularExpression)
|
||||
text = text.replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression)
|
||||
return text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func decodeNumericEntities(_ source: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"&#(x[0-9A-Fa-f]+|\d+);"#, options: [.caseInsensitive]) else {
|
||||
return source
|
||||
}
|
||||
let result = NSMutableString(string: source)
|
||||
let matches = regex.matches(in: source, range: NSRange(location: 0, length: result.length))
|
||||
for match in matches.reversed() {
|
||||
let token = result.substring(with: match.range(at: 1))
|
||||
let radix = token.lowercased().hasPrefix("x") ? 16 : 10
|
||||
let digits = radix == 16 ? String(token.dropFirst()) : token
|
||||
guard let value = UInt32(digits, radix: radix), let scalar = UnicodeScalar(value) else { continue }
|
||||
result.replaceCharacters(in: match.range, with: String(Character(scalar)))
|
||||
}
|
||||
return result as String
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
func rdUInt16BE(at offset: Int) -> UInt16? {
|
||||
guard offset >= 0, offset + 2 <= count else { return nil }
|
||||
return (UInt16(self[offset]) << 8) | UInt16(self[offset + 1])
|
||||
}
|
||||
|
||||
func rdUInt32BE(at offset: Int) -> UInt32? {
|
||||
guard offset >= 0, offset + 4 <= count else { return nil }
|
||||
return (UInt32(self[offset]) << 24)
|
||||
| (UInt32(self[offset + 1]) << 16)
|
||||
| (UInt32(self[offset + 2]) << 8)
|
||||
| UInt32(self[offset + 3])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// File formats that can be opened by `RDEpubURLReaderController`.
|
||||
public enum RDReaderDocumentFormat: String, CaseIterable, Sendable {
|
||||
case epub
|
||||
case plainText = "txt"
|
||||
case mobi
|
||||
case cbz
|
||||
|
||||
public init?(fileURL: URL) {
|
||||
self.init(rawValue: fileURL.pathExtension.lowercased())
|
||||
}
|
||||
|
||||
public static func supports(_ fileURL: URL) -> Bool {
|
||||
RDReaderDocumentFormat(fileURL: fileURL) != nil
|
||||
}
|
||||
|
||||
public static var supportedPathExtensions: Set<String> {
|
||||
Set(allCases.map(\.rawValue))
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,18 @@ public final class RDEpubPlainTextBookBuilder {
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
let rawText = rd_decodeTextFile(url: textFileURL)
|
||||
return try build(text: rawText, pageSize: pageSize, style: style)
|
||||
}
|
||||
|
||||
/// Builds a paginated book from text already held in memory. This is used
|
||||
/// by container formats such as MOBI, whose text does not exist as a
|
||||
/// standalone file on disk.
|
||||
public func build(
|
||||
text: String,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
let rawText = text
|
||||
let chapterSpecs = splitChapters(from: rawText)
|
||||
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
|
||||
@@ -73,7 +73,11 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
}
|
||||
|
||||
public func applyDemoDisplayType(_ displayType: RDEpubReaderView.DisplayType) {
|
||||
readerController?.configuration.displayType = displayType
|
||||
if let readerController {
|
||||
readerController.configuration.displayType = displayType
|
||||
} else {
|
||||
cbzReaderController?.setDisplayType(displayType)
|
||||
}
|
||||
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||||
}
|
||||
|
||||
@@ -145,14 +149,16 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
|
||||
private func embedReaderController() {
|
||||
let controller: UIViewController
|
||||
if bookURL.pathExtension.lowercased() == "epub" {
|
||||
switch RDReaderDocumentFormat(fileURL: bookURL) {
|
||||
case .epub:
|
||||
controller = RDEPUBReaderController(
|
||||
epubURL: bookURL,
|
||||
configuration: epubConfiguration
|
||||
)
|
||||
} else {
|
||||
case .cbz:
|
||||
controller = RDCBZReaderController(bookURL: bookURL, configuration: epubConfiguration)
|
||||
case .mobi, .plainText:
|
||||
let bookIdentifier = bookURL.lastPathComponent
|
||||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||||
let pageSize = currentTextPageSize()
|
||||
let renderStyle = currentTextRenderStyle()
|
||||
let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
|
||||
@@ -176,23 +182,33 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
imageMaxHeightRatio: 0.85
|
||||
)
|
||||
)
|
||||
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
|
||||
do {
|
||||
let source: (textURL: URL, title: String)
|
||||
if RDReaderDocumentFormat(fileURL: bookURL) == .mobi {
|
||||
let document = try RDMOBIParser().parse(fileURL: bookURL)
|
||||
source = (try cachedMOBITextURL(text: document.text), document.title)
|
||||
} else {
|
||||
source = (bookURL, bookURL.deletingPathExtension().lastPathComponent)
|
||||
}
|
||||
let textBook = try builder.build(textFileURL: source.textURL, pageSize: pageSize, style: renderStyle)
|
||||
controller = RDEPUBReaderController(
|
||||
textBook: textBook,
|
||||
bookIdentifier: bookIdentifier,
|
||||
title: bookTitle,
|
||||
textFileURL: bookURL,
|
||||
title: source.title,
|
||||
textFileURL: source.textURL,
|
||||
configuration: epubConfiguration
|
||||
)
|
||||
} else {
|
||||
|
||||
let fallback = UIViewController()
|
||||
let textView = UITextView()
|
||||
textView.isEditable = false
|
||||
textView.text = rd_decodeTextFile(url: bookURL)
|
||||
fallback.view = textView
|
||||
controller = fallback
|
||||
} catch {
|
||||
controller = makeErrorController(error)
|
||||
}
|
||||
case nil:
|
||||
controller = makeErrorController(
|
||||
NSError(
|
||||
domain: "RDEpubReaderView.UnsupportedFormat",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "不支持此文件格式"]
|
||||
)
|
||||
)
|
||||
}
|
||||
embeddedController = controller
|
||||
addChild(controller)
|
||||
@@ -213,8 +229,15 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
|
||||
private var cbzReaderController: RDCBZReaderController? {
|
||||
embeddedController as? RDCBZReaderController
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
|
||||
if let cbzReaderController {
|
||||
return cbzReaderController.goToPage(pageNumber, animated: animated)
|
||||
}
|
||||
guard let readerController else { return false }
|
||||
guard pageNumber > 0 else { return false }
|
||||
|
||||
@@ -451,18 +474,41 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
|
||||
return content
|
||||
}
|
||||
return ""
|
||||
private func cachedMOBITextURL(text: String) throws -> URL {
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: bookURL.path)
|
||||
let size = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0
|
||||
let modified = (attributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
||||
let safeName = bookURL.deletingPathExtension().lastPathComponent
|
||||
.replacingOccurrences(of: #"[^A-Za-z0-9._-]"#, with: "_", options: .regularExpression)
|
||||
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let directory = base.appendingPathComponent("ssreaderview-mobi", isDirectory: true)
|
||||
.appendingPathComponent("\(safeName)-\(size)-\(Int64(modified))", isDirectory: true)
|
||||
let textURL = directory.appendingPathComponent("content.txt")
|
||||
if FileManager.default.fileExists(atPath: textURL.path) { return textURL }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try Data(text.utf8).write(to: textURL, options: .atomic)
|
||||
return textURL
|
||||
}
|
||||
|
||||
private func makeErrorController(_ error: Error) -> UIViewController {
|
||||
let fallback = UIViewController()
|
||||
fallback.view.backgroundColor = .systemBackground
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
label.textColor = .secondaryLabel
|
||||
label.text = error.localizedDescription
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
fallback.view.addSubview(label)
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: fallback.view.leadingAnchor, constant: 24),
|
||||
label.trailingAnchor.constraint(equalTo: fallback.view.trailingAnchor, constant: -24),
|
||||
label.centerYAnchor.constraint(equalTo: fallback.view.centerYAnchor)
|
||||
])
|
||||
return fallback
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDEpubURLReaderController: RDEPUBReaderDelegate {
|
||||
|
||||
@@ -2,7 +2,7 @@ Pod::Spec.new do |s|
|
||||
s.name = "RDEpubReaderView"
|
||||
s.module_name = "RDEpubReaderView"
|
||||
s.version = "0.0.2"
|
||||
s.summary = "A reader view for EPUB and plain-text books"
|
||||
s.summary = "A reader view for EPUB, TXT, MOBI, and CBZ books"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git"
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
# RDEpubReaderView
|
||||
|
||||
项目内置的 EPUB 阅读器 CocoaPods 源码。
|
||||
项目内置的 EPUB、TXT、MOBI 与 CBZ 阅读器 CocoaPods 源码。
|
||||
|
||||
统一使用 `RDEpubURLReaderController(bookURL:)` 打开文件,支持扩展名:
|
||||
|
||||
- `epub`:完整 EPUB 解析与排版
|
||||
- `txt`:纯文本章节识别与排版
|
||||
- `mobi`:无 DRM 的经典 MOBI6/PalmDOC(未压缩或 PalmDOC 压缩)
|
||||
- `cbz`:ZIP 漫画图片包,文件名自然排序、图片分页与阅读进度保存
|
||||
|
||||
MOBI 的 HUFF/CDIC 压缩、KF8 专有排版以及 DRM 内容会返回明确的不支持错误;CBZ 会限制页数和解压体积,避免异常压缩包耗尽设备资源。
|
||||
|
||||
`Podfile` 通过 `:path => 'Vendor/RDEpubReaderView'` 引用本目录;自动复制工程到其他目录打包时,不再依赖仓库外的 `ReadViewSDK` 路径。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user