215 lines
8.7 KiB
Swift
215 lines
8.7 KiB
Swift
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])
|
||
}
|
||
}
|