第7章:EPUB 解析:继承与中文适配

同一份解析库从 ESP32 搬到了 SF32,为什么中文书第一次真正"读"得舒服了?断行、内存、全角空格——每一处"继承"背后都藏着一次"适配"。

🔍

本章导师:柯南

核心方法论:真相只有一个——不轻信表象,逐行验证源码

「柯南办案有个习惯:先到现场,再听证词。移植项目也一样——README 说'解析库原样搬了过来',这算不算数,得打开源码自己核对。继承了什么、改了什么、为什么中文能断行了、内存堆放在哪,每一条都要有真凭实据。这一章,我们沿着 EPUB 从 zip 到屏幕的链路,把'继承'和'中文适配'两件事查个水落石出。」

7.1 继承自上游:lib/Epub 的模块划分

先回到第 1 章说过的那句判断:移植成功与否,看上游的 lib/Epub 还在不在。现在把这句话"立案"验证——打开仓库,lib/Epub/ 下依然是四个子模块,和 atomic14 上游几乎一一对应:EpubList(EPUB 文件管理,内含 tinyxml2)、Renderer(渲染器模块:帧缓冲、字体、图片合成)、RubbishHtmlParser(XHTML 解析,产出 TextBlock / ImageBlock)、ZipFile(包内文件寻址,配合 miniz 解压)。README「目录结构」写得明明白白,这四个模块连同 lib/miniz-2.2.0(zip 解压)、lib/tjpgd3lib/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 原样躺着,这本移植教程的地基才算稳。

7.2 解析流程回顾:从 zip 到 XHTML 块

EPUB 本质是一个 zip 压缩包,里面装着一堆 XML / XHTML 文件。完整链路是:先用 miniz 解包,在 META-INF/container.xml(一份描述"书的主文件在哪"的小 XML)里找到 OEBPS/content.opf 的位置;再用 tinyxml2 解析 content.opf,取出三样东西——metadata(书名、作者、封面)、manifest(所有文件清单)、spine(阅读顺序);最后按 spine 顺序,把每个章节的 XHTML 交给 RubbishHtmlParser 解析成一串 blocks(TextBlockImageBlock)。这段流程在「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 并留出图片位置;headtable 直接跳过(这是它敢自称 "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 → blocksRubbishHtmlParser::VisitEnter标签逻辑原样;页面数组加锁
换行分页TextBlock::layout中文走新分支(7.4)
渲染Renderer实现重写(第 10 章)
柯南提示

链路回顾别贪多。上季已经把"zip → content.opf → XHTML → blocks → pages → 帧缓冲"讲透了,这一季你只需要记住"哪里变了":内存、断行、线程。三处都能在上面那张表里对号入座——对上号,这章就抓住了。

7.3 中文适配(一):UTF-8 与内存管理

先说一个基础但致命的背景: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 TextBlocknew 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 / callocPSRAM 内存堆的分配与释放解析器代码显式调用
cxx_mem_allocate / cxx_mem_free覆盖 SDK 弱符号,重定向 C++ new / deletelib/Epub/ 里的 new / delete
ft_smalloc / ft_sfree / ft_srealloc / ft_scallocFreeType 的内存系统接口字形位图、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 表。这里"够用就好"的取舍,正是嵌入式开发对精确性的常见妥协——知道它不完美,比不知道强得多。

7.4 中文适配(二):中文 vs 拉丁排版差异

拉丁文本(英文、法语……)的排版基础是单词:单词之间用空格分隔,断行只能断在空格处。而中文完全不同:汉字基本等宽(每个字占一个近乎正方形的格),字与字之间没有空格,断行原则上是"能断在任意相邻两个字之间"。这套差异直接决定了换行算法该往哪边走——上游按"空格分词 + 动态规划最小代价断行"写的 TextBlock::layout 对纯英文很漂亮,但遇到没有空格的中文,会把它当成一个长得没边的"单词",一行根本放不下。所以移植版必须为中文单开一条路。

关键实现在 lib/epdiy/font_ft.cis_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_chinese0xE3 0x80 0x800x4e00..0x9fa5。在移植版里它们成对出现的地方,就是英文版没有、中文书必须的"适配"。

7.5 移植变化盘点:继承 vs 适配

把 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.clib/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。能对上,这一章就真的装进脑子里了——真相比记忆可靠。

章末练习

练习 1:继承 vs 适配 入门

打开 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 的中文断行与首行缩进。

练习 2:UTF-8 与内存 入门

为什么 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 段)也指向它。

练习 3:中文断行 进阶

为什么上游"空格分词 + 动态规划"的换行对中文失效?is_delimiter() 把哪几类字符定义为断点?epd_get_fixed_width_words() 超宽时从哪个位置切开?

提示

中文没有空格,会被当成一个超长单词;断点是空格/换行、常用汉字区 U+4E00–U+9FA5、全角标点;超宽回退到"上一次可断位置",一直没断点则硬切。

参考答案

中文字间无空格,按"空格分词"会把整段中文当成一个单词,放不下一行。is_delimiter 把空格/换行、常用汉字区(0x4e00–0x9fa5)、一串全角标点(逗号、句号、问号、顿号、书名号、省略号、破折号等)定义为断点。get_fixed_width_words 逐字符累加宽度,每遇到断点记录"上一次可断位置";一旦超宽,从最近的可断位置切开;若始终没有断点,则硬切到下一个字符。

练习 4:全角空格与缩进 挑战

has_leading_indent() 数到什么程度判定"有前导缩进"?为什么判定有前导缩进后,is_chinese_justified 会变成 false?两个全角空格与四个半角空格为什么在视觉上等价?

提示

全角空格 U+3000 的编码是 0xE3 0x80 0x80,设计宽度等于一个汉字;缩进判定是为了避免"原始缩进 + 自动空两格"双重叠加。

参考答案

满两个全角空格或四个半角空格即判定"有前导缩进"。一旦判定有前导缩进,is_chinese_justified 为 false,TextBlock::layout 就不会再叠加"两个字宽 + 16 px"的自动缩进,避免原始排版里的缩进与自动缩进重复,首行不至于被双重推走。全角空格 U+3000 的宽度恰好等于一个汉字,即约两个半角空格宽度,所以"2 个全角空格 ≈ 4 个半角空格"在视觉上等价。