一、问题背景

电子书格式转换是刚需,但现有方案重度依赖 Calibre。Calibre 功能强大,可对于一个 macOS 桌面工具来说,引入一个 300MB 的外部依赖来完成"TXT 转 EPUB"这类基础操作,太重了。

核心挑战在于两个专有格式:

  • AZW3(KF8):亚马逊闭源格式,基于 PalmDB 容器 + MOBI 头部 + PalmDOC 压缩

  • KEPUB:Kobo 增强 EPUB,结构上是 EPUB + 额外 <span> 标注,但文件名必须为 .kepub.epub 双重后缀

本文介绍如何用纯 Swift 从零构建解析引擎,彻底移除 Calibre 依赖。


二、MOBI/AZW3 二进制结构

AZW3 文件底层是 PalmDB 格式,结构如下:

┌──────────────────────┐
│  PDB Header (78 B)   │  ← 记录数、偏移量等元信息
├──────────────────────┤
│  Record List         │  ← 每条记录 (4B offset + 4B flags) × N
├──────────────────────┤
│  Record 0            │  ← MOBI Header + EXTH Header
├──────────────────────┤
│  Record 1..M         │  ← HTML 正文(可能 PalmDOC 压缩)
├──────────────────────┤
│  Record M+1..N       │  ← 图片资源(封面、插图)
└──────────────────────┘

2.1 PDB Header 解析

struct PDBHeader {
    let name: String         // 32B, 书名
    let numRecords: UInt16   // offset 76
}
​
static func parsePDBHeader(_ data: Data) throws -> PDBHeader {
    guard data.count >= 78 else { throw ParseError.tooSmall }
    let name = String(data: data[0..<32], encoding: .utf8)?
        .trimmingCharacters(in: .nulls) ?? "Unknown"
    let numRecords = data.bigUInt16(at: 76)
    return PDBHeader(name: name, numRecords: Int(numRecords))
}

Record List 从 byte 78 开始,每 8 字节一条记录(offset + attributes),共 numRecords 条。

2.2 MOBI Header 与 EXTH

Record 0 的前 16 字节是 PalmDOC 记录头,之后是 MOBI Header:

  • byte 16-19:MOBI 标识

  • byte 20-23:header 长度

  • byte 92-95:EXTH flags(bit 6 = 0x40 表示 EXTH Header 存在)

EXTH Header 紧跟在 MOBI Header 之后,由若干 record 组成,每条 record 结构:

type (4B, big-endian) + length (4B) + data (length-8)B

关键 record type:

  • 113(0x71):ASIN(Amazon Standard Identification Number)

  • 504(0x1F8):新版 ASIN(KF8+)

func findASIN(in data: Data, record0Start: Int) -> String? {
    let asinTypes: [UInt32] = [113, 504]
    let exthStart = record0Start + 16 + mobiHeaderLength
​
    var pos = exthStart
    while pos + 8 < data.count {
        let type = data.bigUInt32(at: pos)
        let len  = Int(data.bigUInt32(at: pos + 4))
        if len < 8 || pos + len > data.count { break }
​
        if asinTypes.contains(type) {
            return String(data: data[(pos+8)..<(pos+len)], encoding: .utf8)?
                .trimmingCharacters(in: .nulls)
        }
        pos += len
    }
    return nil
}

三、PalmDOC 解压算法

MOBI 文件的 HTML 正文通常用 PalmDOC 压缩(compression type = 2)。这是一个 LZ77 变体,字节级编码:

字节范围 含义
0x00 输出字面量 0x00
0x01-0x08 字面量复制 N 字节(ESC 序列)
0x09-0x7F 字面量直接输出
0x80-0xBF LZ77 回引:与下一字节组成 16-bit code,distance = (code>>3)&0x7FF,length = (code&7)+3
0xC0-0xFF 空格 + (byte ^ 0x80)

Swift 实现核心循环:

func palmDocDecompress(_ data: Data, textLength: Int) -> Data {
    var out = Data(); out.reserveCapacity(textLength)
    var i = 0
​
    while i < data.count && out.count < textLength {
        let c = data[i]; i += 1
​
        if c == 0 {
            out.append(0)
        } else if c <= 8 {
            guard i + c <= data.count else { break }
            out.append(data[i..<(i+Int(c))]); i += Int(c)
        } else if c < 0x80 {
            out.append(c)
        } else if c < 0xC0 {
            guard i < data.count else { break }
            let n = (UInt16(c) << 8) | UInt16(data[i]); i += 1
            let distance = Int((n >> 3) & 0x07FF)
            let length   = Int(n & 0x07) + 3
            let start    = out.count - distance - 1
            guard start >= 0, start + length <= out.count else { break }
            out.append(out[start..<(start+length)])
        } else {
            out.append(0x20)
            out.append(c ^ 0x80)
        }
    }
    return out.prefix(textLength)
}

四、封面提取

Kindle 封面不显示的根本原因是 EXTH 中缺少有效的 ASIN。解决方案:从源文件直接提取封面图片并写入 Kindle 的缩略图缓存。

提取封面不需要解析 HTML 结构——扫描所有 Image Record 的魔术字节即可:

let imageMagics: [[UInt8]] = [
    [0xFF, 0xD8, 0xFF],           // JPEG
    [0x89, 0x50, 0x4E, 0x47],     // PNG
    [0x47, 0x49, 0x46, 0x38],     // GIF
]
​
for recOffset in recordOffsets[1...] {  // skip Record 0
    let magic = data[recOffset..<(min(recOffset+4, data.count))]
    if imageMagics.contains(where: { magic.starts(with: $0) }) {
        return data[recOffset..<recOffset+recordLength]
    }
}

五、EPUB 构建与格式转换管线

整个转换管线分三层:

源文件 → EpubBuilder(中间 EPUB)→ kepubify(KEPUB)
                                     → Calibre(AZW3,如有安装)

EpubBuilder 是纯 Swift 实现的 EPUB 生成器,负责:

  • TXT → EPUB:按段分行,包装 HTML boilerplate,生成标准 EPUB 容器(mimetype + META-INF + OEBPS)

  • HTML/FB2/CBZ:直接或转换后嵌入 EPUB 结构

  • MOBI/AZW3:调用 MOBIParser 提取 HTML + 图片,重组为 EPUB

  • RTF/DOCX:通过 macOS 内置 textutil 转 HTML 再打包(零额外依赖)

生成的 EPUB 通过内嵌的 kepubify 二进制转换为 KEPUB。整个流程对用户透明。


六、架构与测试

Sources/MoZhuan/
├── App.swift                 # SwiftUI 入口
├── ContentView.swift         # UI 层(拖拽、进度、状态)
├── ConvertViewModel.swift    # 状态管理(@Observable)
├── KepubifyService.swift     # 转换调度 + Calibre 检测
├── EpubBuilder.swift         # 纯 Swift EPUB 生成
├── MOBIParser.swift          # PDB/MOBI 二进制解析 + PalmDOC 解压
├── KindleCoverFixer.swift    # 封面修复引擎(MOBIParser → Kindle 缓存)
├── CalibreInstaller.swift    # Calibre 下载安装器
├── DesignConstants.swift     # 统一间距系统
├── KindleDetector.swift      # Kindle 设备发现
└── KoboDetector.swift        # Kobo 设备发现
​
Tests/
└── MoZhuanTests.swift        # 6 个核心用例,覆盖解析+检测+常量

swift test 全部通过,零编译警告。最低 macOS 14(@Observable 宏要求)。


七、小结

核心经验:

  1. 二进制格式解析用纯 Data 操作 + big-endian 读取,比依赖第三方库更灵活

  2. PalmDOC 解压算法 100 行 Swift 即可实现,无需引入 C 库

  3. EXTH ASIN 提取是解决 Kindle 封面问题的关键

  4. EPUB 生成用标准 ZIP + XML 即可,ditto -c -k 是 macOS 下最轻量的打包方式

  5. 合理分层(解析 / 构建 / 调度)让每个模块可独立测试

GitHubGitHub - wordwu/mozhuan: macOS 电子书格式转换 %26 Kindle 封面修复 · GitHub(MIT 开源)

环境:macOS 14+ / Swift 6.3 / Xcode 16+

更多推荐