第11章:分页算法——把排好的一串块,装进一页页

换行把段落切成行,分页再把行装进页。屏幕没有无限长的一卷纸,放不下,就开新页。

🪶

本章导师:诸葛亮

核心方法论:运筹帷幄,决胜千里

「上一章我们用动态规划把一段文字排成了行,可那只是一条没有尽头的长卷。屏幕却不会给你无限长的纸——它只有固定的一页高。于是问题变成了:手里这串行和图片,怎么分配到一个一个的页格里,既不把一页浪费大半,也不让内容撑破边界?这看起来只是"往下放、放不下就翻页"的体力活,正因为它简单,才最容易被低估。真正的高手,先算清每块占地多高,再决定何时落子。今天我们就用谋士的眼睛,把这段分页代码一层层看透。」

11.1 问题:从"一串块"到"一页页"

回顾整条流水线:RubbishHtmlParser 解析完一章的 XHTML 后,得到一串 std::list<Block *> blocks,说人话:排版里的最小单元,一段文字或一张图片就是一个块)。其中 TextBlock 已经用动态规划算好了断行点(存在 line_breaks 里),ImageBlock 已经记下图片源路径。但这一串 blocks 本质上是一条"不知道尽头在哪"的长卷——它自己根本不知道会被切成几页。电子墨水屏的每一页只有固定高度 page_height(说人话:页高就是屏幕一页能放多少行内容的总高度,超出就得另起一页;由渲染器的 get_page_height() 提供)。分页(pagination,说人话:把排好的内容按一屏一屏切分开,每屏就是一页)就是把这卷长卷,裁剪成一张张固定高度的页。

分页的输出是一个 std::vector<Page *> pages。每个 Page 只回答一个问题:"这一页放了哪些元素?" 而每个元素(PageElement)记录两样东西:它来自哪个 block、以及它在本页的 y_pos(纵向位置)。这里的精髓在于:分页阶段一个像素都不画,它只做"记账"——把每个行/每张图应放的位置记下来,真正的绘制留到下一章。看 lib/Epub/RubbishHtmlParser/Page.h 里的真实定义。

/* Page.h:一页 = 一串"带 y 坐标的元素" */
class PageElement {
public:
  int y_pos;                  // 元素在本页的纵向位置
  virtual void render(Renderer *renderer, Epub *epub) = 0;
};

class PageLine : public PageElement {
  TextBlock *block;            // 来自哪个文本块
  int line_break_index;      // 该块的第几行
};

class PageImage : public PageElement {
  ImageBlock *block;           // 来自哪张图片
};

class Page {
  std::vector<PageElement *> elements;   // 本页的所有元素
};

分页工作的入口是 RubbishHtmlParser::layout(renderer, epub)。它先做两件准备工作:从渲染器取行高(说人话:一行文字占多高;用页高除以行高,就能算出一页能放几行)与页高,然后让每个 block 先自己"排好版"——文本块断行、图片块缩放。注意第二个循环里的 vTaskDelay(1):解析整章可能很耗时,每次处理一个 block 就喂一次看门狗,避免触发 FreeRTOS 看门狗复位。这是嵌入式代码里"长任务主动让出"的典型写法。

/* RubbishHtmlParser::layout —— 分页前的准备 */
const int line_height = renderer->get_line_height();   // 一行多高
const int page_height = renderer->get_page_height();  // 一页多高

for (auto block : blocks) {
  block->layout(renderer, epub);   // 文本块断行、图片块缩放
  vTaskDelay(1);                // 喂看门狗
}
诸葛亮提示

为什么分页和渲染必须分开?其一,分页只操作"行/图的编号和坐标",不碰像素,所以它可以在 test/rubbish_html_parser.cpp 里脱离硬件直接跑单元测试;其二,翻页时我们只需 render_page(current_page) 画当前这一页,而不必重新分页——分页结果被缓存成 pages 复用。职责分离,是运筹的第一步。

11.2 块高度计算:文本行数 × 行高,图片等比缩放

要把内容装进页,第一步是知道每个元素"占多高"。文本块的高度好算:TextBlock::layout 已经在上一章量好了每个单词的宽度并算出行断点,行数就是 line_breaks.size();再乘上行高 line_height 就是整块的高度。而行高 get_line_height() 直接来自字体的度量信息——EpdiyFrameBufferRenderer 里它等于 m_regular_font->advance_y(字体的行步进值),字间距 get_space_width() 则取空格字形 advance_x。字体是"页面的度量单位",所有高度都从它派生。

/* EpdiyFrameBufferRenderer:行高与字间距都来自字体度量 */
int get_line_height() {
  return m_regular_font->advance_y;   // 字体的行步进 = 行高
}
int get_space_width() {
  auto space_glyph = epd_get_glyph(m_regular_font, ' ');
  return space_glyph->advance_x;     // 空格的宽度
}

图片块的高度则要"算"出来。图片的原始尺寸存在 EPUB 里,可能比屏幕还大,必须等比缩放。看 ImageBlock::layout 的真实实现:先用 get_image_size 读出原始宽高;只要任意一边超过页面,就取 min(页面宽/原宽, 页面高/原高) 作为缩放比,宽高同乘——这是"等比缩小到能放进一页"的经典做法。缩放后的 height,就是这张图在分页时占用的高度。缩小的图片不会放大,这是有意的取舍(放大无意义且费内存)。

/* ImageBlock::layout —— 读尺寸并等比缩放 */
uint8_t *image_data = epub->get_item_contents(m_src, &image_data_size);
renderer->get_image_size(m_src, image_data, image_data_size, &width, &height);

if (width > renderer->get_page_width() ||
    height > renderer->get_page_height()) {
  float scale = std::min(
      float(renderer->get_page_width()) / float(width),
      float(renderer->get_page_height()) / float(height));
  width  *= scale;
  height *= scale;
}
// 水平居中:x 位置在分页时一并算好
x_pos = (renderer->get_page_width() - width) / 2;

除了元素本身的高度,还有一块"隐性高度":段落间距。在 layout() 的分页主循环里,每处理完一个文本块,都会在 y 上追加 line_height * 0.5——这是段与段之间留出的半行空白,让段落不会挤成一团。三类"占页量"汇总如下表。

元素占页高度来源
文本行line_heightget_line_height() = m_regular_font->advance_y
段落间line_height * 0.5文本块结束后无条件追加半行
图片等比缩放后的 heightImageBlock::layout,超限才缩放

11.3 装填策略:从当前 y 开始放,放不下就开新页

准备好了高度,就可以装填(说人话:按顺序把一个个块放进当前页,这一页放不下了就另起一页再继续放)了。分页主循环非常直白:用一个 y 指针跟踪"当前页已经用掉了多少高度",从 0 开始;对每个文本块逐行、对每张图片逐个处理——y + 元素高度 > page_height 就说明当前页放不下这个元素了,于是开一张新页并把 y 清零,再从页顶开始放。下面是 RubbishHtmlParser::layout 分页部分的完整真实代码。

/* RubbishHtmlParser::layout —— 分页主循环 */
int y = 0;
pages.push_back(new Page());              // 先开第一页
for (auto block : blocks) {
  if (block->getType() == BlockType::TEXT_BLOCK) {
    TextBlock *textBlock = (TextBlock *)block;
    for (int i = 0; i < textBlock->line_breaks.size(); i++) {
      if (y + line_height > page_height) {   // 放不下这一行了
        pages.push_back(new Page());
        y = 0;                             // 新页从顶部开始
      }
      pages.back()->elements.push_back(
          new PageLine(textBlock, i, y));  // 记下:第 i 行放在 y 处
      y += line_height;
    }
    y += line_height * 0.5;                 // 段间半行空白
  }
  if (block->getType() == BlockType::IMAGE_BLOCK) {
    ImageBlock *imageBlock = (ImageBlock *)block;
    if (y + imageBlock->height > page_height) { // 图片放不下
      pages.push_back(new Page());
      y = 0;
    }
    pages.back()->elements.push_back(new PageImage(imageBlock, y));
    y += imageBlock->height;
  }
}

这是一次贪婪的"先到先放"装填:放得下就放,放不下就翻页,绝不回头。它不做任何全局优化——不会尝试把前一页末尾的空白补满,也不会为了保持某段完整而提前换页。好处是复杂度是线性的(一次遍历、每元素 O(1)),坏处是页面边界的落点比较"粗糙"。还要注意装填的最小单位:文本按"行"装、图片按"张"装——一行文字、一张图片都是不可再拆的原子。所以页边界永远落在某两行之间,而不会切在某个词的中间。

/* 数值演算:page_height=925, line_height=28
   一页最多 = 925 / 28 = 33 行(余 1 点放不下第 34 行)
   一个 34 行的段落:
     页 0:第 1..33 行(y 从 0 累加到 33*28=924)
     第 34 行:y+28=952 > 925 → 开新页,页 1 从顶部放 1 行 */
// 于是页 0 的 924px 被用完,页 1 只放了 1 行 + 半行段距
诸葛亮提示

把"元素高度"与"剩余空间"分开想,分页就只是一道算术题:剩余 = page_height - y,能放进当前页的条件是 元素高度 ≤ 剩余,等价于代码里的 y + height > page_height 才翻页。这个条件看起来简单,但它决定了一切的成败——翻页时机早了浪费空间,晚了内容溢出被裁掉。谋定而后动,先想清条件,再写循环。

11.4 分页的局限:够用,但糙

作者在 README 的 "How well does it work?" 一节里很坦诚地自评:"The code makes no attempt to break pages at suitable places - there are hints that can be extracted from the XHTML files and there are also CSS files that could be used."——代码从不尝试在"合适的地方"断页。所谓"合适",是指章节标题不该孤悬在页面最底一行、图片不该和它的图注分家、遇到明确的断页提示(如 XHTML 里的分页样式或 CSS 的 page-break-*)应当尊重。这些"提示"就在 XHTML 和 CSS 里,只是当前实现完全无视了它们。

最直观的"糙"是标签覆盖的取舍:解析器维护了一张跳过表 SKIP_TAGS = {"head", "table"}——表格整个被跳过,遇到 <table> 就从解析树里 return false(不深入子树)。这延续了第 9 章"极简标签"的策略:只认 p/div/li/br/h1..h6/b/i/img,其余结构要么当文本、要么直接放弃。分页层面同样如此:不做孤儿行控制、不保证"标题跟着正文走"。下表列出几处可观察到的局限与改进方向。

/* RubbishHtmlParser.cpp:被整体跳过的标签 */
const char *SKIP_TAGS[] = {"head", "table"};

if (matches(tag_name, SKIP_TAGS, NUM_SKIP_TAGS)) {
  return false;   // 不深入该子树 → 表格内容被忽略
}
局限可观察到的现象改进方向
不在"合适处"断页标题/图片可能孤悬页尾解析 XHTML 断页提示与 CSS
表格整体跳过<table> 的章节内容缺失把表格当作简单文本块
块尾半行无条件追加每段结束都浪费半行,可能提早触发翻页仅当下一块紧随其后时追加
文本行/图片不可拆块整体搬页,留下难看的页尾空白段落级"孤儿行"控制

还有一个藏在细节里的微妙之处:y += line_height * 0.50.5 是浮点、yint,赋值时会截断取整——若行高为奇数,实际追加的半行会比"恰好一半"少 1px。这在视觉上无伤大雅,但它说明这段代码"够用但糙":处处都是能跑就行的小妥协。改进的思路也不难,关键是把"是否处于块首行"纳入翻页条件——伪代码如下。

/* 改进示意:避免段落首行成为页面最后一行(防孤行) */
for (每行 i in textBlock->line_breaks) {
  bool is_first_line = (i == 0);
  if (is_first_line && y + line_height * 2 > page_height) {
    // 该块首行起算放不满两行 → 整块搬到下一页
    pages.push_back(new Page());
    y = 0;
  }
  /* ...原装填逻辑... */
}
注意

分页结果直接决定阅读体验:一页的开头若是孤零零的图片或标题,用户会觉得"排版坏了"。嵌入式开发里"能用"和"好用"之间往往就差这些细节——而改进的第一步,是把分页逻辑放进 test/rubbish_html_parser.cpp 的单元测试里,让"页数对不对、元素顺序对不对"可以在本机一键验证,再放手去改。

章末练习

练习 1:模拟装填 入门

page_height = 925line_height = 28,一个 34 行的文本块会被分进几页?前几行在第 0 页、最后几行在哪一页?请按 11.3 的装填条件逐步演算。

提示

先算一页最多放下几行:925 / 28 向下取整。第 34 行触发 y + 28 > 925 时开新页。

参考答案

一页最多 925 / 28 = 33 行(33 行后 y = 924,再加一行 924 + 28 = 952 > 925)。所以第 0 页放第 1..33 行,第 34 行开新页,第 1 页只放 1 行再加段间半行。可见 34 行的段落用了 2 页,第二页几乎全空。

练习 2:图片等比缩放 入门

一张原始尺寸 800×1200 的图片,页面 get_page_width() = 520get_page_height() = 925。按 ImageBlock::layout 的公式,缩放后的宽高各是多少?它会被居中放在 x_pos 的什么位置?

提示

scale = min(520/800, 925/1200),取较小者保证两边都不越界;x_pos = (520 - 新宽) / 2

参考答案

scale = min(0.65, 0.7708…) = 0.65,于是 width = 800 × 0.65 = 520height = 1200 × 0.65 = 780。正好顶满页面宽度、纵向留白 145px;x_pos = (520 - 520) / 2 = 0,图片贴着左边缘。若换一张窄图(如 200×100),不触发缩放,x_pos = (520 - 200) / 2 = 160,居中摆放。

练习 3:段间半行的影响 进阶

分页代码在每个文本块结束后都无条件追加 line_height * 0.5 的空行。这会造成怎样的浪费?什么情况下会让一页白白多翻一次?如果要修,该在什么地方加条件?

提示

想想"块尾的半行"占用了 y,紧接着下一块第一行就可能因这半行而放不下;还要考虑最后一页末尾那半行永远不会被下一块使用。

参考答案

半行虽然不大,却会推高 y:下一块首行可能恰好因这半行触发 y + line_height > page_height 而整块搬去下一页,导致当前页底部空出大半页。更明显的浪费在全书最后一页:块结束后追加的半行永远不会再被填充。修法:只在该块之后确实还有内容需要放置时追加间距,例如用 std::next(block) != blocks.end() 判断是否存在后继块。

练习 4:设计"防孤标题"分页 挑战

章节标题是一个 CENTER_ALIGN 的加粗文本块,它可能独自出现在页面最底部。请你设计并写出改进版分页伪代码,保证"标题块首行起算放不满两行时就整块搬去下一页"。需要读取块的哪些信息?代价是什么?

提示

需要知道"当前块是不是标题块、该行是不是它的首行"——结合第 9 章的样式信息(styleCENTER_ALIGN)与 line_breaks 判断。代价是可能让前一页多空一点。

参考答案

思路:在逐行装填循环里,若 i == 0 且块的样式是标题(CENTER_ALIGN),则把翻页条件从 y + line_height > page_height 改为 y + line_height * 2 > page_height——保证标题行之后至少还能放下一行正文。实现上需要把 block 转成 TextBlock 并读取 TextBlock::get_style()(第 9 章见过),这与 11.4 末尾给出的伪代码一致。代价是每个标题块可能让前一页少两行;但换来的是"标题不孤悬"的排版质量,值得。真正工程化时,还要把这条规则放进单元测试覆盖(标题在页尾、标题紧贴页底等用例)。