第9章:HTML 解析器——RubbishHtmlParser

一份 XHTML 里有几十种标签,我们只留七种;其余的全部排除。(说人话:XHTML 是 HTML 的严格写法,而 HTML 就是网页的标记语言——用一对对尖括号标签描述文字结构;标签是像 <p><b> 这样被尖括号包起来的名字,元素则指标签连同它包住的那段内容。)

🎩

本章导师:福尔摩斯

核心方法论:排除不可能

「排除一切不可能的,剩下的即使再不可思议,那也是真相。上一章我们拿到了一份 XHTML 口供——可它太啰嗦了:几十种标签、几百条样式、甚至还有一整张 table 数据表。嵌入式处理器没有耐心听完所有废话。所以我只留下能划分段落的、能加粗倾斜的、能放图片的、能换行的几种标签,其余一律排除。这个方法很蠢,蠢到作者干脆把解析器命名为 Rubbish(垃圾)。可正是这份"主动变蠢"的克制,让一整章 HTML 变成一串干净的 blocks——也才让换行、分页、渲染全都变得可能。」

9.1 为什么叫 "Rubbish":只取最小必要标签

名字就暴露了一切。RubbishHtmlParser.h 的头注释是一句大实话:a very stupid xhtml parser - it will probably work for very simple cases but will probably fail for complex ones(一个非常愚蠢的 XHTML 解析器——对付简单情况大概够用,复杂情况大概会挂)。作者不装不端,直接在类注释里承认这是个"垃圾解析器"。可垃圾不等于没用:README 说得很明白,"I've limited our parsing to a set of minimum tags that are enough to give us the basic structure of the book without making things too complicated"(我把解析限制在一组最小标签上,足以还原书的基本结构,又不会把事情搞复杂)。在嵌入式上,划定范围本身就是一种设计。

为什么"不解析 CSS"反而是加分项?CSS(说人话:控制网页样式的语言,字号、颜色、行距都归它管;本项目故意不解析它)是 EPUB 排版的真正主力,可它对嵌入式几乎全是负担:字号、行距、margin、颜色……每一项都对应额外的解析器与运行时开销。而电纸屏是单色的,项目只内置 4 种字体样式(说人话:样式就是粗体、斜体这类文字属性;这里内置 regular / bold / italic / bold-italic 四种,README 原话 "I've only included 4 font styles")。所以 README 直接承认:"we just use the standard HTML tags such as <h1>, <h2> etc. and <b> and <i>"(只用 h1、h2 这类标准标签和 b、i)。把 CSS 整个排除掉,不是做不到,而是"在约束下主动选择不做"——这正是第 1 章讲的约束匹配。

具体留哪些?看 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)。前五张是"认识谁",最后一张是"不认识谁"——见到就整棵子树都不理。整个解析器的全部"知识",就是这六张小小的字符串数组,外加一个 matches() 助手函数。在动手看表之前,先分清两个概念:块级标签(说人话:占一整行、自成一段的标签,如 p、h1、div)与行内标签(说人话:不另起一行、嵌在文本里只改样式的标签,如 b、i)。下面这张表,就是把六张表里的标签按这两类归了归类。

/* RubbishHtmlParser.cpp:整个解析器的"全部知识"——六张标签表 */
const char *HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
const char *BLOCK_TAGS[]  = {"p", "li", "div", "br"};
const char *BOLD_TAGS[]   = {"b"};
const char *ITALIC_TAGS[] = {"i"};
const char *IMAGE_TAGS[]  = {"img"};
const char *SKIP_TAGS[]   = {"head", "table"};
# README · "Parsing the ePub contents":作者自己列出的最小标签集
# - Block tags  <div>, <p>, <h1>, <h2> etc...
# - Inline tags <b>, <i>
# - Images     <img>
# - Line breaks <br>
# - The CSS content of the ePub file is not parsed(CSS 不解析)
分类标签解析器的动作
块级(正文)p / li / div结束当前文本块,新建一个两端对齐(JUSTIFIED)文本块
块级(标题)h1h6标记粗体,新建一个居中对齐(CENTER_ALIGN)文本块
换行br结束当前块,沿用原样式另起一块
行内b / i只翻转样式标志(is_bold / is_italic),不新建块
图片imgsrc,新建一个 ImageBlock
跳过head / table整棵子树忽略,不产生任何块
福尔摩斯提示

为什么连 <table> 都要排除?因为表格是 HTML 里结构最复杂的元素之一:tr / td / th、跨行跨列、边框合并,真要渲染一张表,工作量几乎等于重写一个小型排版引擎。古登堡书偶尔出现表格,但绝大多数正文段落并不依赖它。排除 table 与排除 CSS 是同一个逻辑:用 1% 的代码量实现 99% 的阅读体验,剩下 1% 的场景直接放弃。排除,也是一种能力。

9.2 解析策略:块级起新块、行内并入当前块

核心策略只有两条。第一条:遇到块级标签,结束手头的块、另起一个块;第二条:遇到行内标签(b / i),不新建块,只改变"后续文本的样式"这一状态,文本继续并入当前块。整个实现里只有两个成员 is_bold / is_italic 承担"当前样式"的职责。这两条规则,就构成了从 XHTML 流式结构到 blocks 序列的全部转换逻辑。

代码落在三个回调里。因为 RubbishHtmlParser 继承自 tinyxml2::XMLVisitor,TinyXML2 遍历 DOM 时会回调它:进入元素调用 VisitEnter遇到文本节点调用 Visit离开元素调用 VisitExitVisitEnter 里用 matches() 判断标签名属于哪张表并做出动作,VisitExit 里把 is_bold / is_italic 复位,Visit 里把文本连同当前样式一起交给 addText——样式像接力棒一样,从进入标签一路传到文本节点。

/* RubbishHtmlParser.cpp:VisitEnter —— 见标签行事(节选,图片分支见 9.3) */
bool RubbishHtmlParser::VisitEnter(const tinyxml2::XMLElement &element,
                                    const tinyxml2::XMLAttribute *firstAttribute)
{
  const char *tag_name = element.Name();
  /* ① 图片:建 ImageBlock(完整代码见 9.3) */
  if (matches(tag_name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { /* ... */ }
  else if (matches(tag_name, SKIP_TAGS, NUM_SKIP_TAGS))
    return false;                  // ② head / table:整棵子树跳过
  else if (matches(tag_name, HEADER_TAGS, NUM_HEADER_TAGS))
  {
    is_bold = true;                  // ③ 标题一律按粗体处理
    startNewTextBlock(CENTER_ALIGN);  //    且居中对齐
  }
  else if (matches(tag_name, BLOCK_TAGS, NUM_BLOCK_TAGS))
  {
    if (strcmp(tag_name, "br") == 0)
      startNewTextBlock(currentTextBlock->get_style());  // ④ br:沿用样式换行
    else
      startNewTextBlock(JUSTIFIED);   //    p / li / div:两端对齐新块
  }
  else if (matches(tag_name, BOLD_TAGS, NUM_BOLD_TAGS))
    is_bold = true;                 // ⑤ 行内:只翻样式标志
  else if (matches(tag_name, ITALIC_TAGS, NUM_ITALIC_TAGS))
    is_italic = true;
  return true;
}
/* RubbishHtmlParser.cpp:块的生命周期 + 文本并入 */
void RubbishHtmlParser::startNewTextBlock(BLOCK_STYLE style)
{
  if (currentTextBlock)
  {
    if (currentTextBlock->is_empty())   // 手头是空块 → 不新建,改样式复用
    {
      currentTextBlock->set_style(style);
      return;
    }
    else
      currentTextBlock->finish();     // 非空 → 封口,结束当前块
  }
  currentTextBlock = new TextBlock(style);   // 新建一块,挂进 blocks 尾部
  blocks.push_back(currentTextBlock);
}

/* 文本节点:连同当前样式并入当前块 */
bool RubbishHtmlParser::Visit(const tinyxml2::XMLText &text)
{
  addText(text.Value(), is_bold, is_italic);
  return true;
}

"带样式并入"落在 addText 里:它先把文本里的 HTML 实体(如 &amp;&quot;)用 replace_html_entities() 还原成真实字符,再调 currentTextBlock->add_span(text, is_bold, is_italic) 把文本与样式一起交给文本块。样式不是"整段记住"的,而是逐词记录的——下一章会看到,add_span 里每个词都存一个样式位掩码(BOLD_SPAN / ITALIC_SPAN),排版时量宽度要按词带样式量。这一环的粒度,直接决定了第 10 章算法的输入。

回调时机职责
VisitEnter进入元素按标签名建块 / 跳过子树 / 翻样式标志
Visit遇到文本节点把文本连同当前样式并入当前块
VisitExit离开元素复位 is_bold / is_italic
福尔摩斯提示

startNewTextBlock 里那句"空块复用"是全文件最精巧的 5 行。看 <div></div><p>text</p>:解析器一开始就备好了一个空块,进入 <div> 时它还是空的,直接改样式复用;到 <p> 依旧复用——直到真的文字 "text" 进来,这个块才被填上内容。如果每次都新建块,这段 HTML 会留下两个"没有任何词"的空壳,分页时每个空壳都会被当作需要换行的占位,页面上凭空多出空行、页数虚增。复用空块,正是为"块级标签里没有文字"的常见情况兜底。

9.3 数据结构:blocks 列表(文本块 / 图片块)

解析的产物是 std::list<Block *> blocks——一个链表,每一项是一块内容。Block 是抽象基类,只声明四个纯虚方法(layoutrenderdumpgetType)加一个可覆盖的 finish()BlockType 枚举只有两个值:TEXT_BLOCKIMAGE_BLOCK。也就是说,整章电子书在解析之后被抽象成了"文本块 / 图片块"两种元素,其余全部被排除——这种极端的二元抽象,让后续 layout 与 render 的代码可以写得非常平直。

文本块是重头戏。TextBlock 把一段连续文本切成一串单词,记录每个词的宽度、x 坐标与样式位掩码,并用公开的 line_breaks 向量记下"每一行最后一个词的下标"。这些就是第 10 章换行算法要用到的全部原料。图片块则简单得多:ImageBlock 只存一个 m_src(图片路径),layout 时交给 renderer 读宽高、按比例缩放、水平居中——它自身不参与任何"断行"的智力活动。

/* Block.h:块是内容的最小单位,只分两种类型 */
typedef enum
{
  TEXT_BLOCK,
  IMAGE_BLOCK
} BlockType;

class Block
{
public:
  virtual ~Block() {}
  virtual void layout(Renderer *renderer, Epub *epub, int max_width = -1) = 0;
  virtual void dump() = 0;
  virtual BlockType getType() = 0;
  virtual bool isEmpty() = 0;
  virtual void finish(){};
};
/* TextBlock.h:一段文本 = 一串词 + 一堆逐词度量数据 */
typedef enum
{
  BOLD_SPAN = 1,
  ITALIC_SPAN = 2,
} SPAN_STYLE;

typedef enum
{
  JUSTIFIED = 0,      /* 两端对齐(正文默认) */
  LEFT_ALIGN = 1,
  CENTER_ALIGN = 2,   /* 居中(标题) */
  RIGHT_ALIGN = 3,
} BLOCK_STYLE;

class TextBlock : public Block
{
  std::vector<const char *> spans;       /* 原始文本片段 */
  std::vector<const char *> words;       /* 切好的每个词 */
  std::vector<uint16_t> word_widths;  /* 每个词的像素宽度 */
  std::vector<uint16_t> word_xpos;    /* 每个词的 x 坐标 */
  std::vector<uint8_t> word_styles;   /* 每个词的样式掩码 */
  BLOCK_STYLE style;
  /* …… 其余方法见 TextBlock.h …… */
public:
  std::vector<uint16_t> line_breaks;  /* 每行最后一个词的下标 */
};

一个容易忽略的细节是图片路径拼接。EPUB 章节 HTML 里的 <img src="images/foo.jpg"> 是相对路径,而 zip 里的真实路径是 OEBPS/text/images/foo.jpg。解析器在创建图片块时拼的是 m_base_path + srcEpubReader 创建 parser 前先算出章节文件所在目录作为 base_path(item.substr(0, item.find_last_of('/') + 1)),再传进构造函数。这样图片块才能用完整路径去 get_item_contents 取数据。单元测试 test/rubbish_html_parser.cpp 恰好验证了这一点:同一份 HTML,先传 base_path 为空、再传 "HTML/",两次都断言第 6 个块是图片,m_src 分别是 test.pngHTML/test.png

/* RubbishHtmlParser.cpp:图片作为块 —— base_path + src 拼出完整路径 */
if (matches(tag_name, IMAGE_TAGS, NUM_IMAGE_TAGS))
{
  const char *src = element.Attribute("src");
  if (src)
  {
    // 若手头有个空文本块,先把它从列表里扔掉,免得留空壳
    BLOCK_STYLE style = currentTextBlock->get_style();
    if (currentTextBlock->is_empty())
    {
      blocks.pop_back();
      delete currentTextBlock;
      currentTextBlock = nullptr;
    }
    blocks.push_back(new ImageBlock(m_base_path + src));  // 图片块
    startNewTextBlock(style);                  // 恢复一个同样式的新文本块
  }
  else
    ESP_LOGE(TAG, "Could not find src attribute");
}
结构 / 成员类型说明
blocksstd::list<Block *>章节内容的有序列表:文本块 / 图片块
currentTextBlockTextBlock *当前正在累积文本的块
spansvector<const char *>TextBlock 内的原始文本片段
wordsvector<const char *>切好的词(指向 spans 内副本)
word_widthsvector<uint16_t>每个词的像素宽度
word_stylesvector<uint8_t>每个词的样式掩码(BOLD_SPAN / ITALIC_SPAN)
line_breaksvector<uint16_t>每行最后一个词的下标
m_base_pathstd::string章节文件所在目录,用于拼图片完整路径
注意

注意图片处理里那句 blocks.pop_back(); delete currentTextBlock;——它假定 currentTextBlock 一定是列表的最后一个元素。这个假定依赖一条"隐式不变量":parse() 一开始就 startNewTextBlock(JUSTIFIED),此后每个新块都 push_back 到链表尾部,所以 currentTextBlock 永远是"最新压进去的那一个"。一旦哪天有人提前建块、打乱顺序,这个 pop_back 就会删错对象。这类靠注释与约定维持的脆弱不变量,正是手写解析器里最危险的地方——也是单元测试存在的理由。

9.4 从 XHTML 到可布局块序列

把整条链路串起来。parse()tinyxml2::XMLDocument doc(false, tinyxml2::COLLAPSE_WHITESPACE) 建文档、doc.Parse(html, length) 整段解析、再 doc.Accept(this) 触发访问者遍历——这三个调用,就是"XHTML → blocks"的全部入口。注意 COLLAPSE_WHITESPACE:TinyXML2 在解析阶段就把文本节点里多余的空白折叠掉了,只保留有效字符,解析器自己无需再处理首尾空白。构造函数 RubbishHtmlParser(html, length, base_path) 一进来就调用 parse(),也就是说:对象构造完成的一刻,blocks 列表已经就绪

入口在哪调用?EpubReader::parse_and_layout_current_section()。它先 get_spine_item(state.current_section) 拿到当前章节的文件路径、算出 base_path,再 get_item_contents(item) 把整章 XHTML 读进一块堆内存,然后 new RubbishHtmlParser(html, strlen(html), base_path);解析完立刻 free(html) 归还原文,再 parser->layout(renderer, epub) 让每个块自行计算换行与图片尺寸,最后用 parser->get_page_count() 记下本章节的页数。注意日志里那一串 esp_get_free_heap_size()——"读入原文 / 解析 / 布局"三个阶段各吃多少 PSRAM,作者都留了日志,正是为了回答第 1 章那个问题:解析到底有多吃内存。

/* RubbishHtmlParser.cpp:XHTML → blocks 的入口 */
void RubbishHtmlParser::parse(const char *html, int length)
{
  startNewTextBlock(JUSTIFIED);              /* 先备好第一个文本块 */
  tinyxml2::XMLDocument doc(false, tinyxml2::COLLAPSE_WHITESPACE);
  doc.Parse(html, length);                   /* 整段解析成 DOM 树 */
  doc.Accept(this);                       /* 访问者模式遍历全树 */
}
/* EpubReader.cpp:解析 + 布局一个章节(节选) */
std::string item = epub->get_spine_item(state.current_section);
std::string base_path = item.substr(0, item.find_last_of('/') + 1);
char *html = reinterpret_cast<char *>(epub->get_item_contents(item));
ESP_LOGD(TAG, "After read html: %d", esp_get_free_heap_size());
parser = new RubbishHtmlParser(html, strlen(html), base_path);
free(html);                            /* 原文用完立即归还 */
ESP_LOGD(TAG, "After parse: %d", esp_get_free_heap_size());
parser->layout(renderer, epub);          /* 交给每个块去算行、量图片 */
ESP_LOGD(TAG, "After layout: %d", esp_get_free_heap_size());
state.pages_in_current_section = parser->get_page_count();

结果可以验证。test/rubbish_html_parser.cpp 喂给解析器一段只有 7 个块的 HTML,断言 get_blocks().size() == 7。为什么是 7?因为 <head> 里的 <title> 被 SKIP_TAGS 跳过,不产生块;剩下的 h1、p、p、div、h2、img、p 依次对应 7 个块,其中第 6 个是 ImageBlock(索引从 0 数起,位置在 "A sub heading" 之后、最后一个 p 之前)。这段测试只跑在本机 CPU 上、不碰真屏幕——因为 TestRenderer 把 get_text_width 直接实现成 strlen(text),把 get_page_width/height 定死成 100,算法在内存里就能完整跑一遍。这是"解析与布局与硬件解耦"的最好示范。

/* test/rubbish_html_parser.cpp:一段恰好 7 个块的测试 HTML */
const char *html =
    "<html><head><title>Test</title></head><body>"
    "<h1>This is a title</h1>"   /* 块1:标题(CENTER_ALIGN + 粗体) */
    "<p>Text</p>"                /* 块2 */
    "<p>Some more text</p>"      /* 块3 */
    "<div>A block of text</div>" /* 块4 */
    "<h2>A sub heading</h2>"     /* 块5:标题 */
    "<img src=\"test.png\" />"   /* 块6:图片块 */
    "<p>Bananas!</p>"            /* 块7 */
    "</body></html>";

RubbishHtmlParser parser(html, strlen(html), "HTML/");
parser.layout(new TestRenderer(), new Epub("test"));
TEST_ASSERT_EQUAL(7, parser.get_blocks().size());
/* 第 6 个块应为图片,路径是 base_path + src */
auto iterator = parser.get_blocks().begin();
std::advance(iterator, 5);
Block *img_block = *iterator;
TEST_ASSERT_EQUAL(BlockType::IMAGE_BLOCK, img_block->getType());
TEST_ASSERT_EQUAL_STRING("HTML/test.png",
                         reinterpret_cast<ImageBlock *>(img_block)->m_src.c_str());
阶段日志观察什么
读入原文After read html整章 XHTML 占用的堆内存
解析After parseDOM 树 + blocks 的峰值占用
布局After layout换行 / 图片缩放后的最终占用
福尔摩斯提示

为什么要让 TinyXML2 折叠空白(COLLAPSE_WHITESPACE)?因为 XHTML 为了可读性布满了换行与缩进——一个 <p> 标签可能跨了五行。若不折叠,这些空白会被当成真实文本塞进 blocks,排版时出现莫名其妙的间距。折叠后,文本节点干净了,add_span 只需要再处理"词与词之间那一个空格"。这一层处理放在解析器里而不是布局器里,正是为了让第 10 章的换行算法拿到一份"几乎不需要预处理"的词表。

章末练习

练习 1:数 blocks 入门

给定 HTML:<p>Hello</p><b>World</b><i>!</i><p>Next</p>。推断解析器会生成几个块?<b> / <i> 里的文本进到哪个块?它们的样式位掩码各是多少?

提示

回忆 9.2 的两条策略:块级标签才起新块,行内标签(b/i)只是翻样式标志。数一数有几个块级标签。

参考答案

共 2 个文本块。第一个块由 <p> 建立,文本依次并入 "Hello"、"World"、"!"(b/i 不新建块);第二个块由最后一个 <p> 建立,含 "Next"。按词记录的样式掩码分别是:Hello→0、World→1(BOLD_SPAN)、!→2(ITALIC_SPAN)、Next→0。关键结论:块数由块级标签决定,行内标签只影响词的样式位。

练习 2:为什么要跳过 head / table 入门

RubbishHtmlParser.cppSKIP_TAGSVisitEnter,回答:为什么 <head>(含 <title>)和 <table> 要被整个跳过?如果 head 不跳,会发生什么?

提示

想想 head 里装的是"关于页面"的信息还是"页面正文";table 要正确渲染需要处理什么结构。

参考答案

<head> 里是 title、meta 等"页面元信息",不构成阅读正文,若不跳过,书名等文本会被当成正文块,章节开头出现一串无意义文字;<table> 结构复杂(行 / 列 / 合并单元格),在单色电纸屏上渲染成本极高,而绝大多数正文不依赖它。机制上,VisitEnter 对 SKIP_TAGS 返回 false,TinyXML2 就不会进入该子树继续遍历——相当于整段忽略,既不建块也不收集文本。

练习 3:空块复用 进阶

分析 startNewTextBlock:为什么 <div></div><p>text</p> 最终只产生一个块?如果去掉"空块复用"逻辑,会带来什么问题?

提示

关注 is_empty()set_style 的分支;再想想分页时每个文本块会贡献什么。

参考答案

解析器一开始就备好了一个空块,进入 <div><p> 时它都是空的,连续两次 set_style 复用同一个块,直到文字 "text" 进入才真正填充——所以最终只有 1 个块。若去掉复用逻辑,每个"没有文字内容"的块级标签都会 push 一个空 TextBlock,这段 HTML 会留下两个 0 词的空壳;分页时(见第 11 章)每个空块都会被当作需要另起一行的占位,页面上凭空多出空行、页数虚增。9.4 测试里的 7 个块,正是"复用空块"之后的结果。

练习 4:给解析器加 <strong> 支持 挑战

用最少改动让解析器支持 <strong>(加粗)与 <em>(斜体)。然后分析:这种"标志式"样式处理(两个 bool)对嵌套 <b><i>text</i></b> 的局限在哪?乱序闭合时会出什么错?

提示

改一行数组即可:在 BOLD_TAGS 里加 "strong"、ITALIC_TAGS 里加 "em"。局限分析想想 is_bold / is_italic 是"标志"还是"栈"。

参考答案

功能上只需把 BOLD_TAGS 改为 {"b", "strong"}ITALIC_TAGS 改为 {"i", "em"} 即可。局限在于 is_bold / is_italic 是"标志"而非"样式栈":规范嵌套 <b>a<i>b</i>c</b> 时,VisitExit 顺序恰好把两个标志各自复位,没问题;但若 HTML 乱序闭合(如 <i><b>x</i>y</b>),</i> 会先把 is_italic 复位,而 <b> 还没闭合,样式就串了。真正的 HTML 解析器必须用栈记录样式上下文。这里"标志法"只是够用就好。