同一份解析库从 ESP32 搬到了 SF32,为什么中文书第一次真正"读"得舒服了?断行、内存、全角空格——每一处"继承"背后都藏着一次"适配"。
核心方法论:真相只有一个——不轻信表象,逐行验证源码
「柯南办案有个习惯:先到现场,再听证词。移植项目也一样——README 说'解析库原样搬了过来',这算不算数,得打开源码自己核对。继承了什么、改了什么、为什么中文能断行了、内存堆放在哪,每一条都要有真凭实据。这一章,我们沿着 EPUB 从 zip 到屏幕的链路,把'继承'和'中文适配'两件事查个水落石出。」
先回到第 1 章说过的那句判断:移植成功与否,看上游的 lib/Epub 还在不在。现在把这句话"立案"验证——打开仓库,lib/Epub/ 下依然是四个子模块,和 atomic14 上游几乎一一对应:EpubList(EPUB 文件管理,内含 tinyxml2)、Renderer(渲染器模块:帧缓冲、字体、图片合成)、RubbishHtmlParser(XHTML 解析,产出 TextBlock / ImageBlock)、ZipFile(包内文件寻址,配合 miniz 解压)。README「目录结构」写得明明白白,这四个模块连同 lib/miniz-2.2.0(zip 解压)、lib/tjpgd3 与 lib/png/PNGdec(JPEG / PNG 解码)一起构成了完整的解析栈。第 1 章只给了"骨架",这一节我们把每个模块的职责和改动量落到文件上。
为什么这套"家具"能原样搬?因为 EPUB 解析这段逻辑几乎不碰硬件:zip 解包、XML 解析、HTML 标签遍历、断行排版,都是纯数据运算。它只依赖三样东西——标准 C++ 容器(std::vector / std::string)、tinyxml2、以及极少量 RTOS 接入点。真正的差异都集中在"接入点"上:内存从哪来(7.3 的 PSRAM 内存堆)、线程安全怎么保证(解析器里新增的页面互斥锁)、以及渲染器怎么把结果画出来(第 10 章的主角)。换句话说,"继承"的是模块划分与算法,"适配"的是运行环境。
具体到每个模块的改动量,可以精确到文件。先看 RubbishHtmlParser.cpp 的构造函数——它开头声明了 m_pages_mutex = rt_mutex_create("pg_mtx", RT_IPC_FLAG_PRIO),为"页面数组"加了一把互斥锁。这是移植版新增的一行(上游没有),原因是移植版引入了后台字形预热量线(第 8 章细讲),解析 / 排版可能和渲染并发,页面数组必须锁起来。再对比 Renderer/:上游抽象还在,实现全换,EpdiyRenderer 变成了对接 SF32 墨水屏控制器的 SF32PaperRenderer(第 4 章、第 10 章讲)。而 EpubList/ 里的 tinyxml2 是第三方库,一字未动;Epub.cpp 的 content.opf 解析逻辑也和上游逐字一致。
# README「目录结构」节选:lib/ 的解析栈
lib/
├── epdiy/ EPD 驱动核心库
├── Epub/ EPUB 解析专用库
│ ├── EpubList/ EPUB 文件管理模块(含 tinyxml2)
│ ├── Renderer/ 渲染器模块(帧缓冲、字体、图片合成)
│ ├── RubbishHtmlParser/ XHTML 解析模块(TextBlock / ImageBlock)
│ └── ZipFile/ 包内文件寻址(配合 miniz)
├── Images/ 内置图片数据(欢迎/低电量/充电/关机页)
├── miniz-2.2.0/ 轻量级 ZIP 解压库
├── png/PNGdec/ PNG 解码库
└── tjpgd3/ JPEG 解码库
/* lib/Epub/RubbishHtmlParser/RubbishHtmlParser.cpp:移植新增的页面互斥锁 */
RubbishHtmlParser::RubbishHtmlParser(const char *html, int length,
const std::string &base_path)
{
m_base_path = base_path;
m_layout_done = false;
m_blocks_layout_done = false;
m_layout_y = 0;
/* ... */
m_pages_mutex = rt_mutex_create("pg_mtx", RT_IPC_FLAG_PRIO); // 保护 pages[]
parse(html, length);
}
| 模块 | 上游职责 | 移植改动 |
|---|---|---|
EpubList/(含 tinyxml2) | 书目 / 阅读器 / 目录;content.opf 解析 | 原样继承,第三方 tinyxml2 一字未动 |
ZipFile/ | 包内文件寻址(配合 miniz) | 原样继承 |
RubbishHtmlParser/ | XHTML 解析 → TextBlock / ImageBlock | 少量改动:新增页面互斥锁 pg_mtx |
Renderer/ | 渲染抽象(帧缓冲 / 字体 / 图片合成) | 抽象保留,实现重写(SF32 版) |
检验"继承"用 diff,别用感觉。移植前后对比,真正的分界线就藏在"依赖 RTOS 的那几行"里:互斥锁、内存、日志。凡是纯算法代码,上游是什么样,这里就该是什么样——看到 lib/Epub 原样躺着,这本移植教程的地基才算稳。
EPUB 本质是一个 zip 压缩包,里面装着一堆 XML / XHTML 文件。完整链路是:先用 miniz 解包,在 META-INF/container.xml(一份描述"书的主文件在哪"的小 XML)里找到 OEBPS/content.opf 的位置;再用 tinyxml2 解析 content.opf,取出三样东西——metadata(书名、作者、封面)、manifest(所有文件清单)、spine(阅读顺序);最后按 spine 顺序,把每个章节的 XHTML 交给 RubbishHtmlParser 解析成一串 blocks(TextBlock 或 ImageBlock)。这段流程在「ESP32 电子墨水屏」线已经详讲,这里不再展开,只聚焦一个问题:移植后,链路哪里不一样了?
看真实代码能发现三处实质差异。第一,内存来源变了——上游把整本书解压到 ESP32 的堆内存里,移植版把它收敛进 epub_mem.c 定义的专属 PSRAM 内存堆(7.3 讲)。第二,断行算法变了——TextBlock::layout 不再假设"单词以空格分隔",对含中文的段落走了"逐字断行 + 两端对齐 + 首行缩进"的新分支(7.4 讲)。第三,解析器的运行环境从单线程变成多线程——VisitEnter 照旧用 tinyxml2 的 SAX 式回调遍历标签,但页面数组加了互斥锁,允许后台预热量线并行干活。其余环节,包括 content.opf 的 metadata / manifest / spine 解析,都和上游一致。
顺带看一个容易被忽略的细节:RubbishHtmlParser.cpp 顶部维护了一批"标签清单"——HEADER_TAGS(h1–h6)、BLOCK_TAGS(p / li / div / br)、BOLD_TAGS(b)、ITALIC_TAGS(i)、IMAGE_TAGS(img)、SKIP_TAGS(head / table)。VisitEnter 就靠 matches() 查表决定每种标签怎么处理:img 生成 ImageBlock 并留出图片位置;head、table 直接跳过(这是它敢自称 "Rubbish"(垃圾)解析器的原因——不做 CSS、只认标准标签);标题标签生成居中样式的 TextBlock。移植版没有扩充这张表,中英文书都够用——因为真正的差异根本不在标签层面,而在字符层面(下一节见分晓)。
/* lib/Epub/EpubList/Epub.cpp:解析 content.opf(metadata / manifest / spine) */
bool Epub::parse_content_opf(ZipFile &zip, std::string &content_opf_file)
{
char *contents = (char *)zip.read_file_to_memory(content_opf_file.c_str());
tinyxml2::XMLDocument doc;
doc.Parse(contents);
/* ... */
auto metadata = package->FirstChildElement("metadata"); // 书名 / 作者 / 封面
auto manifest = package->FirstChildElement("manifest"); // 文件清单
auto spine = package->FirstChildElement("spine"); // 阅读顺序
/* ... */
}
/* lib/Epub/RubbishHtmlParser/RubbishHtmlParser.cpp:标签清单(节选) */
const char *HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
const char *BLOCK_TAGS[] = {"p", "li", "div", "br"};
const char *IMAGE_TAGS[] = {"img"};
const char *SKIP_TAGS[] = {"head", "table"}; // 跳过:不做 CSS
bool RubbishHtmlParser::VisitEnter(const tinyxml2::XMLElement &element, /* ... */)
{
if (matches(tag_name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
blocks.push_back(new ImageBlock(m_base_path + src)); // 图片块
} else if (matches(tag_name, SKIP_TAGS, NUM_SKIP_TAGS)) {
return false; // 跳过 head / table
} else if (matches(tag_name, HEADER_TAGS, NUM_HEADER_TAGS)) {
currentTextBlock = new TextBlock(CENTER_ALIGN); // 标题居中
}
/* ... */
}
| 环节 | 组件 | 移植后 |
|---|---|---|
| 解包 | miniz | 原样 |
| content.opf 解析 | tinyxml2(Epub.cpp) | 原样 |
| XHTML → blocks | RubbishHtmlParser::VisitEnter | 标签逻辑原样;页面数组加锁 |
| 换行分页 | TextBlock::layout | 中文走新分支(7.4) |
| 渲染 | Renderer | 实现重写(第 10 章) |
链路回顾别贪多。上季已经把"zip → content.opf → XHTML → blocks → pages → 帧缓冲"讲透了,这一季你只需要记住"哪里变了":内存、断行、线程。三处都能在上面那张表里对号入座——对上号,这章就抓住了。
先说一个基础但致命的背景:EPUB 里的文本是 UTF-8 编码的。UTF-8 是一种把 Unicode 码点编码成 1–4 字节的可变长字符编码——ASCII 字符占 1 字节,而绝大多数汉字占 3 字节(码点范围 U+0800–U+FFFF)。这意味着"字符串"不能按字节数切:切在字节中间,就得到一个半截汉字(乱码)。所以解析器第一件事是按 UTF-8 解码字符。看 TextBlock.cpp,它维护一张 utf[] 解码表(首字节 + 掩码决定字符长度),utf8_len() 看首字节判断这个字符占几个字节;has_chinese_char() 则用一条捷径判断段落里有没有中文——常用汉字在 UTF-8 里以 0xE4–0xE9 开头(3 字节),只要扫到首字节落在这个区间就返回 true。这是"真相"式的取证:不看语义,只看字节分布。
与字符编码同样关键的是内存从哪来。上游 atomic14 依赖 ESP32 的 PSRAM;移植版把这个需求做成了显式管理。src/epub_mem.c 定义了 6 MiB(EPUB_MEMHEAP_POOL_SIZE,即 6*1024*1024 字节)的静态缓冲池,用 L2_NON_RET_BSS_SECT 宏把它放进 PSRAM 所在的 l2_non_ret_bss 段("L2" 是 SF32 对 PSRAM 内存域的称呼,"non_ret" 表示这部分断电不保留,正好适合装书)。然后调用 RT-Thread 的 rt_memheap_init 把这段缓冲注册成一个内存堆(memheap,RT-Thread 里可独立管理的一块内存区域,自带分配 / 释放与剩余量统计),名字就叫 "epub_psram_memheap",用 INIT_PREV_EXPORT 让它在系统启动早期就初始化好。
光有堆还不够,关键是让所有解析代码真的用它。epub_mem.c 暴露了四个接口:epub_mem_malloc / free / realloc / calloc,全部转发到 rt_memheap_* 系列。更妙的是它还"接管"了 C++ 的 new:SDK 的 cxx_crt.cpp 里,全局 operator new 调用一个 RT_WEAK 弱符号 cxx_mem_allocate(默认走 rt_malloc);epub_mem.c 给出同名强定义,把 cxx_mem_allocate / cxx_mem_free 改指向 PSRAM 内存堆——于是 lib/Epub/ 里所有 new TextBlock、new char[] 都自动落进 6 MiB 池子,一行不改就完成了"换堆"。最后,#ifdef PKG_FREETYPE 段里还提供 ft_smalloc / ft_sfree / ft_srealloc / ft_scalloc:FreeType 的内存系统(ftsystem.c)正是靠这四个名字申请内存的,于是字形的位图也分配在这同一个 PSRAM 堆里。解析、C++ 对象、字形,三条内存管线殊途同归。
/* src/epub_mem.c:6 MiB PSRAM 内存堆(节选) */
#define EPUB_MEMHEAP_POOL_SIZE (6*1024*1024)
struct rt_memheap epub_psram_memheap;
L2_NON_RET_BSS_SECT_BEGIN(epub_memheap)
L2_NON_RET_BSS_SECT(epub_memheap,
ALIGN(4) static uint8_t epub_psram_memheap_pool[EPUB_MEMHEAP_POOL_SIZE]);
L2_NON_RET_BSS_SECT_END
static int app_cahe_memheap_init(void)
{
rt_memheap_init(&epub_psram_memheap, "epub_psram_memheap",
(void *)epub_psram_memheap_pool, EPUB_MEMHEAP_POOL_SIZE);
return 0;
}
INIT_PREV_EXPORT(app_cahe_memheap_init);
void *epub_mem_malloc(size_t size)
{
uint8_t *p = (uint8_t *)rt_memheap_alloc(&epub_psram_memheap, size);
if (!p) { rt_kprintf("epub_mem_alloc: size %d failed!", size); return 0; }
return p;
}
/* src/epub_mem.c:接管 C++ new 与 FreeType 的内存分配 */
void *cxx_mem_allocate(size_t size) { return epub_mem_malloc(size); }
void cxx_mem_free(void *ptr) { epub_mem_free(ptr); }
#ifdef PKG_FREETYPE
void *ft_smalloc(size_t nbytes) { return epub_mem_malloc(nbytes); }
void ft_sfree(void *ptr) { epub_mem_free(ptr); }
void *ft_srealloc(void *ptr, size_t nbytes) { return epub_mem_realloc(ptr, nbytes); }
#endif /* PKG_FREETYPE */
/* lib/Epub/RubbishHtmlParser/blocks/TextBlock.cpp:UTF-8 解码(节选) */
static int utf8_len(const char ch)
{
int len = 0;
for (int i = 0; i < sizeof(utf); ++i) {
if ((ch & ~utf[i].mask) == utf[i].lead) break;
++len;
}
if (len > 4) assert("invalid unicode.");
return len;
}
static bool has_chinese_char(const std::vector<const char*>& spans)
{
/* ... */
while (*p != '\0') {
int char_len = utf8_len(*p);
if (char_len == 3) {
if (*p >= 0xE4 && *p <= 0xE9) return true; // 汉字首字节区间
}
p += (char_len > 0) ? char_len : 1;
}
return false;
}
| 内存接口 | 作用 | 谁在用 |
|---|---|---|
epub_mem_malloc / free / realloc / calloc | PSRAM 内存堆的分配与释放 | 解析器代码显式调用 |
cxx_mem_allocate / cxx_mem_free | 覆盖 SDK 弱符号,重定向 C++ new / delete | lib/Epub/ 里的 new / delete |
ft_smalloc / ft_sfree / ft_srealloc / ft_scalloc | FreeType 的内存系统接口 | 字形位图、FT_Face 等 |
"内存从哪来"是移植的第一考题。上游只说"PSRAM 必须够",移植版把它落成一句可验证的代码:一个 6 MiB 静态池、一句 L2_NON_RET_BSS_SECT、一个 rt_memheap_init。以后再有人问你"解析要多少内存",答"6 MiB,在 epub_mem.c",就是真相。
has_chinese_char() 用首字节区间 0xE4–0xE9 判断汉字是个启发式:它覆盖了常用汉字区(U+4E00–U+9FA5)的三字节 UTF-8 首字节,但生僻字、扩展区汉字、以及部分日韩汉字并不完全落在这个区间。真要全量检测,得完整解码再查 Unicode 表。这里"够用就好"的取舍,正是嵌入式开发对精确性的常见妥协——知道它不完美,比不知道强得多。
拉丁文本(英文、法语……)的排版基础是单词:单词之间用空格分隔,断行只能断在空格处。而中文完全不同:汉字基本等宽(每个字占一个近乎正方形的格),字与字之间没有空格,断行原则上是"能断在任意相邻两个字之间"。这套差异直接决定了换行算法该往哪边走——上游按"空格分词 + 动态规划最小代价断行"写的 TextBlock::layout 对纯英文很漂亮,但遇到没有空格的中文,会把它当成一个长得没边的"单词",一行根本放不下。所以移植版必须为中文单开一条路。
关键实现在 lib/epdiy/font_ft.c 的 is_delimiter():它把三类字符定义为"断点"。一是空格 / 换行;二是常用汉字区 U+4E00–U+9FA5(判断条件 c >= 0x4e00 && c <= 0x9fa5);三是一串全角标点——全角逗号(U+FF0C)、句号(U+FF0E)、问号(U+FF1F)、顿号(U+3001)、书名号(U+300A / U+300B)、省略号(U+2026)、破折号(U+2014)等。而 epd_get_fixed_width_words() 在限宽内逐字符前进,每遇到一个断点就记下"上一次可断位置";一旦超宽,就从最近的可断位置切开。因为每个汉字都是断点,中文天然支持逐字断行;英文单词内的字母不是断点,仍按词断开——同一套函数同时服务两种语言。
排版细节在 TextBlock::layout 里收尾。对含中文的段落,has_chinese_char(spans) 为真时算出 is_chinese_justified(中文 + 两端对齐 + 无前导缩进),此时它先量一个"字"的宽度 single_char_width,把首行缩进设为两个字宽加 16 像素(indent_width = single_char_width * 2 + extra_spacing)——这正是中文排版的惯例"段首空两格"。另外 has_leading_indent() 专门处理全角空格 U+3000(UTF-8 编码 0xE3 0x80 0x80):它数段首的半角空格与全角空格,满两个全角空格或四个半角空格就认定是"前导缩进",此时放弃两端对齐的缩进补偿,避免首行被"原始缩进 + 自动缩进"双重叠加。这些"中文专属"逻辑,在英文版上游里一行都没有。
/* lib/epdiy/font_ft.c:断点判定——空格 / 汉字区 / 全角标点 */
static bool is_delimiter(uint32_t c)
{
bool sp = (c==' '||c=='\r'||c=='\n')||(c>=0x4e00&&c<=0x9fa5);
bool pu = (c==0xFF0C)||(c==0xFF1F)||(c==0xFF1A)||(c==0xFF01)||
(c==0x3001)||(c==0xFF0E)||(c==0xFF1B)||(c==0x201C)||
(c==0x201D)||(c==0x300A)||(c==0x300B)||(c==0x2026)||
(c==0x2014);
return sp||pu;
}
/* lib/Epub/RubbishHtmlParser/blocks/TextBlock.cpp:中文首行缩进(节选) */
bool has_chinese = has_chinese_char(spans);
bool has_leading = has_leading_indent(spans);
bool is_chinese_justified = (style == JUSTIFIED) && has_chinese && !has_leading;
const int extra_spacing = 16;
if (is_chinese_justified)
{
const char* chinese_char = "字";
const char* temp_end;
int single_char_width = renderer->get_fixed_width_words(
chinese_char, &temp_end, page_width, false, false);
if (temp_end == chinese_char + strlen(chinese_char) && single_char_width > 0)
{
indent_width = single_char_width * 2 + extra_spacing; // 段首空两格
}
}
/* lib/Epub/RubbishHtmlParser/blocks/TextBlock.cpp:全角空格识别(节选) */
// 全角空格 UTF-8 编码:0xE3 0x80 0x80(U+3000)
while (*p != '\0') {
if (*p == ' ') { space_count++; p++; }
else if ((unsigned char)*p == 0xE3 &&
(unsigned char)*(p+1) == 0x80 &&
(unsigned char)*(p+2) == 0x80) {
full_space_count++; p += 3;
} else { break; }
}
if (full_space_count >= 2 || space_count >= 4) return true;
| 维度 | 中文 | 拉丁 |
|---|---|---|
| 字符宽度 | 基本等宽(每字一格) | 不等宽(i 比 W 窄) |
| 词间分隔 | 无空格,逐字 | 空格分词 |
| 断行位置 | 任意相邻两字之间(每字一个断点) | 仅空格处 |
| 两端对齐 | 可逐字均匀撑开 | 单词间加空隙 |
| 段首 | 惯例空两格(2×字宽 + 16 px) | 无强制缩进 |
判断"这段代码是不是为中文写的",就看三个词:has_chinese、0xE3 0x80 0x80、0x4e00..0x9fa5。在移植版里它们成对出现的地方,就是英文版没有、中文书必须的"适配"。
把 7.1–7.4 的发现汇总成一句话:解析的主干是继承,解析的血肉是适配。主干——四个子模块的划分、content.opf 解析、标签遍历、miniz / tinyxml2 依赖——和上游一致,这部分可以放心地说"从 ESP32 项目带过来了"。血肉——UTF-8 解码的显式处理、6 MiB PSRAM 内存堆、C++ new 与 FreeType 的内存重定向、中文断行与缩进、页面数组的互斥锁——是移植版为了"在 SF32 上把中文书读顺"而补上的适配。盘点成清单,就是一张"继承 vs 适配"的分工表。
这五处适配并非各管各的,而是层层咬合:内存堆给了解析和字形一个家(7.3);UTF-8 解码让"汉字是 3 字节"这件事不再出错(7.3);中文断行让换行算法不再把整段中文当成一个超长单词(7.4);互斥锁让解析与后台预热量线能安全并行(7.2)——而预热量线(第 8 章)和渲染器(第 10 章)正是接下来两章的主角。换句话说,第 7 章埋下的每一处适配,都会在后面"翻页快不快、换字体卡不卡、排版美不美"里兑现。顺带一提:epub_mem.c 里的 heap_free_size() 把"主 RAM 剩余 + PSRAM 内存堆剩余"加在一起上报,main.cpp 开机就打两行日志 PSRAM free before font init / after font init——用这个函数,你能亲眼看到字体初始化吃掉了多少内存,这是柯南最爱的"实证"。
最后是总结陈词:验证继承,永远用 diff 和 grep,而不是 README。把"到底改了什么"的答案落到文件、函数、宏三个粒度——epub_mem.c 里改了内存、TextBlock.cpp 里改了断行、RubbishHtmlParser.cpp 里改了锁——你对这个项目的理解就从"听说的"变成了"查过的"。下一章,我们顺着内存管线的下游,看同样由 FreeType 驱动的字体系统。
# 第 7 章变化盘点(epdiy-epub 相对上游 atomic14)
继承(原样搬来):
lib/Epub/EpubList/ content.opf 解析(metadata / manifest / spine)
lib/Epub/ZipFile/ 包内文件寻址,配合 miniz
lib/Epub/RubbishHtmlParser/ 标签遍历逻辑(VisitEnter)
适配(移植新增):
TextBlock.cpp UTF-8 解码、has_chinese、首行缩进、全角空格
font_ft.c is_delimiter 断点、中文逐字断行
RubbishHtmlParser.cpp 页面互斥锁(后台预热量线并发)
epub_mem.c 6 MiB PSRAM 内存堆 + new / FreeType 内存重定向
/* src/epub_mem.c:剩余内存统计(主 RAM + PSRAM 堆之和) */
rt_uint32_t heap_free_size(void)
{
rt_uint32_t heap_free_size = max_sram_size() - used_sram_size();
rt_uint32_t psram_heap_free_size = epub_psram_memheap.available_size;
return heap_free_size + psram_heap_free_size;
}
| 变化点 | 文件 | 原因 |
|---|---|---|
| 6 MiB PSRAM 内存堆 | epub_mem.c | 解析 + 字形集中管理内存 |
| C++ new 重定向 | epub_mem.c | lib/Epub/ 用 new 无需改代码 |
| FreeType 内存重定向 | epub_mem.c(PKG_FREETYPE) | 字形位图也进 PSRAM |
| 页面互斥锁 | RubbishHtmlParser.cpp | 解析 / 渲染并发安全 |
| 中文断行 + 缩进 | TextBlock.cpp / font_ft.c | 中文无空格、需逐字断行 |
给自己留一个"验证闭环":读完这章,去 epub_mem.c 里数一数 rt_memheap_ 开头的调用有几个、epub_mem_ 开头的接口有几个;再去 font_ft.c 里找到 0x4e00。能对上,这一章就真的装进脑子里了——真相比记忆可靠。
打开 lib/Epub/,指出四个子模块各自是什么;再各举一个"原样继承"和"移植新增"的例子(对照 7.1 的表与 7.5 的盘点清单)。
四个子模块是 EpubList / Renderer / RubbishHtmlParser / ZipFile;继承的例子可看 content.opf 解析、标签遍历;新增的例子可看 epub_mem.c、页面互斥锁、中文断行。
EpubList(书目 / 阅读器 / 目录,含 tinyxml2)、Renderer(渲染抽象)、RubbishHtmlParser(XHTML 解析,产出 TextBlock / ImageBlock)、ZipFile(包内文件寻址,配合 miniz)。原样继承:content.opf 的 metadata / manifest / spine 解析、VisitEnter 的标签遍历。移植新增:epub_mem.c 的 6 MiB PSRAM 内存堆、RubbishHtmlParser 的页面互斥锁、TextBlock 的中文断行与首行缩进。
为什么 EPUB 里的汉字不能按字节数切字符串?epub_mem.c 用哪三个手段让"解析、C++ new、FreeType"都吃同一个 PSRAM 内存堆?
汉字在 UTF-8 中占 3 字节,切在中间得乱码;三个手段分别是 epub_mem_malloc 等显式接口、覆盖 cxx_mem_allocate 弱符号、提供 ft_smalloc 等 FreeType 内存接口。
汉字在 UTF-8 中占 3 字节,按字节切字符串会切出半截字符(乱码),所以必须先按 UTF-8 首字节解码出完整字符。三个手段:(1) epub_mem_malloc / free / realloc / calloc 显式指向 6 MiB memheap;(2) cxx_mem_allocate / cxx_mem_free 覆盖 SDK 的 RT_WEAK 弱符号,让 C++ 的 operator new / delete 走同一堆;(3) ft_smalloc / ft_sfree / ft_srealloc / ft_scalloc 四个 FreeType 内存接口(PKG_FREETYPE 段)也指向它。
为什么上游"空格分词 + 动态规划"的换行对中文失效?is_delimiter() 把哪几类字符定义为断点?epd_get_fixed_width_words() 超宽时从哪个位置切开?
中文没有空格,会被当成一个超长单词;断点是空格/换行、常用汉字区 U+4E00–U+9FA5、全角标点;超宽回退到"上一次可断位置",一直没断点则硬切。
中文字间无空格,按"空格分词"会把整段中文当成一个单词,放不下一行。is_delimiter 把空格/换行、常用汉字区(0x4e00–0x9fa5)、一串全角标点(逗号、句号、问号、顿号、书名号、省略号、破折号等)定义为断点。get_fixed_width_words 逐字符累加宽度,每遇到断点记录"上一次可断位置";一旦超宽,从最近的可断位置切开;若始终没有断点,则硬切到下一个字符。
has_leading_indent() 数到什么程度判定"有前导缩进"?为什么判定有前导缩进后,is_chinese_justified 会变成 false?两个全角空格与四个半角空格为什么在视觉上等价?
全角空格 U+3000 的编码是 0xE3 0x80 0x80,设计宽度等于一个汉字;缩进判定是为了避免"原始缩进 + 自动空两格"双重叠加。
满两个全角空格或四个半角空格即判定"有前导缩进"。一旦判定有前导缩进,is_chinese_justified 为 false,TextBlock::layout 就不会再叠加"两个字宽 + 16 px"的自动缩进,避免原始排版里的缩进与自动缩进重复,首行不至于被双重推走。全角空格 U+3000 的宽度恰好等于一个汉字,即约两个半角空格宽度,所以"2 个全角空格 ≈ 4 个半角空格"在视觉上等价。