书名的真凶、封面的去向、章节的顺序,全部写在这份小小的 XML 里。
核心方法论:真相只有一个
「上一章我们证明了 EPUB 是一具 zip 的尸体,可尸体不会说话。真正能作证的,是藏在 OEBPS/content.opf 里的那份口供——它一分为三:metadata 供出书名与作者,manifest 交出了整本书的文件清单,spine 则交代了该按什么顺序翻开每一页。要听懂这份口供,我们得请一位精通 XML 的翻译官:TinyXML2。今天我们就跟着 Epub 类,把这份口供逐字问出来——真相,藏在一层层的 FirstChildElement 里。」
上一章我们把 content.opf 定位为"整本书的清单"。现在把它拆开,它清清楚楚地分成三部分,README 的原话是:"The content.opf contains three sections, metadata, manifest and spine. The metadata section contains the title of the book, the author and the cover image. The manifest section contains the list of files in the epub archive. The spine section tells you what order to read the files in."(content.opf 包含三个区块:metadata、manifest 和 spine。metadata 含书名、作者与封面图;manifest 含归档内所有文件的清单;spine 告诉你按什么顺序读这些文件。)三个区块各司其职,说人话:metadata 是书的元信息,manifest 是文件清单,spine 是阅读顺序。以仓库里那本 pg43-images.epub(《The Strange Case of Dr. Jekyll and Mr. Hyde》)为例,三部分一目了然。
先说 metadata(元数据)。它的内容一眼扫过去全是"书的基本信息":书名 dc:title、作者 dc:creator、语言 dc:language、主题 dc:subject……真正的线索藏在最后一行 <meta name="cover" content="item1"/>——它用 content="item1" 指向了清单里一个名叫 item1 的条目(说人话:条目就是清单里登记一个文件的一行记录,一章正文、一张封面各占一条),而那个条目就是封面图。也就是说,封面不是 metadata 直接给出来的,而是 metadata 与 manifest 联手"指认"出来的:metadata 说"封面叫 item1",manifest 才告诉你 item1 到底是哪个文件。这种"跨区块用 id 咬合"的机制贯穿整份 opf,是破译它的钥匙。
/* OEBPS/content.opf:metadata + manifest(真实内容节选,已精简) */
<package xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0" unique-identifier="id">
<metadata>
<dc:creator>Robert Louis Stevenson</dc:creator>
<dc:title>The Strange Case of Dr. Jekyll and Mr. Hyde</dc:title>
<dc:language>en</dc:language>
<dc:subject>Horror tales</dc:subject>
<meta name="cover" content="item1"/> # 封面 → 指认 manifest 里的 item1
</metadata>
<manifest>
<item href="@public@...@images@cover.jpg" id="item1" media-type="image/jpeg"/> # 封面图
<item href="pgepub.css" id="item2" media-type="text/css"/>
<item href="@public@...@43-h-0.htm.html" id="item5" media-type="application/xhtml+xml"/>
<item href="@public@...@43-h-1.htm.html" id="item6" media-type="application/xhtml+xml"/>
# ... 其余章节 ...
<item href="toc.ncx" id="ncx" media-type="application/x-dtbncx+xml"/> # 目录文件,id 固定为 ncx
<item href="wrap0000.html" id="coverpage-wrapper" media-type="application/xhtml+xml"/>
</manifest>
manifest(清单)是"文件登记簿":每一个 <item> 都有 id、href、media-type 三个属性,分别对应"代号"、"文件路径"、"文件类型"。注意两个约定俗成的 id:封面图常被 metadata 指认为 item1,而目录文件 toc.ncx 的 id 固定是 ncx——Epub 类正是靠"找 id 为 ncx 的条目"来记住目录文件在哪的。最后是 spine(书脊),它不重复文件内容,只按阅读顺序列出一串 <itemref idref="...">,靠 idref 回指 manifest 里的 id。spine 里第几个、就按第几个读——这就是"阅读顺序"的物理形态。本书的 spine 以 wrap0000.html(封面页)开头,之后是 item5、item6……直到末章。
/* OEBPS/content.opf:spine —— 阅读顺序(真实内容节选) */
<spine toc="ncx"> # toc="ncx":目录文件是清单里 id 为 ncx 的条目
<itemref idref="coverpage-wrapper" linear="yes"/> # 第 1 位:封面页
<itemref idref="item5" linear="yes"/> # 第 2 位:第一章
<itemref idref="item6" linear="yes"/> # 第 3 位:第二章
# ... 按 manifest 里的 id 依次引用 ...
<itemref idref="item16" linear="yes"/> # 最后一位:末章
</spine>
| 区块 | 内容 | 解析后存到 Epub 的哪里 |
|---|---|---|
metadata | 书名、作者、语言、封面指认 | m_title、m_cover_image_item |
manifest | 文件清单:id → href + media-type | 局部 std::map items;m_toc_ncx_item |
spine | 阅读顺序:一串 itemref | m_spine(std::vector<std::pair<...>>) |
(另)toc.ncx | 人看的章节目录 | m_toc(std::vector<EpubTocEntry>) |
理解 opf 的关键是记住这套"id 咬合"的关系:metadata 用 content="item1" 指认封面 → manifest 用 id="item1" 定义封面路径 → spine 用 idref="item5" 决定章节顺序。id 是贯穿三份口供的"证物编号",解析代码里所有的"查表"都在追这些编号。顺着编号读,三部分就串成了一条链。
content.opf、container.xml、toc.ncx 都是 XML(说人话:一种用标签描述数据的文本格式,像 <metadata>、<spine> 这样一对对尖括号标签把内容包起来)。解析 XML 就得请解析器——说人话就是"把文本读成程序能用的结构"的程序。项目用的是 TinyXML2(一个极小的 XML 解析库),README 对它的评价只有一句:"a very small and simple XML parser"(一个非常小且简单的 XML 解析器)。它由 leethomason 维护,整个库就是 tinyxml2.cpp + tinyxml2.h 两个文件、零外部依赖、纯 C++ 实现,通过 platformio.ini 的 lib_deps 从 GitHub 直接拉取(https://github.com/leethomason/tinyxml2.git)。对 ESP32 这样"内存按 KB 算"的环境,这几乎是唯一合理的选择——而像 libxml2 这类功能完整的重型解析器,光是自身代码量就足以把固件体积顶爆。
TinyXML2 的用法是典型的 DOM 风格:先 XMLDocument doc; doc.Parse(contents) 把整段 XML 解析成一棵内存里的元素树,然后用 doc.FirstChildElement("package") 取根元素、element->NextSiblingElement("item") 在兄弟节点间游走、element->Attribute("href") 读属性、element->GetText() 取元素文本。解析结果用一个枚举 tinyxml2::XML_SUCCESS 判断成败,失败时还能用 doc.ErrorIDToName(result) 把错误码翻译成人话。这段"取根 → 找子 → 遍历兄弟"的套路,会在这章接下来的每份 XML 里反复出现,是整个 EPUB 解析的地基。
; platformio.ini:TinyXML2 作为唯一第三方依赖拉取
lib_deps =
https://github.com/leethomason/tinyxml2.git
/* Epub.cpp:TinyXML2 的最小使用范式(真实代码风格) */
tinyxml2::XMLDocument doc;
auto result = doc.Parse(contents); // 解析整段 XML
if (result != tinyxml2::XML_SUCCESS)
{
ESP_LOGE(TAG, "Error parsing content.opf - %s", doc.ErrorIDToName(result));
return false;
}
auto package = doc.FirstChildElement("package"); // 取根元素
auto metadata = package->FirstChildElement("metadata"); // 往下找子元素
auto title = metadata->FirstChildElement("dc:title");
m_title = title->GetText(); // 读元素文本
为什么嵌入式偏爱 TinyXML2 而不是更"强大"的解析器?答案在"恰到好处"四个字。第一,体积小:两个文件、无依赖,不引入额外的运行时环境。第二,容错好:面对古登堡书那些不那么规范、缺东少西的 XML,它很少崩溃,解析失败只是返回一个错误码。第三,API 直白:取元素、取属性、取文本三个动作覆盖了本项目全部需求,没有用不上的重型特性。取舍逻辑和第 1 章技术栈那节一脉相承——约束匹配:需求是"读几份小 XML",就选"刚好够用"的库,不为用不上的功能买单。
| TinyXML2 API | 在本项目中的用途 |
|---|---|
XMLDocument::Parse() | 解析整段 XML 文本,建 DOM 树 |
FirstChildElement() | 取第一个同名子元素(如 package、metadata) |
NextSiblingElement() | 遍历同名兄弟元素(如一个个 item / navPoint) |
Attribute() | 读属性值(如 full-path、media-type、idref) |
GetText() | 读元素文本(如书名) |
ErrorIDToName() / XML_SUCCESS | 解析成败判断与错误信息 |
注意 Epub.cpp 里一个耐人寻味的细节:doc.Parse(contents) 之后,紧接着就是 free(contents)——原文那份 char 数组在解析完立刻被还掉。因为 DOM 解析已经把内容"复制"进了元素树,原文不再需要。这种"解析完立即释放原文"的习惯,配合上一章的 PSRAM 阈值,就是嵌入式内存管理里最朴素也最有效的"随用随还"。
真正的解析落在 lib/Epub/EpubList/Epub.h(声明)与 Epub.cpp(实现)的 Epub 类里。先看它的"记忆":m_title(书名)、m_cover_image_item(封面路径)、m_toc_ncx_item(目录文件路径)、m_base_path(opb 所在目录)、m_spine(阅读顺序,std::vector<std::pair<std::string, std::string>>)、m_toc(章节目录)。这些字段,正好对应 8.1 表格里那一行行"解析后存到哪里"。而把它们填满的,是 Epub::load() 里三条依次执行的流水线:find_content_opf_file → parse_content_opf → parse_toc_ncx_file。
第一步 find_content_opf_file(),把 7.1 的地图真正用起来:它用 ZipFile::read_file_to_memory("META-INF/container.xml") 读出 container.xml,用 TinyXML2 解析,然后沿着 container → rootfiles → rootfile 一层层 FirstChildElement 往下钻,遍历每个 rootfile,专门找 media-type 等于 "application/oebps-package+xml" 的那个,把它 full-path 属性的值当作 content.opf 的路径。为什么不能直接假设路径是 OEBPS/content.opf?因为规范允许书商把 opf 放任意目录——只有地图说了算。拿到路径后,m_base_path = content_opf_file.substr(0, content_opf_file.find_last_of('/') + 1) 顺手算出目录前缀(这里是 "OEBPS/"),为后面拼接相对路径做准备。
/* Epub.cpp:find_content_opf_file —— 读地图、找真凶(真实代码节选) */
char *meta_info = (char *)zip.read_file_to_memory("META-INF/container.xml");
if (!meta_info) { /* ESP_LOGE(TAG, "Could not find META-INF/container.xml"); */ return false; }
tinyxml2::XMLDocument meta_data_doc;
auto result = meta_data_doc.Parse(meta_info);
free(meta_info); // 解析完立刻还掉原文
if (result != tinyxml2::XML_SUCCESS) { /* ... */ return false; }
auto container = meta_data_doc.FirstChildElement("container");
auto rootfiles = container->FirstChildElement("rootfiles");
auto rootfile = rootfiles->FirstChildElement("rootfile");
while (rootfile)
{
const char *media_type = rootfile->Attribute("media-type");
if (media_type && strcmp(media_type, "application/oebps-package+xml") == 0)
{
const char *full_path = rootfile->Attribute("full-path");
if (full_path) { content_opf_file = full_path; return true; }
}
rootfile = rootfile->NextSiblingElement("rootfile"); // 遍历下一个 rootfile
}
第二步 parse_content_opf(),把 8.1 的三份口供逐个问出来。先 package → metadata → dc:title 读出书名存入 m_title;再在 metadata 里遍历 <meta>,专门找 name="cover" 的那个,把它的 content 值("item1")记作 cover_item。接着进 manifest:把每个 <item> 的 id 与 m_base_path + href 配对塞进一个 std::map<std::string, std::string> items,顺手做两件事——若该 id 等于 cover_item 就把 href 存进 m_cover_image_item(封面就此落案),若 id 是 "ncx" 就把 href 存进 m_toc_ncx_item。最后进 spine:按序遍历 <itemref>,把每个 idref 在 items 里查到对应路径,m_spine.push_back(std::make_pair(id, items[id]))。一张 id→路径 的对照表 items,串起了封面、目录和整个阅读顺序。
/* Epub.cpp:parse_content_opf —— manifest 建表 + spine 定序(真实代码节选) */
auto metadata = package->FirstChildElement("metadata");
auto title = metadata->FirstChildElement("dc:title");
m_title = title->GetText(); // ① 书名落案
auto cover = metadata->FirstChildElement("meta"); // ② 找 meta[name=cover]
while (cover && cover->Attribute("name") &&
strcmp(cover->Attribute("name"), "cover") != 0)
cover = cover->NextSiblingElement("meta");
auto cover_item = cover ? cover->Attribute("content") : nullptr;
auto manifest = package->FirstChildElement("manifest");
std::map<std::string, std::string> items; // id → 实际路径
auto item = manifest->FirstChildElement("item");
while (item)
{
std::string item_id = item->Attribute("id");
std::string href = m_base_path + item->Attribute("href"); // 拼上目录前缀
if (cover_item && item_id == cover_item) m_cover_image_item = href; // ③ 封面落案
if (item_id == "ncx") m_toc_ncx_item = href; // ④ 目录落案
items[item_id] = href;
item = item->NextSiblingElement("item");
}
auto spine = package->FirstChildElement("spine");
auto itemref = spine->FirstChildElement("itemref");
while (itemref)
{
auto id = itemref->Attribute("idref");
if (items.find(id) != items.end())
m_spine.push_back(std::make_pair(id, items[id])); // ⑤ 按序入队阅读顺序
itemref = itemref->NextSiblingElement("itemref");
}
第三步 parse_toc_ncx_file() 读章节目录,放到 8.4 展开。三条流水线走完,Epub 对象就"验明正身"了——书有书名、有封面、有阅读顺序、有目录。此后任何"取某一章正文"的需求,都通过 get_item_contents()(第 7 章讲过)按路径去 zip 里解压。值得一提的是作者还留了防护:get_spine_item() 用 m_spine.at()(带越界检查的访问)外加 try/catch 捕获 std::out_of_range,越界时回退到第 0 项,注释原话是 "so it does not crashes when you want to go after last page"(翻过最后一页也不至于崩溃)。
| 步骤 | 函数 | 产出 / 填充的字段 |
|---|---|---|
| ① 找 opf 路径 | find_content_opf_file | content_opf_file、m_base_path |
| ② 解析清单 | parse_content_opf | m_title、m_cover_image_item、m_toc_ncx_item、m_spine |
| ③ 解析目录 | parse_toc_ncx_file | m_toc(章节目录条目) |
Epub 类对"越界"的防护是不一致的:get_spine_item() 用 try/catch 兜底回退到第 0 项,而 get_toc_item() 直接 m_toc[toc_index](std::vector 的 operator[],不检查越界)。读源码时要把这两种访问方式分开对待——前一个能扛住翻过末页,后一个在目录索引越界时会行为未定义。这正是真实项目里"补丁式防护"的常态,也是你该自己留意的地方。
书单界面上看到的"章节目录",数据不是来自 spine,而是来自另一份文件 toc.ncx——8.1 的 manifest 里 id 为 ncx 的那个条目。为什么目录和正文顺序要分家?因为二者职责不同:toc.ncx 是给人看的目录(标题文本 + 跳转锚点),spine 是给机器读的正文顺序。它们是两张表,靠 href 对齐。先看第一张表怎么建:parse_toc_ncx_file() 解析 ncx → navMap → navPoint,对每个 navPoint 取 navLabel/text 的文本作标题、content 的 src 作目标地址,再把 href 按 '#' 拆成"章节路径 + 锚点"两段(如 ...43-h-1.htm.html 与 pgepubid00002),最后每条压成 EpubTocEntry(title, href, anchor, 0) 塞进 m_toc。真实目录里,第 3 条正是 "STORY OF THE DOOR",指向第一章的 #pgepubid00002。
/* Epub.cpp:parse_toc_ncx_file —— 从 toc.ncx 建章节目录(真实代码节选) */
auto ncx = doc.FirstChildElement("ncx");
auto navMap = ncx->FirstChildElement("navMap");
auto navPoint = navMap->FirstChildElement("navPoint");
while (navPoint)
{
auto navLabel = navPoint->FirstChildElement("navLabel")
->FirstChildElement("text")->FirstChild();
std::string title = navLabel->Value(); // 目录标题
auto content = navPoint->FirstChildElement("content");
std::string href = m_base_path + content->Attribute("src");
size_t pos = href.find('#'); // 拆 href#anchor
std::string anchor = "";
if (pos != std::string::npos)
{
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
}
m_toc.push_back(EpubTocEntry(title, href, anchor, 0));
navPoint = navPoint->NextSiblingElement("navPoint"); // 下一个目录项
}
两张表建好后,Epub 还欠一张"对照表":用户在目录里选中的是 m_toc 的下标,可阅读器真正要读的是 m_spine 的下标——两者不一定一致(目录第 3 项可能对应正文第 5 章)。get_spine_index_for_toc_index() 就是干这个的:它遍历 m_spine,找出 href 与所选目录项 m_toc[toc_index].href 完全相等的那个 spine 下标;找不到就默认 0(回到书开头)。这正是 8.1 说的"靠 href 对齐"的具体实现。UI 层则靠 EpubToc 类(lib/Epub/EpubList/EpubToc.h/.cpp)驱动:load() 时构造 Epub 并 load(),render() 每页渲染 ITEMS_PER_PAGE = 6 条目录,get_selected_toc() 返回 get_spine_index_for_toc_index(state.selected_item)。
/* Epub.cpp:把"目录下标"翻译成"正文章节下标"(真实代码) */
int Epub::get_spine_index_for_toc_index(int toc_index)
{
// the toc entry should have an href that matches the spine item
for (int i = 0; i < m_spine.size(); i++)
{
if (m_spine[i].second == m_toc[toc_index].href) // 按 href 对表
return i;
}
// not found - default to the start of the book
return 0; // 找不到则回到书开头
}
/* EpubToc.cpp:get_selected_toc —— 把目录选中项交给阅读器 */
uint16_t EpubToc::get_selected_toc()
{
return epub->get_spine_index_for_toc_index(state.selected_item);
}
把整条链路接起来,就是你在屏幕上按 SELECT 后发生的全部:书单里选中一本书 → main.cpp 构造 EpubToc 进入目录态 → EpubToc::load() 重新 new Epub(...) 并 load()(再一次走完 8.3 的三步,把目录解析出来)→ EpubToc::render() 每页画 6 条目录 → 用户按 SELECT → get_selected_toc() 对表出章节下标 → EpubReader::set_state_section() 写入 state.current_section → 进入阅读态,EpubReader 按这个下标用 get_spine_item() 取正文。到这一步,"找书 → 看目录 → 读正文"的完整闭环,终于在数据层面全部打通。
| 类 / 结构 | 职责 | 关键成员 |
|---|---|---|
Epub | 解析整本书:清单 + 目录 + 按路径取内容 | load()、get_title()、get_spine_item()、get_toc_item()、get_spine_index_for_toc_index() |
EpubTocEntry | 一条章节目录项 | title、href、anchor、level |
EpubToc | 章节目录界面 | load()、next()/prev()、render()、get_selected_toc() |
EpubReader | 阅读某章并翻页 | set_state_section()、next()/prev()、parse_and_layout_current_section() |
记住"目录 ≠ 正文顺序"这个易错点:toc.ncx 的条目数量和顺序,与 spine 的 itemref 通常对不上(本书 oebps.epub 实测目录 12 条、spine 13 项,就是证据)。任何"目录第几条 ↔ 正文第几章"的换算,都必须经过 get_spine_index_for_toc_index 那张 href 对照表,不能想当然地按下标直接对应。
拆开 fixtures/oebps.epub,打开 OEBPS/content.opf,回答:(a) metadata 里 <meta name="cover" .../> 的 content 值是什么?(b) manifest 里 id 为 ncx 的条目指向哪个文件?(c) spine 的 toc 属性值是什么?(d) spine 第一个 itemref 的 idref 指向谁?
对照 8.1 的代码块逐行找:封面指认在 metadata,目录文件在 manifest,顺序在 spine。
(a) item1(即封面图在 manifest 里的 id)。(b) toc.ncx(media-type="application/x-dtbncx+xml")。(c) ncx——告诉阅读器"目录文件是 manifest 里 id 为 ncx 的条目"。(d) coverpage-wrapper,也就是 wrap0000.html 封面页,之后才是正文章节 item5、item6……。
读 Epub::find_content_opf_file(),回答:(a) 它先读的是 zip 里的哪个文件?为什么不能直接假设 opf 路径是 OEBPS/content.opf?(b) 判定"这个 rootfile 就是 opf"的依据是哪个属性的哪个取值?(c) 若 META-INF/container.xml 缺失,read_file_to_memory 会返回什么?
先读 META-INF/container.xml,再沿 container → rootfiles → rootfile 钻取;判定依据是 media-type;回忆第 7 章 read_file_to_memory 失败时的返回值。
(a) 先读 META-INF/container.xml,因为规范允许书商把 content.opf 放在任意目录(甚至不在 OEBPS/ 下),只有 container.xml 的 full-path 说了算,拍脑袋猜路径会翻车。(b) 依据 media-type 属性等于 "application/oebps-package+xml",命中后取它的 full-path 属性值作为 opf 路径。(c) read_file_to_memory 定位不到文件会返回 nullptr,于是 find_content_opf_file 打印 "Could not find META-INF/container.xml" 并返回 false,最终 Epub::load() 失败——在设备上会进入"10 秒后重启"的兜底分支。
读 Epub::parse_content_opf(),把"封面图路径是怎么一步步定位出来的"完整写出来。特别说明:(a) metadata 里哪一行是"封面指认"?(b) cover_item 存的是什么?(c) manifest 遍历时凭什么把某条 item 的 href 存进 m_cover_image_item?(d) href 为什么要拼上 m_base_path?
链路:metadata 的 <meta name="cover" content="item1"/> → cover_item="item1" → manifest 里 item_id == cover_item 的那条 → 拼上 m_base_path 存入 m_cover_image_item。
(a) <meta name="cover" content="item1"/>,它的 name="cover" 表明身份、content="item1" 给出 manifest 里的 id。(b) cover_item 存的就是这个 content 值("item1"),即封面在 manifest 里的 id。(c) manifest 遍历时,当某条 <item> 的 id 恰好等于 cover_item,就把它的 href 存进 m_cover_image_item——封面由"metadata 指认 + manifest 定义"联合定位。(d) manifest 里的 href 是相对 content.opf 所在目录的(如 @public@...@images@cover.jpg),必须拼上 m_base_path(OEBPS/)才能得到 zip 里的完整路径。
读 parse_toc_ncx_file()、get_spine_index_for_toc_index() 与 EpubToc::get_selected_toc(),回答:(a) toc.ncx 的目录项 href 为何要按 '#' 拆成两段?(b) 为什么"目录下标"不能直接当作"spine 下标"用?(c) 若用户在目录里选了《STORY OF THE DOOR》(真实测试中它在 m_toc 的下标是 2,href 为 ...43-h-1.htm.html),get_spine_index_for_toc_index(2) 会返回多少?返回的该下标在 spine 里对应哪个 id?
(a) 锚点用于章节内定位;(b) 目录与 spine 是两张表、数目与顺序都不同(oebps.epub 实测 12 条目录 vs 13 个 spine 项);(c) 在 test/test_epub_index_load.cpp 与 test/test_epub_load.cpp 里找真实数字。
(a) content src 形如 ...43-h-1.htm.html#pgepubid00002,'#' 前是章节文件路径(供 get_spine_item 取正文),'#' 后是章节内锚点(供精确定位,本项目未深入使用但仍保留在 EpubTocEntry.anchor)。(b) 目录条目数与 spine 项数、顺序都未必一致(oebps.epub 实测目录 12 条、spine 13 项,且第一条目录"书名"对应的正文文件是 43-h-0.htm.html),所以必须用 href 对表换算。(c) 从真实测试数据看,STORY OF THE DOOR 的 toc 项(下标 2)href 为 OEBPS/@public@...@43-h-1.htm.html;get_spine_index_for_toc_index(2) 遍历 m_spine,找到 href 相等的那一项,返回下标 2——它对应 spine 里 id 为 item6 的条目(spine 下标 0=封面页、1=item5/43-h-0、2=item6/43-h-1)。若对不上表,则返回 0,回到书开头兜底。