前置知识:理解内存管理策略(第 10 章),了解渲染引擎和字体系统(第 9 章),熟悉 Activity 框架(第 8 章)。 本章目标:拆解 CrossPoint Reader 如何在 380KB 内存中完成从”打开一本 EPUB 文件”到”在墨水屏上显示一页排版精美的文字”的全过程。

一本电子书在你手机上打开只需要一秒钟——你可能从来没想过这件事有什么难的。但在一颗 160MHz、380KB 内存的芯片上,打开一本书是一场精心编排的战役:ZIP 要流式解压、XML 要增量解析、排版要动态规划、渲染要多遍执行。这一章,我们逐一拆解。

11.1 EPUB 文件结构拆解

在开始写代码之前,先搞清楚我们要处理的对象长什么样。 EPUB 是目前最流行的电子书格式。它的本质出奇地简单——一个改了扩展名的 ZIP 压缩包。你可以把任何 .epub 文件的扩展名改成 .zip,然后用解压软件打开,会看到这样的目录结构:
我的书.epub(本质是 ZIP 压缩包)
  └── ZIP 解包后
        ├── META-INF/
        │     └── container.xml   → 入口文件:告诉你 .opf 在哪
        ├── content.opf           → 核心元数据:书名、作者、章节顺序
        ├── toc.ncx / nav.xhtml   → 目录结构(可供用户跳转的章节列表)
        ├── style.css             → 样式表(字体大小、缩进、对齐等)
        ├── chapter1.xhtml        → 第一章内容(HTML 格式)
        ├── chapter2.xhtml        → 第二章内容
        ├── ...
        └── images/
              └── cover.jpg       → 封面图片
阅读器要做的事情,就是按照特定顺序依次解析这些文件:
  1. container.xml — 找到 .opf 文件的路径(它是”目录的目录”)
  2. content.opf — 获取书名、作者等元数据,以及 <spine> 标签定义的章节阅读顺序
  3. toc.ncx 或 nav.xhtml — 构建用户可见的目录结构
  4. 各个 .xhtml 文件 — 真正的书籍内容,是标准的 HTML
如果你做过 Web 开发,会觉得很亲切:EPUB 本质上就是一堆 HTML 页面打包成了一个 ZIP 文件。

11.2 EPUB 解析流水线

理解了文件结构,接下来看 CrossPoint 是怎么把它加载到内存中的。
// lib/Epub/Epub.cpp
bool Epub::load(const std::string& path) {
  zip = std::make_unique<ZipFile>(path);

  // 1. 先尝试加载二进制缓存(如果之前解析过这本书)
  if (bookCache.loadFromFile(cachePath + "/book.bin")) {
    return true;  // 缓存命中,直接用,几十毫秒就完成
  }

  // 2. 缓存不存在(第一次打开),完整解析
  parseContainer();    // container.xml → 找到 .opf 路径
  parseOpf();          // 解析 spine(阅读顺序)+ metadata(元数据)
  parseToc();          // 解析目录结构

  // 3. 将解析结果序列化为二进制缓存,下次打开秒进
  bookCache.saveToFile(cachePath + "/book.bin");
}
这里的核心设计思想是:第一次打开慢一点没关系,但同一本书第二次打开必须快。 解析 XML 是 CPU 密集型操作,在 160MHz 的芯片上可能需要数秒。而加载预先序列化好的二进制缓存文件只需要顺序读取字节流,快几十倍。 这和 Web 开发中的”编译缓存”是同一个思路——Webpack 第一次构建慢,之后就快了。

11.3 ZIP 流式解压:不能全部展开

在手机或电脑上,处理 ZIP 文件很简单:把整个文件解压到临时目录,然后随意读取。但在 380KB 内存的芯片上,这是不可能的——一本普通 EPUB 可能有几十 MB,解压后更大。 CrossPoint 采用流式解压策略:不把文件展开到内存中,而是在需要读取某个文件时,直接在 ZIP 内部定位到该文件的位置,边读边解压。
// lib/ZipFile/ZipFile.h
struct ZipInflateCtx {
  InflateReader inflater;       // deflate 解压器
  FsFile* file;                 // 指向 SD 卡上的 ZIP 文件
  uint32_t remainingBytes;      // 该条目还剩多少字节未读
  uint8_t readBuffer[512];      // 512 字节的读取缓冲区
};

// 两种提取方式:

// 方式一:读到内存(适用于小文件,如 container.xml)
uint8_t* readFileToMemory(const std::string& filename, size_t& outSize);

// 方式二:流式输出(适用于大文件,如章节 XHTML)
bool readFileToStream(const std::string& filename, Print& out);

Deflate 压缩和 zlib

ZIP 文件内部使用的压缩算法叫 deflate,它是 LZ77 + 哈夫曼编码的组合。你不需要深入理解算法细节,只需要知道:
  • 压缩(deflate):把重复的数据模式替换为更短的”引用”,减小文件体积
  • 解压(inflate):把这些”引用”还原为原始数据
zlib 是 deflate 算法最广泛使用的实现库。CrossPoint 使用了一个内存占用更小的 inflate 实现,只需要很小的工作缓冲区就能完成解压——这里的 512 字节缓冲区就是它的”工作台”。 流式解压的过程类似于”边接水边喝”:
SD 卡上的 ZIP 文件

  ├── 读取 512 字节到缓冲区
  │      │
  │      ├── inflate 解压
  │      │      │
  │      │      └── 输出解压后的数据 → 交给 XML 解析器
  │      │
  │      └── 缓冲区用完 → 再读 512 字节

  └── 直到该文件条目的所有字节都处理完毕
任何时刻,内存中只有 512 字节的压缩数据和一小块解压工作区。一本 50MB 的 EPUB 和一本 500KB 的 EPUB,内存消耗几乎一样。

11.4 自定义二进制缓存格式

XML 解析很慢,但解析结果可以缓存。CrossPoint 设计了两种二进制缓存文件。

book.bin:整本书的元数据缓存

book.bin(版本 3):
┌─────────────────────────────────┐
│ version (1 字节) = 3             │  ← 版本号,格式变了就递增
│ LUT 偏移量 (4 字节)              │  ← Look-Up Table 的位置
│ spine 数量 + TOC 数量            │  ← 有多少章、目录有多少项
│ 书名、作者、封面路径              │  ← 元数据
│ spine 条目数组(href + 累计大小) │  ← 每章的文件路径和累计字节数
│ TOC 条目数组(标题 + 层级)       │  ← 目录结构
│ 查找表                           │  ← 快速定位用
└─────────────────────────────────┘

section.bin:单章的排版缓存

当你打开某一章时,阅读器需要把 HTML 内容排版为一页一页的显示数据。这个过程(解析 HTML、文本断行、计算分页)非常耗时。所以排版结果也会缓存:
section.bin(版本 8):
┌─────────────────────────────────┐
│ version = 8                      │
│ 字体/行距/边距等渲染参数          │  ← 任何参数变化 → 缓存失效,触发重建
│ 页数                             │
│ 各页: TextBlock / ImageBlock     │  ← 每页包含哪些文本块和图片块
│ 脚注列表                         │
│ 页面偏移查找表                   │  ← 快速跳转到第 N 页
│ 锚点 → 页码映射                  │  ← HTML 锚点对应哪一页(用于目录跳转)
└─────────────────────────────────┘

缓存校验:参数变了就重建

这里有一个关键问题:如果用户换了字体或调了行距,之前的排版缓存就作废了——因为字体不同,每行能容纳的字数不同,分页结果完全不同。
// lib/Epub/Epub/Section.cpp
bool Section::loadSectionFile(uint8_t fontId, uint8_t lineCompression, ...) {
  // 读取缓存文件头部,先校验版本号
  // 版本号必须 = 8,否则格式不兼容

  // 然后逐一对比当前渲染参数:
  //   fontId           — 字体
  //   lineCompression  — 行距压缩
  //   extraParagraphSpacing — 段间距
  //   alignment        — 对齐方式
  //   viewportWidth    — 视口宽度
  //   viewportHeight   — 视口高度
  //   hyphenation      — 是否启用断字
  //   embeddedStyle    — 是否使用内嵌样式
  //   imageRendering   — 图片渲染模式

  // 任何一项不匹配 → return false → 触发完整重建
}
这种设计确保了缓存的正确性:宁可多花几秒重新排版,也不能让用户看到错误的排版结果。

11.5 HTML 解析:增量式,1KB 缓冲区

EPUB 的章节内容是 XHTML(一种更严格的 HTML)。CrossPoint 使用 Expat 库来解析 XML。

为什么选 Expat

Expat 是一个事件驱动的 XML 解析器——你把 XML 数据一块一块地喂给它,它每遇到一个标签就回调你的函数。这种模式天然适合流式处理:
ZIP 流式解压 → 每次输出约 1KB 数据 → 喂给 Expat → 触发回调
内存中不需要保存整个 XML 文档,只需要 1KB 的处理缓冲区。

支持的 HTML 标签

CrossPoint 不是一个完整的浏览器,它只支持电子书中常用的标签子集:
标签用途
h1 - h6标题(不同级别有不同字号)
p段落
li, ol, ul列表
div容器
blockquote引用块
img图片
table, tr, td表格
a链接(用于脚注跳转)
em, strong, i, b斜体、粗体
br换行
sup上标(常见于脚注标记)

UTF-8 安全截断

当 1KB 缓冲区满了需要”切一刀”时,有一个容易忽略的陷阱:UTF-8 编码中,一个中文字符占 3 个字节,一个表情符号占 4 个字节。如果恰好从多字节字符的中间切断,就会产生乱码。 CrossPoint 在截断时会检查最后几个字节是否是多字节序列的一部分,如果是,就往前退到完整字符的边界再切。这个小细节保证了中文、日文等多字节文本的正确处理。

11.6 文本排版:让每一行都好看

HTML 解析完成后,得到的是一串带格式标记的文本流。下一步是排版——决定每行显示多少字、在哪里换行、怎么对齐。

动态规划换行算法

最简单的换行策略是”贪心法”:从左往右填字,放不下了就换行。但贪心法的结果往往参差不齐——某些行塞得满满的,紧接着下一行只有几个字。 CrossPoint 使用了一种更聪明的方法:动态规划换行(类似 Knuth-Plass 算法)。它会综合考虑多行的排列效果,通过最小化”丑陋度”(badness)来找到全局最优的换行方案。
贪心法换行(局部最优):
┌──────────────────────────────────┐
│ 这是一段很长的示例文本用来演示两种│  ← 塞满了
│ 换行算法的区别                    │  ← 剩很多空白
└──────────────────────────────────┘

动态规划换行(全局最优):
┌──────────────────────────────────┐
│ 这是一段很长的示例文本用         │  ← 适度留白
│ 来演示两种换行算法的区别         │  ← 均匀分布
└──────────────────────────────────┘
动态规划的代价是更多的计算量,但对于每章只需排版一次(结果缓存到 section.bin)的场景,这个代价完全值得。

两端对齐(Justified)的像素分配

在两端对齐模式下,每一行的左右两端都要对齐。多出来的空白需要均匀分配到单词之间的间隔中。 这里的关键是像素级别的精确分配。如果一行有 5 个间隔,需要分配 13 个像素的额外空白,简单地 13 / 5 = 23——前 3 个间隔各多分 1 个像素(各得 3px),后 2 个间隔各得 2px。在 800 像素宽的墨水屏上,1 个像素的差异肉眼几乎看不出,但如果不做这种精细分配,累积误差会让右边缘参差不齐。

CSS text-indent 支持

EPUB 中的段落通常带有首行缩进(text-indent)。CrossPoint 会解析这个 CSS 属性,并在排版时将其转换为像素偏移量。中文排版的”每段开头空两格”就是通过这个机制实现的。

11.7 多遍渲染流水线

排版完成后,终于到了”画到屏幕上”的环节。但在墨水屏上渲染文字并非一蹴而就——CrossPoint 采用了多达四遍的渲染流水线。
// src/activities/reader/EpubReaderActivity.cpp
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, ...) {
  // === 第 1 遍:字体预热扫描 ===
  auto scope = fcm->createPrewarmScope();
  page->render(renderer, fontId, marginLeft, marginTop);
  scope.endScanAndPrewarm();
  // 这一遍不真正画像素,只是扫描页面用了哪些字形(glyph),
  // 提前从 Flash 加载到缓存中,避免后续渲染时频繁读取 Flash。

  // === 第 2 遍:黑白渲染 ===
  page->render(renderer, fontId, marginLeft, marginTop);
  renderStatusBar();                              // 画底部状态栏
  renderer.displayBuffer(HalDisplay::FAST_REFRESH); // 送到屏幕(快刷模式)
  renderer.storeBwBuffer();                       // 保存黑白缓冲区副本

  // === 第 3-4 遍:灰度抗锯齿(如果用户开启了此功能)===
  if (SETTINGS.textAntiAliasing) {
    // 第 3 遍:渲染灰度低位
    renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
    page->render(...);
    renderer.copyGrayscaleLsbBuffers();

    // 第 4 遍:渲染灰度高位
    renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
    page->render(...);
    renderer.copyGrayscaleMsbBuffers();

    // 将灰度数据叠加到屏幕上
    renderer.displayGrayBuffer();
    renderer.restoreBwBuffer();  // 恢复黑白缓冲区(灰度渲染会修改它)
  }
}

为什么需要这么多遍?

  • 第 1 遍(预热):字体文件存在 Flash 中,随机访问很慢。预热遍先收集所有需要的字形编号,然后批量加载,把随机读取变成顺序读取,速度提升数倍。这在第 9 章已经详细介绍过。
  • 第 2 遍(黑白):墨水屏的基本模式是 1-bit 黑白。这一遍产生清晰的黑白文字,适用于快刷模式。
  • 第 3-4 遍(灰度):如果开启了抗锯齿,需要额外两遍来计算 2-bit 灰度数据。灰度抗锯齿可以让文字边缘更平滑,但代价是多两遍渲染和额外的刷屏时间。
四遍渲染听起来很浪费,但每一遍都在复用同一个排版结果(存在 Page 对象中),不需要重新计算布局。而且字体预热带来的加速效果远大于多一遍扫描的开销。

11.8 阅读进度保存:6 个字节记住你读到哪了

用户合上书,下次打开要能回到上次的位置。这个功能看似简单,但在断电就丢失 RAM 的嵌入式系统上需要持久化到存储中。 CrossPoint 用了极其精简的格式——只需 6 个字节:
void saveProgress(int spineIndex, int currentPage, int pageCount) {
  uint8_t data[6];
  data[0] = currentSpineIndex & 0xFF;         // spine 索引低 8 位
  data[1] = (currentSpineIndex >> 8) & 0xFF;  // spine 索引高 8 位
  data[2] = currentPage & 0xFF;               // 当前页码低 8 位
  data[3] = (currentPage >> 8) & 0xFF;        // 当前页码高 8 位
  data[4] = pageCount & 0xFF;                 // 总页数低 8 位
  data[5] = (pageCount >> 8) & 0xFF;          // 总页数高 8 位
  f.write(data, 6);
}
每个值用 2 个字节(16 位小端序),可以表示 0-65535 的范围。对于电子书来说绰绰有余——65535 页已经是一本超级厚的书了。

字体变更后的页码换算

这里有一个容易被忽略的问题:如果用户换了字体大小,每页能显示的字数变了,之前保存的”第 42 页”就不准了。 CrossPoint 的做法是按比例换算
旧进度:第 42 页 / 共 100 页 → 阅读进度 = 42%
新字体排版后:共 120 页 → 新页码 = 120 × 42% ≈ 第 50 页
这不是精确到字符级别的定位,但对于用户体验来说足够好——你切换字体后可能会偏差几行,但不会跳到完全不同的章节。

11.9 脚注三层跳转

学术类书籍中脚注很常见。点击正文中的脚注标记(如 [1]),跳转到脚注内容;阅读完脚注后,再跳回原来的位置。有些脚注里还有链接,可能还需要再跳一层。 CrossPoint 支持最多 3 层嵌套跳转:
static constexpr int MAX_FOOTNOTE_DEPTH = 3;

struct SavedPosition {
  int spineIndex;   // 章节索引
  int page;         // 页码
};

SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH];  // 位置栈
int footnoteDepth = 0;                             // 当前深度
工作方式就像浏览器的”返回”按钮:
正文第 5 页 → 点击脚注[1] → 跳转到脚注区
  压栈:savedPositions[0] = {spine=3, page=5}
  footnoteDepth = 1

脚注内容 → 点击"参见脚注[7]" → 跳转到另一个脚注
  压栈:savedPositions[1] = {spine=3, page=末尾}
  footnoteDepth = 2

阅读完毕 → 按返回键
  弹栈:恢复到 savedPositions[1] → 回到脚注[1]
  footnoteDepth = 1

再按返回键
  弹栈:恢复到 savedPositions[0] → 回到正文第 5 页
  footnoteDepth = 0
3 层的限制是刻意为之——占用的内存极小(3 个位置 x 每个几字节),而实际中几乎不会有超过 3 层的脚注嵌套。

11.10 断字算法(Liang/TeX)

英文排版中有一个经典问题:如果一个长单词 “internationalization” 恰好出现在行末,塞不下整个单词又不想留太大的空白,怎么办? 答案是断字(Hyphenation)——在合适的位置把单词拆开,加上连字符:
Without hyphenation:              With hyphenation:
┌────────────────────────┐       ┌────────────────────────┐
│ The process of         │       │ The process of inter-  │
│ internationalization   │       │ nationalization is     │
│ is complex.            │       │ complex.               │
└────────────────────────┘       └────────────────────────┘
但不是随便在哪里都能拆——“in-ter-na-tion-al-iza-tion” 有合法的断字点,而 “inte-rnatio-naliz-ation” 就不对。

Liang 断字算法

CrossPoint 使用的是 Liang 断字算法,与 TeX 排版系统完全相同。这个算法由 Franklin Liang 在 1983 年提出,至今仍是排版领域的标准。 它的核心思想是用模式匹配来确定断字点。每种语言有一套预定义的模式(pattern),例如英文的 .ach4 表示”在 ach 之前倾向于断开”。

预编译的二叉 Trie

所有断字模式被预编译为一棵二叉 Trie(前缀树的二叉版本),存放在 Flash 中。这个设计有两个好处:
  1. 零堆分配:查找断字点时不需要分配任何堆内存,只需要在 Flash 中沿着 Trie 的指针走。对于内存紧张的系统来说,这一点至关重要。
  2. 多语言支持:每种语言一棵 Trie,存放在 Flash 的不同位置。支持英语、法语、德语、俄语、西班牙语、意大利语等多种语言。
断字与前面介绍的动态规划换行是协同工作的:换行算法在评估某个换行方案时,会查询断字算法来获取额外的候选断行点。两者结合,才能在窄屏幕上也产生高质量的排版效果。

11.11 TXT 流式分页

不是所有电子书都是 EPUB。纯文本(TXT)文件虽然简单,但也不能一股脑加载到内存里——一本百万字的小说可能有好几 MB。 CrossPoint 采用 8KB 缓冲区逐块读取 的方式处理 TXT 文件:
TXT 文件(可能几 MB)

  ├── 读取前 8KB → 排版 → 记录每页的起始字节偏移量
  ├── 读取下一个 8KB → 继续排版 → 记录偏移量
  ├── ...
  └── 读完整个文件
排版完成后,会生成一个 index.bin 缓存文件,记录每一页对应文件中的哪个字节偏移量。之后翻到第 N 页时,只需要:
  1. 从 index.bin 中查到第 N 页的字节偏移量
  2. fseek() 跳到文件的对应位置
  3. 读取一页大小的数据
  4. 排版并渲染
这样无论 TXT 文件有多大,翻页的时间都是恒定的。

11.12 XTC 漫画渲染

XTC 是 CrossPoint 支持的一种漫画/图片书格式。和文字排版不同,漫画渲染的挑战在于图片数据量大

1-bit 模式:每字节 8 像素

在纯黑白模式下,每个像素只需要 1 个 bit——“黑”或”白”。所以一个字节可以存 8 个像素:
一个字节 = 0b10110010
           │││││││└── 像素 7: 白
           ││││││└─── 像素 6: 黑
           │││││└──── 像素 5: 白
           ││││└───── 像素 4: 白
           │││└────── 像素 3: 黑
           ││└─────── 像素 2: 黑
           │└──────── 像素 1: 白
           └───────── 像素 0: 黑
800x480 分辨率的屏幕,1-bit 模式只需要 800 × 480 / 8 = 48,000 字节 = 约 47KB

2-bit 模式:4 遍渲染实现 4 级灰度

如果漫画需要灰度(4 级:白、浅灰、深灰、黑),每个像素需要 2 个 bit。一个完整的 2-bit 帧缓冲区需要 47KB × 2 = 94KB——快占满 1/4 的 RAM 了。 CrossPoint 的技巧是分 4 遍渲染,复用同一块缓冲区:
第 1 遍:渲染灰度值 = 0(黑色)的像素    → 写入屏幕
第 2 遍:渲染灰度值 = 1(深灰)的像素    → 写入屏幕
第 3 遍:渲染灰度值 = 2(浅灰)的像素    → 写入屏幕
第 4 遍:渲染灰度值 = 3(白色)的像素    → 写入屏幕
每遍只用 1-bit 缓冲区(47KB),4 遍结束后屏幕上就呈现出 4 级灰度的效果。虽然多花了 4 倍的时间,但节省了 47KB 内存——在 380KB 的系统中,这 47KB 可能就是”能用”和”崩溃”的区别。

本章要点

  1. EPUB 本质是 ZIP 包,内部是 HTML + CSS + 元数据 XML。解析流程:container.xml → content.opf → toc → 各章 XHTML。
  2. ZIP 流式解压使用 512 字节缓冲区边读边解压,无论书有多大,内存消耗恒定。
  3. 二进制缓存(book.bin / section.bin)将耗时的 XML 解析和排版结果序列化保存,第二次打开同一本书可以瞬间加载。缓存会校验所有渲染参数,任何变化都触发重建。
  4. HTML 增量解析使用 Expat 库的事件驱动模型,配合 1KB 缓冲区逐块处理,并注意 UTF-8 多字节字符的安全截断。
  5. 动态规划换行比贪心法产生更均匀的排版效果,配合像素级的两端对齐分配,在 800px 宽的墨水屏上实现高质量的文字排版。
  6. 四遍渲染流水线依次完成字体预热、黑白渲染、灰度抗锯齿,在有限的帧缓冲区中实现最佳显示效果。
  7. 阅读进度仅用 6 字节保存(spine 索引 + 页码 + 总页数),字体变更时按比例换算页码。
  8. 脚注跳转使用 3 层位置栈实现压栈/弹栈导航,类似浏览器的前进/后退。
  9. Liang 断字算法与 TeX 排版系统相同,使用预编译的二叉 Trie 实现零堆分配的多语言断字。
  10. TXT 流式分页用 8KB 缓冲区逐块处理,缓存每页的字节偏移量到 index.bin,实现大文件的恒定时间翻页。
  11. XTC 漫画在 2-bit 灰度模式下通过 4 遍渲染复用缓冲区,节省 47KB 内存。

下一章预告:第 12 章我们将探索 CrossPoint Reader 的网络功能——如何在芯片上运行 Web 服务器、实现 WebDAV 协议让 Calibre 无线传书、浏览 OPDS 在线书库,以及与 KOReader 跨设备同步阅读进度。