图是电子书里的"重量级乘客":先解码、再等比缩放、最后算好块高度排进页面。每一张都带着解码成本与内存账单。
核心方法论:艺术与工程,先量尺寸,再落笔
「达芬奇画壁画,先要在墙上打素描稿,量好尺寸才敢动笔。图像在这块墨水屏上也是同一个道理:你得先知道它有多大,才能决定画多大、占多高、留多少空给文字。可这张"画布"窄得可怜——4 位灰阶、几百 KB 内存、刷一次还要几百毫秒。于是每一张图都是一道取舍题:解码要快还是要省内存?缩小交给硬件还是软件?占一页还是占半页?这章的每一行代码,都是在替这些取舍做选择。」
在一本 EPUB 里,图像有两个完全不同的身份。第一个是封面图(说人话:书在书架上的"门面"大图,好比纸质书封面印的那张画):它不在章节的 XHTML 里,而是被记录在 OEBPS/content.opf 的 metadata 里——一个 name="cover" 的 meta 标签,其 content 属性指向 manifest 中某件资源的 id。Epub.cpp 解析时正是顺着这条链找到封面:先遍历 meta 找 name="cover" 的项,拿到 cover_item,再在 manifest 里匹配 item_id == cover_item 的元素,把它的 href 存进 m_cover_image_item。之后书目列表页用 get_cover_image_item() 一行就能取到封面地址。
第二个身份是书内插图:它们嵌在章节 XHTML 的 <img> 标签里。仓库自带的两本示例书恰好各有代表性——data/pg43-images.epub(约 304 KB)里有一张 cover.jpg(222,685 字节);data/pg14838-images.epub(约 1.4 MB)则塞了一打插图(peter04.jpg、peter08.jpg、peter11.jpg…还混着一张 peter02.gif)。文件名里的 -images 后缀是作者的提示:这是本带插图的书。书目列表渲染封面时,会先把封面按固定比例(image_width = 2 * image_height / 3,约 3:2 竖版缩略图)缩放再画出来——说人话:改变图片的尺寸,这里指把大图按比例缩小,让它能在小小的屏幕里放得下。
把两种图像统一进"块"模型的,是 RubbishHtmlParser。解析器维护一张 IMAGE_TAGS[] = {"img"} 的清单,遇到 <img> 标签就新建一个 ImageBlock(m_base_path + src):m_src 记录 src 属性,并与章节所在目录 m_base_path 拼接成完整路径。图片块和文本块一样被推进 blocks 列表,经历"布局算坐标 → 分页分配 → 渲染绘制"三段旅程——这正是第 11、12 章那套"分页记账、渲染执行"的分工。看真实代码。
/* RubbishHtmlParser.cpp:遇到 <img> 就新建一个图片块 */
const char *IMAGE_TAGS[] = {"img"};
const int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]);
// VisitEnter 里:我们只处理 img 标签
if (matches(tag_name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
const char *src = element.Attribute("src");
if (src) {
// 图片块之前若是空文本块,先把它摘掉
if (currentTextBlock->is_empty()) {
blocks.pop_back();
delete currentTextBlock;
currentTextBlock = nullptr;
}
// 图片本身就是一块
blocks.push_back(new ImageBlock(m_base_path + src));
// 图片之后重新累积文本,沿用之前的样式
startNewTextBlock(style);
}
}
/* Epub.cpp:metadata → manifest,两条路找到封面图 */
// ① 在 metadata 里找 name="cover" 的 meta 标签
auto cover = metadata->FirstChildElement("meta");
while (cover && cover->Attribute("name") &&
strcmp(cover->Attribute("name"), "cover") != 0) {
cover = cover->NextSiblingElement("meta");
}
auto cover_item = cover ? cover->Attribute("content") : nullptr;
// ② 在 manifest 里找 id 等于 cover_item 的资源,记录它的 href
if (cover_item && item_id == cover_item) {
m_cover_image_item = href; // 封面图的完整路径
}
| 图像类型 | 在哪里被描述 | 谁消费它 | 关键入口 |
|---|---|---|---|
| 封面图 | content.opf 的 metadata(name="cover") | 书目列表页 | get_cover_image_item() → draw_image |
| 书内插图 | 章节 XHTML 的 <img> 标签 | 阅读页 | <img> → ImageBlock |
| 系统图标 | lib/Images/(hourglass.h / warning.h) | 忙碌提示、无 SD 卡警告 | show_busy() / show_img() |
分清"封面图"和"书内插图"两条完全独立的数据流:封面走 metadata→manifest 索引,服务书目列表;插图走 HTML <img>,服务阅读页。前者 get_cover_image_item() 一行就能取到,后者要靠解析器逐个扒出来塞进块列表。调试时先想清楚"这张图属于哪条流",能省一大半排查时间。
拿到图片字节后,第一步是决定"谁来解它"——Renderer::get_image_helper 干这件事。先认识两个名词:解码(说人话:把压缩过的图片数据还原成屏幕能直接画出来的像素),负责干这活的代码叫解码器;PNG 和 JPEG 则是两种最常见的图片格式——PNG 无损,适合图标、线条图这类边界清晰的图;JPEG 有损,适合照片,体积更小。判定顺序有两条:先看文件扩展名(.jpg / .jpeg / .png),扩展名骗不了人的时候再看文件头魔数——JPEG 以 0xFF 0xD8 0xFF 开头,PNG 以 0x89 'P' 'N' 'G' 开头。这是嵌入式里的常见双保险:靠数据说话,不轻信名字。两个 helper 都是懒创建的(png_helper / jpeg_helper 首次用到某格式才 new),因为解码器对象本身也要占内存。
/* Renderer.cpp:按扩展名或魔数选择解码器 */
ImageHelper *Renderer::get_image_helper(const std::string &filename,
const uint8_t *data, size_t data_size) {
if (filename.find(".jpg") != std::string::npos ||
filename.find(".jpeg") != std::string::npos ||
(data_size > 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF)) {
if (!jpeg_helper) jpeg_helper = new JPEGHelper();
return jpeg_helper;
}
if (filename.find(".png") != std::string::npos ||
(data_size > 4 && data[0] == 0x89 && data[1] == 'P' &&
data[2] == 'N' && data[3] == 'G')) {
if (!png_helper) png_helper = new PNGHelper();
return png_helper;
}
return nullptr;
}
PNG 由 PNGdec(lib/png/PNGdec,一个 git 子模块)解码。项目用它的内存模式:png.openRAM(data, size, callback) 直接从内存打开,不碰文件系统。读尺寸时不带回调,渲染时才挂上 png_draw_callback 并调用 png.decode(this, PNG_FAST_PALETTE)。PNGdec 按行回调:每一行解码出一段 RGB565 像素,交给 PNGHelper::draw_callback 处理。注意缩放只做"缩小不做放大"——x_scale = std::min(1.0f, width / png.getWidth()),再逐像素把 RGB565 拆成灰度写进帧缓冲。由于目标行可能重叠(缩小后多行源像素落在同一行),代码用 last_y 去重:同一目标行只画一次。
/* PNGHelper.cpp:逐行回调,把 RGB565 转成灰度像素 */
void PNGHelper::draw_callback(PNGDRAW *draw) {
int y = y_pos + draw->y * y_scale; // 目标行(含缩小因子)
if (y != last_y) { // 同一目标行只画一次
vTaskDelay(1); // 让出 CPU,顺带喂看门狗
png.getLineAsRGB565(draw, tmp_rgb565_buffer, 0, 0x00FFFFFF);
for (int x = 0; x < png.getWidth() * x_scale; x++) {
uint8_t r, g, b;
convert_rgb_565_to_rgb(tmp_rgb565_buffer[int(x / x_scale)], &r, &g, &b);
uint32_t gray = (r * 38 + g * 75 + b * 15) >> 7; // 加权亮度
renderer->draw_pixel(x_pos + x, y, gray);
}
last_y = y;
}
}
JPEG 由 TJpgDec(lib/tjpgd3,ChaN 的 "Tiny JPEG Decompressor")解码——一个为小内存 MCU 专门优化的解压器,官方文档自称只需 3.5 KB 的工作区 RAM,且与图像尺寸无关。项目配置里 POOL_SIZE = 32768(工作区上限开得更宽),输出格式 JD_FORMAT 0 即 RGB888。数据通过 read_jpeg_data 从内存缓冲流式喂入:TJpgDec 需要多少就回调多少。它还支持在解码时直接缩小 1/1、1/2、1/4、1/8(JD_USE_SCALE 1)——项目用一个 scale_factor 循环把"目标缩小比"尽量转成硬件缩放:每翻倍一次 scale_factor 就升一级,把能交给解码器吞掉的缩小量吃掉,剩下的残余比例留给软件逐像素映射。
/* JPEGHelper.cpp:32 KB 工作区 + 硬件缩放因子 */
#define POOL_SIZE 32768
void *pool = malloc(POOL_SIZE); // 解码工作区,与图像尺寸无关
JRESULT res = jd_prepare(&dec, read_jpeg_data, pool, POOL_SIZE, this);
...
this->x_scale = std::min(1.0f, float(width) / float(dec.width));
this->y_scale = std::min(1.0f, float(height) / float(dec.height));
scale_factor = 0;
// 把缩小比"换算"成硬件缩放级数(1/1,1/2,1/4,1/8)
while (x_scale <= 1.0f && y_scale <= 1.0f && scale_factor <= 3) {
scale_factor++;
x_scale *= 2; y_scale *= 2;
}
scale_factor--; x_scale /= 2; y_scale /= 2;
jd_decomp(&dec, draw_jpeg_function, scale_factor); // 按块输出
两条解码链最终汇合到同一个灰度公式:gray = (r * 38 + g * 75 + b * 15) >> 7。这是标准亮度权重 0.299/0.587/0.114 的定点整数版本——38 + 75 + 15 = 128 = 2⁷,用一次右移代替三次浮点乘,正是嵌入式的老手艺。屏幕只有灰阶,所以"去色"在解码阶段就完成了,之后每像素都只是往帧缓冲写一个 4 位值。
| 维度 | PNG(PNGdec) | JPEG(TJpgDec) |
|---|---|---|
| 位置 | lib/png/PNGdec(子模块) | lib/tjpgd3 |
| 工作区 | 一行 RGB565 缓冲(png.getWidth()*2 字节) | 固定 POOL_SIZE=32768,与图像尺寸无关 |
| 输出 | 逐行回调(PNGDRAW) | 逐块回调(JRECT) |
| 缩小 | 纯软件:x_scale/y_scale = min(1, 目标/源) | 硬件 1/1..1/8 + 软件残余 |
| 去色 | 同公式:(r*38 + g*75 + b*15) >> 7 | |
两种解码器有个共同点:都按"小块/逐行"流式输出,而不是把整张解码好的位图(Bitmap,一个像素一格的点阵图像,解码的终点就是把它还原出来)一次性堆在内存里——这是嵌入式解码的第一原则,让工作区与图像大小解耦。PNG 用行回调、JPEG 用矩形块回调,回调里还都塞了一个 vTaskDelay(1):解码很耗 CPU,定时让出时间片既能喂看门狗,也不至于卡死同核上的其他任务。
图片块的布局在 ImageBlock::layout 里完成,逻辑短得惊人。第一步用 get_item_contents 把图片压缩字节整个读进内存;第二步 get_image_size 读出原始宽高——这一步只解析图片头部,不解全图;第三步判断是否超屏:只有宽或高超出页面才触发缩放。缩放是严格的等比:scale = min(页宽/原宽, 页高/原高),取两个方向里较小的比例——保证不裁切、不变形、也不会让高宽方向相互打架。缩放后 width *= scale; height *= scale 就是这块图在页面上的最终占位,最后 x_pos 居中。读完成马上 free(image_data) 释放,绝不把图片字节留到渲染阶段。
/* ImageBlock.h:图片块的布局 —— 读宽高、等比缩放、算块尺寸 */
void ImageBlock::layout(Renderer *renderer, Epub *epub, int max_width = -1) {
size_t image_data_size = 0;
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(max_width != -1 ? max_width : renderer->get_page_width()) / float(width),
float(renderer->get_page_height()) / float(height));
width *= scale; // 等比缩小后的占位尺寸
height *= scale;
}
// 水平居中
x_pos = (renderer->get_page_width() - width) / 2;
free(image_data); // 读完宽高就释放,渲染阶段重新读
}
缩放算出的 height 随后成为分页的关键输入。在 RubbishHtmlParser::layout 分配页面时,图片块走这样一段:若 y + imageBlock->height > page_height,说明剩余空间放不下,就开新页;否则把图片以当前 y 塞进这一页,再 y += imageBlock->height。也就是说,图片块的高度直接决定了它占据的版面——这也是为什么"先读宽高、再缩放、再算块高度"的顺序是硬性的:不解码就拿不到宽高,拿不到宽高就没法排页。
/* RubbishHtmlParser.cpp:图片块按缩放后的高度分页 */
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; // 图片占掉的高度
}
来算一个真实数字。以 4.7 英寸的 ED047TC2(960×540,旋转成竖屏)为例,main.cpp 给阅读页设置的边距是 margin_top=35、margin_left=10、margin_right=10,于是页宽 = 960 − 20 = 940,页高 = 540 − 35 = 505。若来一张 1200×800 的插图:宽度超了,触发缩放;scale = min(940/1200, 505/800) = min(0.783, 0.631) = 0.631;缩放后 width ≈ 757、height ≈ 505——正好贴住高度方向,水平居中 x_pos = (940 − 757) / 2 ≈ 91。这张图独占一页,页面上剩余的 505 − 505 = 0 像素说明它下面不会再排文字。
| 步骤 | 代码 | 结果 |
|---|---|---|
| 读图片字节 | epub->get_item_contents(m_src, &size) | 压缩字节在内存(用完即释放) |
| 读原始宽高 | renderer->get_image_size(...) | 只解析头部,不解全图 |
| 判断超屏 | width > page_width || height > page_height | 小图原样保留、不做放大 |
| 等比缩放 | scale = min(页宽/原宽, 页高/原高) | 不裁切、不变形 |
| 算块高度 / 居中 | height *= scale;x_pos = (页宽 − width)/2 | 供分页与渲染使用 |
layout 的第三个参数 max_width 默认是 -1。注意那行三目运算:max_width != -1 ? max_width : get_page_width()——也就是调用方可以传入一个更窄的宽度来约束图片(比如在缩略图、章节列表等窄场景里),默认才用整页宽。读 ImageBlock 的头文件时别把 -1 当成随便写死的数字:它是"没有限制"的哨兵值。
图像是这套系统里最"重"的数据,内存压力来自三处。其一,get_item_contents 会把图片的压缩字节整个读进堆——一张 222,685 字节的 cover.jpg,光"躺在内存里的原始文件"就超过 200 KB。其二,PNG 渲染每帧要临时 malloc(png.getWidth() * 2) 一行 RGB565 缓冲。其三,JPEG 解码固定要 POOL_SIZE = 32768 的工作区。好在这些内存都是用完即释放的:布局阶段读完宽高就 free,渲染画完又 free,绝不驻留。
/* 每个图片块的内存生命周期:读 → 用 → 释放 */
// 布局阶段:读入压缩字节 → 取宽高 → 立刻释放
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);
free(image_data);
// PNG 渲染:一行 RGB565 临时缓冲,解完即释放
uint16_t *tmp = (uint16_t *)malloc(png.getWidth() * 2);
png.decode(this, PNG_FAST_PALETTE);
free(tmp);
// JPEG 渲染:32 KB 固定工作区,解完即释放
void *pool = malloc(POOL_SIZE);
jd_prepare(&dec, read_jpeg_data, pool, POOL_SIZE, this);
jd_decomp(&dec, draw_jpeg_function, scale_factor);
free(pool);
但峰值叠加依然可观:渲染一个图片块的那一刻,图片字节 + 解码工作区 + 帧缓冲同时在场。帧缓冲本身是 EPD_WIDTH × EPD_HEIGHT / 2 字节 = 960 × 540 / 2 = 259,200 ≈ 253 KB(第 12 章算过);再加上 EPUB 解析期的整章 blocks、文本缓冲……这些远超 ESP32 内部约 520 KB 的 SRAM。所以仓库把 PSRAM 列为第一硬性要求:图片解码的临时内存与帧缓冲都得住进外挂 PSRAM,否则要么解析失败、要么图片画不出来。
README 的 "How well does it work" 一节也如实交代了图像带来的体验代价:"Depending on the images that have been used for the book covers displaying the list of ePub files on the SD Card can take a few seconds and moving through the pages of ePub files can be a bit slow. Rendering the actual pages is pretty reasonable, even when they have images on them."——封面解码会让书目列表慢上几秒;而阅读页本身的渲染"即使带图也相当利索"。这印证了本章的分工:解码是昂贵的一步,布局时把它藏进"只读头部",渲染时按块流式完成,才把成本控制在可接受范围。
| 内存项 | 大小 / 来源 | 释放时机 |
|---|---|---|
| 图片压缩字节 | 约等于文件体积(封面可达 222 KB) | 布局取完宽高 / 渲染画完之后 |
| PNG 行缓冲 | png.getWidth() * 2 字节 | 本次渲染结束 |
| JPEG 工作区 | POOL_SIZE = 32768 固定 | 本次渲染结束 |
| 帧缓冲 | EPD_WIDTH×EPD_HEIGHT/2 ≈ 253 KB | 常驻(PSRAM) |
不是所有图片都能解。项目只支持 JPEG 与 PNG——前文的 get_image_helper 对未知格式直接返回 nullptr,draw_image 便退化成画一个空心占位矩形(draw_rect(x+20, y+20, width-40, height-40))。这解释了一个真实现象:pg14838-images.epub 里那张 peter02.gif 在阅读页上会以占位框的形式出现,而不是真图。遇到"图没出来"的问题,先查格式是否在这两条白名单里。
结合 lib/Epub/EpubList/Epub.cpp,简述封面图是如何被找到的:它先存在于哪里?用了哪两个标签/属性?最终存在哪个成员变量里?书目列表页又是怎么把它画出来的(结合 EpubList.cpp)?
封面在 content.opf 的 metadata;先 name="cover" 拿 id,再按 id 匹配 manifest;列表页按 3:2 比例缩放后用 draw_image。
封面图记录在 OEBPS/content.opf 的 metadata 中:一个 name="cover" 的 meta 标签,其 content 属性是 manifest 里某件资源的 id。解析时遍历 meta 找到它拿到 cover_item,再在 manifest 里匹配 item_id == cover_item 的元素,把其 href 存入 m_cover_image_item。书目列表页用 get_cover_image_item() 取地址、get_item_contents 读字节,按 image_width = 2 * image_height / 3 的竖版比例缩放后 draw_image 画进格子。
沿用 13.3 的屏幕配置(页宽 940、页高 505),分别计算两张图的最终占位与 x_pos:(a) 1200×800;(b) 400×300(一张小插图,不触发缩放)。请写出每一步的 scale、缩放后 width/height、x_pos。
(b) 宽高都小于页面,if 分支不进入,尺寸保持原样只做居中;注意 width *= scale 中 width 是整数,会被截断。
(a) 1200 宽、800 高都超出,scale = min(940/1200, 505/800) = min(0.783, 0.631) = 0.631;缩放后 width ≈ 757、height ≈ 505;x_pos = (940 − 757)/2 ≈ 91。(b) 400<940 且 300<505,不触发缩放,保持 400×300;x_pos = (940 − 400)/2 = 270。可见小图不会被强行放大到整页宽——缩放只做缩小不做放大。
项目把彩色像素转灰度用的公式是 gray = (r*38 + g*75 + b*15) >> 7。请解释:(1) 系数 38/75/15 对应标准亮度公式里的哪几个权重?(2) 为什么 >> 7 等价于除以 128?(3) 相比 float 版本,定点整数版好在哪?
标准公式是 0.299R + 0.587G + 0.114B;38+75+15 = 128 = 2⁷;想想嵌入式的 CPU 有没有 FPU。
(1) 38/128 ≈ 0.297、75/128 ≈ 0.586、15/128 ≈ 0.117,正是 0.299/0.587/0.114 的定点近似,且和等于 128,保证白色 (255,255,255) 仍映射回 255。(2) >> 7 是对无符号整数除以 2⁷ = 128 的移位实现,一步完成除法。(3) 定点整数只做整数乘法和一次移位,而浮点版在无 FPU 的 MCU 上会触发软件浮点模拟,慢一个量级——这是一张图要执行几十万次的操作,省下来的时间很可观。
跟踪 JPEGHelper::render 里的 scale_factor 循环。给定初始 x_scale = y_scale = 0.615,写出循环每一步 scale_factor 与 x_scale 的值,并说明最终 jd_decomp 用的缩放级数与软件残余比例。若改成初始 0.25 又会怎样?
循环条件是三个同时成立:x_scale<=1 且 y_scale<=1 且 scale_factor<=3;退出后要 scale_factor-- 再 x_scale/=2 还原。
初始 0.615、scale_factor=0:第 1 轮 0.615≤1 成立 → scale_factor=1、x_scale=1.23;第 2 轮 1.23≤1 不成立,退出。随后 scale_factor--=0、x_scale/=2=0.615。即 jd_decomp 用 1/1(不缩小),软件按 0.615 逐像素映射。若初始 0.25:第 1 轮→0.5(sf=1);第 2 轮→1.0(sf=2);第 3 轮→2.0(sf=3) 退出,随后 sf=2、x_scale=1.0。即解码器直接输出 1/4 缩小的图,软件残余比例 1.0(无需再缩放)。这个循环把"目标缩小比"在硬件解码与软件映射之间做了分配——硬件能吞多少就吞多少,剩下的才留给软件。