ReadViewSDK/Sources/RDEpubReaderView/DocumentFormats/RDMOBIParser.swift

215 lines
8.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)] = [
("&nbsp;", " "), ("&amp;", "&"), ("&lt;", "<"),
("&gt;", ">"), ("&quot;", "\""), ("&apos;", "'"), ("&#39;", "'")
]
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])
}
}