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..= 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]..= 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]..= 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.. 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]) } }