你管它叫 EPUB,它却偷偷把自己压缩成了 zip——破案要从拆包开始。
核心方法论:真相只有一个
「一桩悬案摆在面前:你管它叫 EPUB,以为它就是一本装订好的书。可把它拆开一看,里面竟是一个压缩包,装着几十个毫无章法的文件——哪个是真凶、哪个是替罪羊,全藏在归档的中央目录里。要破这个案子,得从最不起眼的证据入手:那个被规定必须排在最前面的 mimetype、一份名叫 container.xml 的『地图』、还有一个藏在 META-INF 里的入口。今天我们就顺着这些证据,一层层剥掉 EPUB 的伪装——真相只有一个:它只是一只披着书皮的 zip 罢了。」
先纠正一个直觉:EPUB 不是一种"书",而是一种"打包方式"——本质是一个 zip 归档(说人话:一种把多个文件压缩打包成一个文件的格式)。README 在 "How does it work?" 一节的开门见山原话是:"Epub files are a bit of a pain to parse. Despite the file extension epub, they are actually zip archives containing multiple files."(EPUB 文件有点难解析。尽管扩展名是 epub,它们实际上是由多个文件组成的 zip 压缩包。)也就是说,所谓"打开一本 EPUB",第一步从来不是读文字,而是解压缩(说人话:zip 的还原过程,把打包压缩起来的文件一个个变回原样;反过来,把一堆文件压成一个包就叫"压缩")。只要用 unzip -l 列一下仓库 data/ 里那本 pg43-images.epub,案件现场就全部摊开了。
现场有几件关键"物证"。排在第一位的 mimetype 是一个约 20 字节的文件,内容是 application/epub+zip,规格要求它必须是归档里第一个文件、且不得压缩——它是整本书的"身份证"。然后是 META-INF/container.xml,一份袖珍 XML,它的任务只有一个:告诉你 content.opf 到底藏在哪里。真正的"书"在 OEBPS/ 目录里:content.opf(整本书的清单)、toc.ncx(章节目录)、以及一大堆章节 HTML 和图片。注意那本古登堡书的章节文件名长得离谱(@public@vhost@g@gutenberg@html@files@43@43-h@43-h-1.htm.html)——那是古登堡的 Ebookmaker 工具切分章节时把原始 URL 原样当成了文件名,对解析者来说它们只是"清单里的一串字符",叫什么都无所谓。
# unzip -l 揭穿 pg43-images.epub(真实结构节选)
$ unzip -l /fs/pg43-images.epub
Length Date Time Name
--------- ---------- ----- ----
20 2021-08-01 07:34 mimetype # 身份证:application/epub+zip
221 2021-08-01 07:34 META-INF/container.xml # 地图:指向 content.opf
15000 2021-08-01 07:34 OEBPS/content.opf # 清单:metadata / manifest / spine
7000 2021-08-01 07:34 OEBPS/toc.ncx # 目录:章节目录
14064 2021-08-01 07:34 OEBPS/@public@...@43-h-1.htm.html # 第一章正文
17603 2021-08-01 07:34 OEBPS/@public@...@43-h-2.htm.html # 第二章正文
9699 2021-08-01 07:34 OEBPS/@public@...@43-h-11.htm.html # 末章正文
222685 2021-08-01 07:34 OEBPS/@public@...@images@cover.jpg # 封面图
顺着 container.xml 的指引,我们来到本案的核心现场。这份 container.xml 只有几行,却定义了整本书的"根":rootfiles/rootfile 的 full-path 属性写明了 content.opf 的真实路径(这里是 OEBPS/content.opf),而 media-type="application/oebps-package+xml" 是它的身份标识。之所以绕这一圈,是因为规范允许书商把 content.opf 放在任意目录——阅读器必须"先读地图、再找书",不能拍脑袋猜路径。
/* META-INF/container.xml:全书唯一的"入口地图"(真实内容) */
<?xml version='1.0' encoding='utf-8'?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles>
<rootfile full-path="OEBPS/content.opf"
media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
| 文件 | 在书中的角色 | 本项目解析与否 |
|---|---|---|
mimetype | 身份证,须为归档第一个文件且不压缩 | 不解析(只在 zip 规范层面要求) |
META-INF/container.xml | 地图,指向 content.opf 路径 | 解析(找 full-path) |
OEBPS/content.opf | 清单:metadata / manifest / spine | 解析(第 8 章) |
OEBPS/toc.ncx | 章节目录 | 解析(第 8 章) |
OEBPS/*.htm.html | 章节正文(XHTML) | 解析(第 9 章) |
OEBPS/*.jpg / *.png | 封面与插图 | 解码显示(第 13 章) |
判断一个文件到底是不是 EPUB,最快的方法不是看扩展名,而是拆包看两点:归档里第一个文件是不是 mimetype,以及它的内容是不是 application/epub+zip。在开发机上 file xxx.epub 也会直接告诉你 "Zip archive data"。EPUB 的"伪装"只骗得过操作系统图标,骗不过会拆包的人。
把 zip 解开需要压缩库,本项目用的是 miniz(说人话:一个极小的 zip 压缩解压库)——一个"整个压缩库塞进一个 C 文件"的极简实现。miniz 是 zlib 的独立重实现(同样是 DEFLATE 算法),API 兼容 zlib 最常用的那部分,但完全独立、MIT 许可,特别适合嵌入式。然而有个坑:miniz 其实早就被内置在 ESP32 的 ROM 里了——ROM 说人话就是芯片里只读的出厂存储,ESP32 出厂时自带一些固件例程。README 原话是:"Miniz is actually built into the ESP32 ROM, but support for multifile archives is disabled."(miniz 实际被内置在 ESP32 的 ROM 中,但对多文件归档的支持被禁用了。)ROM 里的 miniz 只留了单流压缩/解压(inflate/deflate)的能力,能解一段数据,却没法"打开一个 zip、按文件名挑文件、逐个解出来"——这正是我们需要的 zip 归档能力,等于被砍掉了。
所以 README 给出的答案是换一个 fork:"To read the file I'm using a nice zip library from here: lbernstrone/miniz. This library has been modified to work on the ESP32 with PSRAM."(我用了 lbernstrone/miniz 这个不错的 zip 库,它已被修改为在带 PSRAM 的 ESP32 上工作。)注意 README 把仓库名拼成了 lbernstrone(多打了一个 r),真实仓库是 lbernstone/miniz-esp32。而具体到这份代码,实际编译的是 lib/miniz-2.2.0/ 里 vendored 的官方 miniz 2.2.0:仓库里的 miniz.c 是把所有实现"合并"(amalgamation)后的单文件产物,配一个 miniz.h 头,直接用 #include "miniz.h" 引进来参与编译。从"README 说的 fork"到"仓库里 vendored 的 stock miniz",又是一处文档与代码的漂移。
# lib/miniz-2.2.0/:vendored 的官方 miniz 2.2.0(amalgamation 产物)
lib/miniz-2.2.0/
miniz.c # 全部实现,单个 C 文件(约 32 万字节)
miniz.h # 公共 API:mz_zip_*、tdefl_*、tinfl_*
ChangeLog.md # 版本变更记录
readme.md # 官方 readme
examples/ # 示例代码
配置层面,真正参与编译的开关在 platformio.ini 的 [common] 段。其中一个宏 -DMINIZ_NO_ZLIB_COMPATIBLE_NAMES 的作用,platformio.ini 的注释写得很直白:"stop miniz conflicting with zlib which is used by the png library"——因为 png 库引入了 zlib,如果 miniz 还导出 zlib 兼容名的函数(inflate/deflate 等),两者就会重名冲突;关掉兼容名后,miniz 的所有符号统一用 mz_ 前缀,各走各路。另一个 -DBOARD_HAS_PSRAM 告诉构建系统"这块板子有 PSRAM"。那么 PSRAM 到底在哪一步被用上?答案藏在 sdkconfig 里,见下面的配置块。
; platformio.ini [common] build_flags:miniz 与 zlib 如何共存
build_flags =
; maximim speed
-Ofast
; needed to stop the png library tying to pull in Arduino.h
-D__MCUXPRESSO
; stop miniz conflicting with zlib which is used by the png library
-DMINIZ_NO_ZLIB_COMPATIBLE_NAMES
; device has PRSRAM
; and should be used for ram intensive display work
-DBOARD_HAS_PSRAM
# sdkconfig.lilygo_t5_47:PSRAM 被配置成"大分配自动入住"
CONFIG_ESP32_SPIRAM_SUPPORT=y
CONFIG_SPIRAM_TYPE_AUTO=y
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_SPIRAM_USE_MALLOC=y # malloc/calloc 可自动使用 PSRAM
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384 # ≤16 KB 的分配留在内部 SRAM
把这两条合起来看,真相就水落石出了:CONFIG_SPIRAM_USE_MALLOC=y 之后,ESP-IDF 的 malloc/calloc 对超过 SPIRAM_MALLOC_ALWAYSINTERNAL(16384 字节)的请求,会优先从 PSRAM 分配。而 ZipFile::read_file_to_memory 里正是用 calloc(file_size + 1, 1) 为每一章正文申请内存——古登堡书的章节 HTML 动辄几十 KB,于是自动落进 PSRAM。这就是 README 那句"解析 EPUB 需要 PSRAM"的底层机制:不是哪一行代码显式写了 ps_malloc,而是靠堆(说人话:动态内存,程序运行时可申请、用完可释放的内存区)分配策略,让大块内容天然绕开内部那 520 kB 的窄道。
| 来源 / 配置 | 角色 | 备注 |
|---|---|---|
| ESP32 ROM 内置 miniz | 只提供单流 inflate/deflate | README:多文件归档支持被禁用 |
lbernstone/miniz-esp32 | README 提及的 fork | README 拼成 lbernstrone(文档手滑) |
lib/miniz-2.2.0/ | 实际参与编译的 vendored 官方 miniz | API 一律 mz_* 前缀 |
-DMINIZ_NO_ZLIB_COMPATIBLE_NAMES | 避免与 png 库的 zlib 重名冲突 | platformio.ini 注释原话 |
CONFIG_SPIRAM_USE_MALLOC | 大分配自动用 PSRAM | 配合 ALWAYSINTERNAL=16384 |
SPIRAM_MALLOC_ALWAYSINTERNAL=16384 是一条阈值线:请求 ≤16 KB 时留在内部 SRAM(速度更快、稳定),请求 >16 KB 时才转入 PSRAM。这意味着"章节文件多大"直接决定了它住哪:2 KB 的简介页住内部 SRAM,100 KB 的章节住 PSRAM。想亲眼验证,可以盯 EpubReader.cpp 里 esp_get_free_heap_size() 打出的 "Before read html" / "After read html" 两行日志,看看解压一章到底吃掉了多少内存。
miniz 的功能很全,但调用它打开一个 zip、定位文件、解压,需要一串小心翼翼的操作序列。作者把它包进了一个叫 ZipFile 的小类,放在 lib/Epub/ZipFile/(ZipFile.h + ZipFile.cpp),README 也专门点名了这个封装:"I've encapsulated the needed ZIP function in a small wrapper class which can be found here: ZipFile." 这个类的门面极小:构造时传入 zip 文件路径,对外只暴露两个方法——read_file_to_memory()(把某个条目整个解压进堆内存,返回指针)和 read_file_to_file()(把某个条目解压成磁盘上的文件)。调用方完全不需要知道 zip 是怎么被打开的。
/* lib/Epub/ZipFile/ZipFile.h:整个封装的门面(真实代码) */
class ZipFile
{
private:
std::string m_filename;
public:
ZipFile(const char *filename)
{
m_filename = filename;
}
~ZipFile() {}
// read a file from the zip file allocating the required memory for the data
uint8_t *read_file_to_memory(const char *filename, size_t *size = nullptr);
bool read_file_to_file(const char *filename, const char *dest);
};
read_file_to_memory() 是绝对主力,内部是一条规整的六步流水线。第一步 mz_zip_reader_init_file() 打开归档并解析中央目录;第二步 mz_zip_reader_locate_file_v2() 按文件名在中央目录里定位,拿到条目索引 file_index;第三步 mz_zip_reader_file_stat() 读出该条目的元数据(重点是解压后大小 file_stat.m_uncomp_size 与文件名 m_filename);第四步按大小 calloc(file_size + 1, 1) 申请内存;第五步 mz_zip_reader_extract_to_mem() 真正解压;第六步 mz_zip_reader_end() 释放归档资源。中间任何一步失败,都会 ESP_LOGE 打印 mz_zip_get_error_string(zip_archive.m_last_error) 并清理现场返回 nullptr——错误处理收敛在一处,是封装最实在的收益。
/* ZipFile.cpp:read_file_to_memory 六步流水线(节选) */
uint8_t *ZipFile::read_file_to_memory(const char *filename, size_t *size)
{
mz_zip_archive zip_archive;
memset(&zip_archive, 0, sizeof(zip_archive)); // ① 清空归档句柄
bool status = mz_zip_reader_init_file(&zip_archive, m_filename.c_str(), 0); // ② 打开 zip
if (!status) { /* ESP_LOGE(TAG, "mz_zip_reader_init_file() failed!\n"); ... */ return nullptr; }
mz_uint32 file_index = 0; // ③ 按名定位
if (!mz_zip_reader_locate_file_v2(&zip_archive, filename, nullptr, 0, &file_index)) { /* ... */ return nullptr; }
mz_zip_archive_file_stat file_stat; // ④ 读元数据,拿解压后大小
if (!mz_zip_reader_file_stat(&zip_archive, file_index, &file_stat)) { /* ... */ return nullptr; }
size_t file_size = file_stat.m_uncomp_size;
uint8_t *file_data = (uint8_t *)calloc(file_size + 1, 1); // ⑤ 多申请 1 字节放 null 终止符
if (!file_data) { /* ... */ return nullptr; }
status = mz_zip_reader_extract_to_mem(&zip_archive, file_index, file_data, file_size, 0); // ⑥ 解压
if (!status) { free(file_data); mz_zip_reader_end(&zip_archive); return nullptr; }
mz_zip_reader_end(&zip_archive); // 收尾:释放归档资源
if (size) *size = file_size;
return file_data;
}
第六步那个 + 1 是本案最容易被忽视的细节。代码注释原话:"get the file size - we do this all manually so we can add a null terminator to any strings"(我们手动处理大小,就是为了给任何字符串补一个 null 终止符)。因为 EPUB 里读出来的东西大多是文本(XML、XHTML),解析器习惯拿到一个以 '\0' 结尾的字符串;如果解压出来的原始字节没有结尾哨兵,把指针直接当 C 字符串用就会读到野内存。多申请一字节并 calloc 清零,等于给每个条目都备好了"句号"。另一个方法 read_file_to_file() 则反过来:遍历全部条目(mz_zip_reader_get_num_files())比对文件名,找到后用 mz_zip_reader_extract_file_to_file() 直接解压成磁盘文件,适合"不想让大文件占堆内存"的场景。
| miniz API | 在 ZipFile 里的用途 |
|---|---|
mz_zip_reader_init_file() | 打开 zip 文件、解析中央目录 |
mz_zip_reader_locate_file_v2() | 按文件名定位条目,输出索引 |
mz_zip_reader_file_stat() | 读条目元数据(m_uncomp_size、m_filename) |
mz_zip_reader_extract_to_mem() | 解压条目到内存缓冲 |
mz_zip_reader_get_num_files() | 统计归档内文件总数(read_file_to_file 遍历用) |
mz_zip_reader_end() | 释放归档占用的所有资源 |
mz_zip_get_error_string() | 把 m_last_error 转成可读错误串 |
把 miniz 的六步流程"翻译"给调用方,是 ZipFile 存在的全部意义:Epub 类只想要"给我 OEBPS/content.opf 的内容",不关心 zip 怎么开、怎么找、怎么解、怎么关。封装把变化隔离在 ZipFile.cpp 一个文件里,也让"打开失败 → 打日志 → 清理 → 返回空"这条错误路径只写一遍。读 ZipFile.cpp 时记住这个心法:接口极小,恰好够用。
有了 ZipFile,读出书里的任意文件就是一行调用的事。但"读哪个文件"不是拍脑袋决定的——整本书的读取顺序是:先读 META-INF/container.xml 得知 content.opf 的真实路径(这一步在 Epub::find_content_opf_file 里,第 8 章展开),再读 content.opf 拿到 manifest 与 spine 清单,最后才按 spine 顺序逐章读正文。负责"通用取文件"的是 Epub::get_item_contents():它先对 item_href 做一次路径规范化(处理 .. 回退、清理重复斜杠),再交给 ZipFile::read_file_to_memory。注意它每次新建一个 ZipFile、读完即返回指针——zip 的打开/关闭开销被当作"每次读取的固定成本"接受了。
/* Epub.cpp:get_item_contents——按需解压任意条目(真实代码) */
uint8_t *Epub::get_item_contents(const std::string &item_href, size_t *size)
{
ZipFile zip(m_path.c_str()); // 每次读取都新开一个归档
std::string path = normalise_path(item_href); // 规范化:把 ".." 回退掉
auto content = zip.read_file_to_memory(path.c_str(), size);
if (!content) { /* ESP_LOGE(TAG, "Failed to read item %s", path.c_str()); */ return nullptr; }
return content;
}
真正把正文"读出来并交出去"的地方在 EpubReader::parse_and_layout_current_section()。它先 epub->get_spine_item(state.current_section) 拿到当前章节在 spine 里的路径,再 get_item_contents(item) 解压出整章 HTML,然后 reinterpret_cast<char *> 成字符串喂给 RubbishHtmlParser(第 9 章的主角),用完立刻 free(html)。这一步印证了全书的按章内存模型:Epub::load() 只把清单(opf/ncx)解进内存,正文是"翻到哪章才解压哪章",解完即释放——一本 1.3 MiB 的书,永远不会整本占着堆。
/* EpubReader.cpp:按 spine 顺序解压当前章节(真实代码) */
std::string item = epub->get_spine_item(state.current_section); // 本章的 href
std::string base_path = item.substr(0, item.find_last_of('/') + 1);
char *html = reinterpret_cast<char *>(epub->get_item_contents(item)); // 解压本章
parser = new RubbishHtmlParser(html, strlen(html), base_path); // 交给解析器
free(html); // 用毕即还
parser->layout(renderer, epub); // 排版成页(第 10-11 章)
把 7.1 到 7.4 串成一张案卷,整条"拆包流水线"就闭环了:Epub::load() 在书单阶段把书"验明正身"(读 container.xml → content.opf → toc.ncx),此后 EpubReader 按 spine 逐章取正文。自始至终,我们动手的只有 zip 这个唯一真凶;XML 解析、HTML 解析都只是它的"下游证人"。而作为对照,read_file_to_file() 提供了一条"解压到磁盘"的支线——虽然本仓库的渲染路径走的是内存,但这个 API 留给了"大图片先落盘再读"的扩展空间。
/* 一条 EPUB 的拆包流水线(本章主角) */
pg43-images.epub
│ ZipFile::read_file_to_memory("META-INF/container.xml")
▼
container.xml ──► 拿到 content.opf 的 full-path(第 8 章解析)
│ ZipFile::read_file_to_memory("OEBPS/content.opf")
▼
content.opf ──► metadata / manifest / spine(第 8 章解析)
│ EpubReader::get_spine_item(i) → get_item_contents()
▼
chapter.htm.html ──► RubbishHtmlParser(第 9 章)
│ 按章解压、用完 free——整本书永不整本驻留内存
| 读出的文件 | 调用方 | 用途 |
|---|---|---|
META-INF/container.xml | Epub::find_content_opf_file | 定位 content.opf 路径 |
OEBPS/content.opf | Epub::parse_content_opf | 书名 / 封面 / manifest / spine |
OEBPS/toc.ncx | Epub::parse_toc_ncx_file | 章节目录条目 |
OEBPS/...-h-N.htm.html | EpubReader::parse_and_layout_current_section | 章节正文 → 解析排版 |
OEBPS/...@images@cover.jpg | EpubList::render | 书单封面缩略图 |
观察 EpubReader.cpp 里那串 ESP_LOGD(TAG, "After read html: %d", esp_get_free_heap_size()) 日志,你能直接"看到"内存曲线:打开书、读清单、解压一章、排版、释放,每一步前后 esp_get_free_heap_size() 都在变化。把这条曲线记下来,再去读 7.2 的 PSRAM 阈值,你就会明白为什么作者反复强调"解析 EPUB 需要内存"——它不是一句口号,是堆上的实打实的峰值。
用 unzip -l(或归档管理器)拆开 fixtures/oebps.epub 或 data/pg43-images.epub,回答:(a) 归档里第一个文件叫什么?(b) META-INF/container.xml 的 full-path 指向哪个文件?(c) 列出 OEBPS/ 下至少三类不同扩展名的文件。
对照 7.1 的现场清单:身份证 mimetype、地图 container.xml、正文 *.htm.html、样式 *.css、图片 *.jpg。
(a) mimetype,内容为 application/epub+zip。(b) OEBPS/content.opf(这就是 content.opf 的实际路径)。(c) 例如章节正文 OEBPS/@public@...@43-h-1.htm.html、样式 OEBPS/pgepub.css / OEBPS/0.css、封面 OEBPS/@public@...@images@cover.jpg,外加 OEBPS/toc.ncx。
读 lib/Epub/ZipFile/ZipFile.cpp 的 read_file_to_memory(),列出完整的 miniz 调用序列(从打开到关闭)。再解释:为什么 calloc(file_size + 1, 1) 要比解压后大小多申请一个字节?若去掉它,把返回指针当 C 字符串用会怎样?
序列是 init → locate → stat → extract → end 五步关键调用;file_stat.m_uncomp_size 决定申请多大;想想字符串结尾哨兵 '\0'。
序列:mz_zip_reader_init_file(打开归档)→ mz_zip_reader_locate_file_v2(按名定位到 file_index)→ mz_zip_reader_file_stat(读 m_uncomp_size 等元数据)→ mz_zip_reader_extract_to_mem(解压到 calloc 出的缓冲)→ mz_zip_reader_end(释放归档)。多申请的那一字节用于放字符串终止符 '\0'(源码注释原话:"we do this all manually so we can add a null terminator to any strings");去掉后,把指针当 C 字符串读会越界读到相邻内存,XML/XHTML 解析器会拿到"没尾巴"的字符串,可能读到野内存甚至崩溃。
结合 7.2 的 sdkconfig,解释 CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384 的含义。设想三种情况:某章 HTML 解压后约 2 KB、约 60 KB、约 300 KB,它们分别会被分配到内部 SRAM 还是 PSRAM?这对"解析 EPUB 为什么需要 PSRAM"说明了什么?
CONFIG_SPIRAM_USE_MALLOC=y 时,calloc 请求 >16384 字节会优先走 PSRAM;<=16384 留内部 SRAM。
SPIRAM_MALLOC_ALWAYSINTERNAL=16384 是一条阈值:请求 ≤16,384 字节时 malloc/calloc 强制留在内部 SRAM;请求 >16,384 字节时自动转向 PSRAM(若可用)。因此 2 KB 的章节住内部 SRAM,60 KB 与 300 KB 的章节都落进 PSRAM。古登堡书的章节正文动辄几十上百 KB,而 ZipFile::read_file_to_memory 正是用 calloc(file_size+1) 申请——所以"解析 EPUB 需要 PSRAM"不是玄学,而是每一章解压时堆上真实发生的大块分配。
对比两种阅读策略:A. Epub::load() 时把整本书所有章节一次性解压进内存;B. 本项目做法——只把清单(opf/ncx)解进内存,正文按 spine 逐章解压、读完即 free。请至少从内存峰值、打开一本书的耗时、实现复杂度三个角度说明 B 为什么更适合 ESP32。若某本书只有 3 章且每章很小,B 的劣势又是什么?
想想 EpubReader.cpp 里的 esp_get_free_heap_size() 日志与 get_item_contents 每次新建 ZipFile 的开销;再想"逐章解压"每次都要重新打开 zip 的代价。
内存峰值:方案 A 的峰值 ≈ 整本书解压后大小(1.3 MiB 的书就是 1.3 MiB 常驻),方案 B 的峰值 ≈ 最大单章 + 排版缓冲,翻页时只住一章,内存曲线平缓得多。打开耗时:A 在 load() 时就要解压全书,打开书很慢;B 只在"翻到那一章"时才解压,打开书只读清单,响应快。实现复杂度:A 需要一次建好全部章节的索引与缓冲、管理整本书生命周期;B 只需一个 get_item_contents 通用入口,天然与"翻到哪算哪"的阅读模型吻合。B 的劣势在于每次切换章节都要重新打开 zip、定位、解压(get_item_contents 里每次 new ZipFile),对于只有 3 章、每章很小的小书,这个固定开销相对放大,反而显得有些浪费——但权衡下来,把"整本书驻留内存"换成"按需解压",对内存极窄的 ESP32 是明显的净收益。