第十三章|基础专题四:PNG 底层原理
从 PNG Signature、Chunk、扫描线、Filter、zlib、DEFLATE、Alpha、颜色管理、Canvas 和 WebCodecs ImageDecoder 理解 PNG 的无损压缩链路。
第十三章|基础专题四:PNG 底层原理
原稿学习节奏:第 4 周。
建议学习时长:8~10 小时 前置知识: 像素与颜色、Packed/Planar、信息熵、预测与残差、Huffman 编码 本周主线: 先把二维像素序列化为扫描线,再用可逆过滤降低相邻字节相关性,最后用 zlib 封装的 DEFLATE 压缩,并通过 Chunk 组织成可扩展、可校验的 PNG 码流。
本周安排
| 天数 | 主题 | 建议时长 | 当日输出 |
|---|---|---|---|
| Day 1 | PNG 的定位、Signature、Chunk 布局 | 1 小时 | 能手工识别 PNG 文件和 Chunk |
| Day 2 | IHDR、颜色类型、位深、扫描线 | 1.5 小时 | 能计算每行字节数并解释像素打包 |
| Day 3 | None、Sub、Up、Average、Paeth | 2 小时 | 能手算过滤与反过滤 |
| Day 4 | zlib、DEFLATE、LZ77、Huffman | 1.5 小时 | 能画出过滤后数据进入 DEFLATE 的路径 |
| Day 5 | Alpha、tRNS、颜色管理、Adam7、APNG | 1.5 小时 | 能解释透明黑边和渐进显示 |
| Day 6 | 浏览器解码、Canvas、Worker、性能与安全 | 1.5 小时 | 完成一个 PNG Chunk 解析器 |
| Day 7 | 面试题、练习与复盘 | 1 小时 | 独立完成本周验收题 |
一、学习目标
学完本周后,你必须能够:
- 准确区分 PNG 图像格式、zlib 数据格式、DEFLATE 压缩算法和具体编码器实现。
- 从十六进制字节中识别 PNG Signature、IHDR、IDAT 和 IEND。
- 解释一个 Chunk 的
Length + Type + Data + CRC结构。 - 说明 PNG 为什么是无损格式,但不同编码器生成的文件大小仍然不同。
- 根据
color type、bit depth和宽度计算扫描线字节数。 - 手算 None、Sub、Up、Average 和 Paeth 过滤结果,并完成反过滤。
- 解释过滤为什么能改善 DEFLATE 的压缩效果,但不会直接减少字节数。
- 解释 LZ77 和 Huffman 在 DEFLATE 中各自负责什么。
- 区分 Chunk CRC 与 zlib 尾部 Adler-32 的校验范围。
- 解释
PLTE、tRNS、显式 Alpha 通道和 PNG 的 straight alpha。 - 排查透明素材经过 Canvas、GPU 合成或导出后出现黑边、白边的问题。
- 设计 PNG 在 AI 视频网页和在线 Web 视频编辑器中的正确使用方式。
- 说明为什么连续视频帧不应长期使用 PNG 序列作为正式传输格式。
- 编写一个安全的 PNG Chunk 解析器和一个扫描线反过滤器。
- 对大尺寸 PNG 的解码内存、压缩炸弹、主线程阻塞和资源释放做工程评估。
二、概念地图
2.1 PNG 位于完整媒体系统的哪一层
原始像素
RGB / RGBA / 灰度 / 调色板索引
↓
扫描线序列化
↓
PNG Filter
None / Sub / Up / Average / Paeth
↓
zlib 数据格式
↓
DEFLATE
LZ77 + Huffman
↓
PNG Chunk
IHDR / PLTE / IDAT / IEND / 辅助 Chunk
↓
PNG 字节流
↓
浏览器或图像库解码
↓
RGBA 像素、纹理或 Canvas 内容
2.2 必须严格区分的层次
| 层次 | PNG 场景中的例子 | 关键结论 |
|---|---|---|
| 原始像素 | RGBA8888、灰度 8-bit | 尚未压缩,通常占用大量内存 |
| 图像编码格式 | PNG | 定义文件结构、颜色类型、过滤、压缩与解码规则 |
| 压缩算法 | DEFLATE | 通用无损压缩,组合 LZ77 与 Huffman |
| 压缩封装 | zlib | 在 DEFLATE 外增加头部和 Adler-32 |
| 编码器实现 | libpng、浏览器编码器、图像工具 | 决定过滤器选择、压缩级别和搜索策略 |
| 基础码流 | PNG datastream | Signature 后跟多个 Chunk |
| 容器 | PNG 本身不是 MP4 式多轨容器 | 它组织一幅静态图或 APNG 动画,不管理音视频轨道 |
| 浏览器 API | <img>、createImageBitmap、ImageDecoder、Canvas | 负责加载、解码、渲染和像素访问 |
2.3 四个常见误区
- PNG 不是“把 RGBA 直接丢给 ZIP”。 在 DEFLATE 前还有颜色表示、扫描线组织和 Filter。
- DEFLATE 不等于 zlib。 DEFLATE 是压缩数据格式;zlib 是包含 DEFLATE 数据和校验值的封装。
- PNG 无损不等于每次保存字节完全相同。 像素可完全恢复,但 Chunk、过滤策略、压缩块和元数据可以不同。
- PNG 支持透明不等于内部使用预乘 Alpha。 PNG 文件中的颜色样本使用非预乘,也称 straight/unassociated alpha。
三、直觉解释
3.1 PNG 的核心思想
PNG 的核心可以概括为:
保留所有像素信息
+
把相邻像素改写成更小、更重复的差值
+
用通用无损压缩器压缩这些差值
假设一行灰度像素是:
100, 101, 102, 103, 104, 105
原始数据中的数值都接近 100,但并不完全相同。若使用 Sub 过滤器,保存“当前值减左边值”:
100, 1, 1, 1, 1, 1
过滤后的字节数没有减少,仍然是 6 个字节;但出现了大量重复的 1,这更容易被 LZ77 找到重复串,也更容易形成偏斜的符号概率供 Huffman 编码。
解码时只需做累加:
100
100 + 1 = 101
101 + 1 = 102
...
因此过滤是完全可逆的。
3.2 为什么 UI、图标和文字截图适合 PNG
这类图像通常具有:
- 大面积纯色;
- 重复的水平或垂直边缘;
- 有限的颜色数量;
- 需要精确保留文字边缘;
- 可能需要透明度。
过滤后会产生大量 0、小差值和重复模式,DEFLATE 很容易压缩。
3.3 为什么照片的 PNG 通常很大
照片包含:
- 传感器噪声;
- 纹理;
- 光照渐变;
- 大量细微颜色变化;
- 很少完全重复的局部字节序列。
即使过滤能减少相邻差异,PNG 仍必须无损保存所有细节。JPEG 则允许通过量化丢弃视觉不敏感的信息,所以照片通常更适合 JPEG、AVIF 或视频编码,而不是 PNG。
3.4 为什么“同一张图”可以有不同大小的 PNG
像素相同并不意味着编码决策相同。编码器可以选择:
- 真彩色还是调色板;
- 每行使用哪一种 Filter;
- DEFLATE 的块划分方式;
- 静态、固定或动态 Huffman 表;
- LZ77 匹配搜索深度;
- 压缩级别;
- 是否保留文本、ICC、Exif 等元数据;
- IDAT 切成多少个 Chunk。
这些选择都不改变最终解码像素,却会改变文件大小和编码时间。
四、数学原理
4.1 扫描线字节数
设:
- (W):图像宽度,单位为像素;
- (H):图像高度;
- (B_{pp}):每像素总 bit 数;
- (S):每条未过滤扫描线的字节数。
则:
[ S=\left\lceil\frac{W\cdot B_{pp}}{8}\right\rceil ]
非 Adam7 PNG 在进入 DEFLATE 前,每行还会增加 1 个 Filter Type 字节,因此总过滤数据大小为:
[ N_{\text{filtered}}=H\cdot(S+1) ]
手算例子
一张 1920×1080、8-bit RGBA PNG:
[ B_{pp}=4\times 8=32 ]
[ S=\frac{1920\times 32}{8}=7680\text{ bytes} ]
[ N_{\text{filtered}}=1080\times(7680+1)=8{,}295{,}480\text{ bytes} ]
其中 1080 个额外字节是每条扫描线前的 Filter Type。
注意:这是 进入 DEFLATE 前 的数据量,不是最终 PNG 文件大小。
4.2 过滤器中的邻居
对当前待处理字节 (x),定义:
c b
a x
- (a):当前扫描线中,距离 (x) 一个完整像素的左侧字节;
- (b):上一扫描线同一位置的字节;
- (c):上一扫描线左上位置的字节;
- (bpp):Filter 使用的“每完整像素字节数”,不足 1 字节时按 1 处理;
- 缺失的邻居视为 0。
过滤运算都在字节域内进行,最终结果取模 256。
4.3 None
[ F(x)=x ]
不做预测,直接输出原字节。
适合:
- 已经难以预测的数据;
- 低位深调色板图;
- 编码速度优先的场景。
4.4 Sub
[ F(x)=(x-a)\bmod 256 ]
反过滤:
[ x=(F(x)+a)\bmod 256 ]
手算
原始扫描线:
10, 13, 15, 20
灰度 8-bit 时 (bpp=1)。
第 1 字节:10 - 0 = 10
第 2 字节:13 - 10 = 3
第 3 字节:15 - 13 = 2
第 4 字节:20 - 15 = 5
结果:
10, 3, 2, 5
Sub 擅长利用水平方向相关性。
4.5 Up
[ F(x)=(x-b)\bmod 256 ]
反过滤:
[ x=(F(x)+b)\bmod 256 ]
手算
上一行:
10, 20, 30, 40
当前行:
12, 22, 33, 43
过滤后:
2, 2, 3, 3
Up 擅长利用垂直方向相似性。
4.6 Average
[ F(x)=\left(x-\left\lfloor\frac{a+b}{2}\right\rfloor\right)\bmod 256 ]
反过滤:
[ x=\left(F(x)+\left\lfloor\frac{a+b}{2}\right\rfloor\right)\bmod 256 ]
手算
若:
- (x=105)
- (a=100)
- (b=96)
则:
[ \left\lfloor\frac{100+96}{2}\right\rfloor=98 ]
[ F(x)=105-98=7 ]
4.7 Paeth
先计算线性预测:
[ p=a+b-c ]
再计算:
[ p_a=|p-a|,\quad p_b=|p-b|,\quad p_c=|p-c| ]
选择距离 (p) 最近的 (a)、(b) 或 (c) 作为预测值。相等时按规范规定的顺序优先选择。
手算
若:
- (a=100)
- (b=90)
- (c=80)
- (x=104)
则:
[ p=100+90-80=110 ]
[ p_a=10,\quad p_b=20,\quad p_c=30 ]
因此预测值为 (a=100):
[ F(x)=104-100=4 ]
Paeth 可以理解为:用左、上、左上估计局部平面趋势,再选择最可信的实际邻居。
4.8 Filter 是“相关性变换”,不是压缩
设原数据长度为 (n),过滤后仍有 (n) 个数据字节,另加每行 1 个 Filter Type 字节。过滤本身甚至略微增加数据量。
它的目标是改变符号分布:
原始:100 101 102 103 104
过滤:100 1 1 1 1
过滤后:
- 零和小残差更多;
- 重复串更多;
- Huffman 符号概率更集中;
- LZ77 更容易引用历史数据。
真正减少字节数的是后续 DEFLATE。
4.9 LZ77 的“距离 + 长度”
LZ77 在历史窗口中查找重复串。
例如输入:
1, 2, 3, 1, 2, 3, 1, 2, 3
前三个字节可作为 literal 输出。后六个字节可以表示为类似:
距离 = 3
长度 = 6
直觉是:“接下来的 6 个字节,与当前位置向前 3 个字节开始的内容相同。”
DEFLATE 的 LZ77 历史窗口最大为 32 KiB。编码器搜索得越充分,通常压缩率越高,但编码更慢。
4.10 Huffman 编码
若过滤后的符号分布为:
| 符号 | 概率 |
|---|---|
| 0 | 60% |
| 1 | 20% |
| 255 | 10% |
| 其他 | 10% |
Huffman 会给高概率符号较短码字。PNG 编码器可以让 DEFLATE 使用:
- 不压缩块;
- 固定 Huffman 表;
- 动态 Huffman 表。
这是 DEFLATE 的选择,不是 PNG Filter 的选择。
4.11 Adler-32
PNG 的 IDAT 数据使用 zlib 格式。zlib 尾部的 Adler-32 校验 解压后的数据。
初始化:
[ s_1=1,\quad s_2=0 ]
对每个输入字节 (d_i):
[ s_1=(s_1+d_i)\bmod 65521 ]
[ s_2=(s_2+s_1)\bmod 65521 ]
最终:
[ \text{Adler32}=(s_2\ll16)+s_1 ]
手算 [1, 2, 3]
| 步骤 | (s_1) | (s_2) |
|---|---|---|
| 初始 | 1 | 0 |
| 读 1 | 2 | 2 |
| 读 2 | 4 | 6 |
| 读 3 | 7 | 13 |
结果:
0x000D0007
4.12 Chunk CRC
每个 PNG Chunk 都有 CRC。CRC 的计算范围是:
Chunk Type + Chunk Data
不包含 Length。
因此:
Length | Type | Data | CRC
└─────┬─────┘
CRC 范围
CRC 检查 Chunk 层损坏;Adler-32 检查 zlib 解压数据。二者层次不同,不能混为一谈。
4.13 Alpha 合成
PNG 文件使用 straight alpha。设:
- 前景颜色为 (C_f);
- 背景颜色为 (C_b);
- 归一化 Alpha 为 (\alpha\in[0,1]);
- 输出颜色为 (C_o)。
简单不透明背景下:
[ C_o=\alpha C_f+(1-\alpha)C_b ]
手算
50% 红色前景覆盖蓝色背景:
[ C_f=(255,0,0),\quad C_b=(0,0,255),\quad \alpha=0.5 ]
[ C_o\approx(128,0,128) ]
工程上,严格颜色合成还应考虑传递函数和颜色空间;不能把所有场景都当成未管理的 sRGB 字节直接线性运算。
五、底层数据结构
5.1 PNG Signature
PNG 字节流固定以 8 字节开始:
89 50 4E 47 0D 0A 1A 0A
含义可以直观理解为:
89:避免被误认为纯 ASCII 文本;50 4E 47:ASCII"PNG";0D 0A:CRLF;1A:历史文本传输环境中的结束标记;0A:LF。
解析器应先验证完整 Signature,再进入 Chunk 循环。
5.2 Chunk 通用布局
+-------------------+
| Length 4 bytes |
+-------------------+
| Type 4 bytes |
+-------------------+
| Data N bytes |
+-------------------+
| CRC 4 bytes |
+-------------------+
- 多字节整数使用网络字节序,即大端序。
Length只表示 Data 长度。Type和Data参与 CRC。- 即使 Data 为空,CRC 仍存在。
- 解析时必须先做整数溢出和剩余长度检查,不能直接信任 Length。
Type 四个字母中的属性位
Chunk Type 的四个 ASCII 字母大小写具有语义:
| 字节位置 | 大写 | 小写 |
|---|---|---|
| 第 1 字节 | Critical,关键 | Ancillary,辅助 |
| 第 2 字节 | Public,公开 | Private,私有 |
| 第 3 字节 | Reserved 位正确,必须大写 | 保留位被设置,不应出现在标准流中 |
| 第 4 字节 | Unsafe to copy | Safe to copy |
例子:
IHDR:关键、公开、保留位正确。tEXt:辅助、公开、保留位正确、安全复制。- 未识别的关键 Chunk 通常意味着无法正确解码。
- 未识别的辅助 Chunk 可按规则跳过,但编辑器是否能保留它还受 safe-to-copy 位影响。
5.3 关键 Chunk
IHDR
必须是第一个 Chunk,Data 长度固定为 13 字节:
Width 4 bytes
Height 4 bytes
Bit depth 1 byte
Color type 1 byte
Compression method 1 byte
Filter method 1 byte
Interlace method 1 byte
目前标准公开定义:
- Compression method:
0 - Filter method:
0 - Interlace method:
0或1
PLTE
调色板,每项 3 字节:
R G B
最多 256 项。
- Color type 3 必须有 PLTE。
- Color type 0、4 不允许有 PLTE。
- Color type 2、6 可把 PLTE 作为建议调色板,但不是像素真实索引表。
IDAT
保存 zlib 压缩数据。
多个 IDAT:
- 必须连续出现;
- Data 字段按顺序直接拼接;
- 拼接后形成一个 zlib 数据流;
- 不能把每个 IDAT 当成独立 zlib 流。
IEND
结束 Chunk,Data 长度为 0。IEND 后不得再有 PNG 内容。
5.4 Color Type 与 Bit Depth
| Color type | 图像类型 | 通道 | 允许 Bit depth |
|---|---|---|---|
| 0 | 灰度 | G | 1、2、4、8、16 |
| 2 | 真彩色 | RGB | 8、16 |
| 3 | 索引色 | Palette Index | 1、2、4、8 |
| 4 | 灰度 + Alpha | GA | 8、16 |
| 6 | 真彩色 + Alpha | RGBA | 8、16 |
Bit depth 表示:
- 非索引图:每个样本的位数;
- 索引图:每个调色板索引的位数;
- 不是总的“每像素位数”。
例如:
- Color type 6、Bit depth 8:每像素 (4\times8=32) bit。
- Color type 2、Bit depth 16:每像素 (3\times16=48) bit。
- Color type 3、Bit depth 4:每像素 4 bit,即每字节可存 2 个索引。
5.5 扫描线打包
非交错图像按:
从上到下逐行
每行从左到右
低于 8-bit 的灰度或索引样本会紧密打包,并把最左样本放在高位。
例如 1-bit 索引:
像素:1 0 1 1 0 0 1 0
字节:10110010
每条扫描线独立从字节边界开始,行尾未使用的低位不属于下一行。
过滤后结构:
Filter Type | Filtered Scanline Bytes
Filter Type | Filtered Scanline Bytes
...
5.6 辅助 Chunk
| Chunk | 作用 |
|---|---|
tRNS | 调色板 Alpha 表或单一透明颜色 |
gAMA | 图像 Gamma 信息 |
cHRM | 色度坐标和白点 |
sRGB | 声明标准 sRGB 显示意图 |
iCCP | 嵌入 ICC Profile |
pHYs | 物理像素尺寸 |
tEXt | Latin-1 文本 |
zTXt | 压缩 Latin-1 文本 |
iTXt | UTF-8 国际化文本 |
eXIf | Exif Profile |
bKGD | 建议背景色 |
tIME | 最后修改时间 |
不要把辅助 Chunk 理解为“都可以随便丢”。是否保留取决于:
- 是否影响颜色和显示;
- 是否正在修改图像像素;
- safe-to-copy 位;
- 产品是否要求保留元数据。
5.7 tRNS 与显式 Alpha
透明度可通过多种方式表示:
- Color type 4:灰度 + 完整 Alpha;
- Color type 6:RGBA 完整 Alpha;
- Color type 3 + tRNS:调色板项对应 Alpha;
- Color type 0 或 2 + tRNS:指定一个完全透明的灰度值或 RGB 值。
Color type 0/2 的 tRNS 不是完整渐变 Alpha,只能表示“某个颜色全透明,其他颜色全不透明”。
5.8 zlib 与 DEFLATE 在 IDAT 中的位置
IDAT Data 拼接
↓
zlib header
↓
DEFLATE blocks
↓
Adler-32
zlib 不等于 gzip:
- zlib 有自己的头部与 Adler-32;
- gzip 有另一套头部与 CRC;
- PNG IDAT 使用的是 zlib 格式,不是
.gz文件格式。
5.9 Adam7
Interlace method 1 使用 Adam7 七次扫描。它先传少量稀疏像素形成粗略预览,再逐步补全。
优点:
- 慢速传输时更早看到全局轮廓;
- 支持渐进显示。
代价:
- 编解码更复杂;
- 过滤按每个 reduced image 的扫描线执行;
- 通常会略微增加文件大小;
- 随机访问单个像素仍然不方便。
5.10 APNG
PNG 第三版规范包含动画 PNG 相关 Chunk:
acTL:动画控制;fcTL:帧控制;fdAT:后续帧数据。
APNG 还涉及:
- 帧区域;
- 延时;
- disposal;
- blend;
- 循环次数。
静态 PNG 解码器通常仍可显示默认图像,但不一定提供动画帧级控制。Web 工程中需要分别验证“能显示动画”和“能逐帧解码”这两种能力。
5.11 一个最小结构示意
89 50 4E 47 0D 0A 1A 0A PNG Signature
00 00 00 0D Length = 13
49 48 44 52 "IHDR"
... 13 bytes ... IHDR Data
... 4 bytes ... CRC
00 00 00 xx Length
49 44 41 54 "IDAT"
... zlib bytes ... IDAT Data
... 4 bytes ... CRC
00 00 00 00 Length = 0
49 45 4E 44 "IEND"
AE 42 60 82 常见 IEND CRC
六、编码器流程
6.1 总流程
输入像素
↓
确定颜色类型和位深
↓
可选:量化为调色板
↓
组织扫描线
↓
每行选择 Filter
↓
Filter Type + 过滤后字节
↓
zlib / DEFLATE
↓
切分 IDAT
↓
写 Signature、IHDR、辅助 Chunk、IDAT、IEND
↓
计算每个 Chunk CRC
6.2 第一步:选择表示方式
编码器需要判断:
- 是否真的需要 Alpha;
- 是否只有二值透明,可否用 tRNS;
- 颜色数量是否不超过 256,可否使用索引色;
- 8-bit 是否足够,还是必须保留 16-bit;
- 是否保留 ICC、Exif、文本等元数据。
这是编码器实现策略,不是 PNG 标准强制的唯一选择。
调色板收益例子
1000×1000、只有 16 种颜色:
- RGBA8 原始像素:约 4 MB;
- 4-bit 索引:约 0.5 MB;
- PLTE:最多几十字节;
- 再经过 Filter + DEFLATE,通常还能进一步压缩。
但把照片强行量化为 256 色会改变像素,量化步骤本身是有损的。PNG 仍会无损保存“量化后的像素”。
6.3 第二步:序列化扫描线
伪代码:
for (let y = 0; y < height; y++) {
const row = packPixels(image[y], colorType, bitDepth);
rows.push(row);
}
16-bit 样本按大端序写入。低位深灰度和索引值按高位优先打包。
6.4 第三步:选择 Filter
一种常见启发式策略:
- 为当前行计算五种过滤结果;
- 把输出字节解释为有符号残差;
- 计算绝对值之和;
- 选择分数最小者。
function score(filtered: Uint8Array): number {
let total = 0;
for (const byte of filtered) {
const signed = byte < 128 ? byte : byte - 256;
total += Math.abs(signed);
}
return total;
}
这只是编码器策略,并非规范要求。真正压缩最优的 Filter 组合与后续 DEFLATE 上下文有关;“残差绝对值最小”只是计算成本较低的近似。
6.5 第四步:DEFLATE
编码器在 DEFLATE 中还要决定:
- 块边界;
- 是否存储、不压缩、固定 Huffman 或动态 Huffman;
- LZ77 匹配长度;
- 搜索深度;
- lazy matching;
- 压缩速度与压缩率的权衡。
“压缩等级 9”不是 PNG 标准字段,而是具体压缩器的策略参数。
6.6 第五步:写 Chunk
推荐顺序的核心约束:
Signature
IHDR
颜色管理、PLTE 等前置 Chunk
连续 IDAT
尾部辅助 Chunk
IEND
每个 Chunk:
- 写 Data 长度;
- 写 Type;
- 写 Data;
- 对 Type + Data 计算 CRC;
- 写 CRC。
IDAT 可以按编码器缓冲区大小切分,切分位置不改变拼接后的 zlib 流。
七、解码器流程
7.1 总流程
读取 Signature
↓
循环读取 Chunk
↓
检查 Length、Type、CRC 和顺序
↓
解析 IHDR、PLTE、tRNS、颜色信息
↓
拼接 IDAT Data
↓
zlib 解封装 + DEFLATE 解压
↓
逐行读取 Filter Type
↓
反过滤
↓
解包低位深样本 / 调色板查表
↓
应用透明度和颜色处理
↓
Adam7 重组(若启用)
↓
输出 RGB/RGBA 或上传 GPU
7.2 安全读取 Chunk
interface PngChunk {
type: string;
data: Uint8Array;
}
function parseChunks(bytes: Uint8Array): PngChunk[] {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (bytes.length < signature.length) {
throw new Error("Truncated PNG signature");
}
for (let i = 0; i < signature.length; i++) {
if (bytes[i] !== signature[i]) {
throw new Error("Invalid PNG signature");
}
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const chunks: PngChunk[] = [];
let offset = 8;
while (offset < bytes.length) {
if (offset + 12 > bytes.length) {
throw new Error("Truncated chunk header");
}
const length = view.getUint32(offset, false);
const dataStart = offset + 8;
const dataEnd = dataStart + length;
const crcEnd = dataEnd + 4;
if (length > 0x7fffffff || dataEnd < dataStart || crcEnd > bytes.length) {
throw new Error("Invalid or truncated chunk length");
}
const typeBytes = bytes.subarray(offset + 4, offset + 8);
const type = String.fromCharCode(...typeBytes);
const data = bytes.slice(dataStart, dataEnd);
// 生产代码应计算并验证 typeBytes + data 的 CRC。
chunks.push({ type, data });
offset = crcEnd;
if (type === "IEND") break;
}
return chunks;
}
生产级解析器还必须处理:
- CRC;
- Chunk 顺序;
- 重复 IHDR/IEND;
- 未识别关键 Chunk;
- IHDR 合法组合;
- 解码尺寸上限;
- 元数据长度上限。
7.3 反过滤
function paeth(a: number, b: number, c: number): number {
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
function unfilterRow(
type: number,
filtered: Uint8Array,
previous: Uint8Array | null,
bpp: number,
): Uint8Array {
const out = new Uint8Array(filtered.length);
for (let i = 0; i < filtered.length; i++) {
const a = i >= bpp ? out[i - bpp] : 0;
const b = previous ? previous[i] : 0;
const c = previous && i >= bpp ? previous[i - bpp] : 0;
let predictor = 0;
if (type === 1) predictor = a;
else if (type === 2) predictor = b;
else if (type === 3) predictor = Math.floor((a + b) / 2);
else if (type === 4) predictor = paeth(a, b, c);
else if (type !== 0) throw new Error("Unknown filter type");
out[i] = (filtered[i] + predictor) & 0xff;
}
return out;
}
注意:a 必须来自 已经重建的当前行,不能来自过滤后的当前行。
7.4 解包与颜色展开
反过滤得到的仍可能不是 RGBA:
- 1/2/4-bit 灰度要拆位并缩放;
- 索引色要通过 PLTE 查 RGB;
- tRNS 可能提供 Alpha;
- 16-bit 样本可能要保留 16-bit,或按输出目标降到 8-bit;
- Color type 0/2/4/6 的通道布局不同。
解码器不能把“每像素 4 字节”写死。
7.5 Alpha 和颜色处理
正确流程需要明确:
- PNG 文件中的 Alpha 是 straight alpha。
- 渲染管线可能内部使用 premultiplied alpha。
- 转换时要避免 Alpha 为 0 时除零。
- 缩放、模糊和插值应避免把透明像素中的不合理 RGB 扩散到边缘。
- 颜色合成最好在正确的颜色空间中执行。
iCCP、sRGB、gAMA、cHRM等信息会影响最终显示。
7.6 流式解码与内存
反过滤至少通常需要:
- 当前行;
- 上一行;
- DEFLATE 状态;
- Chunk 解析状态。
因此理论上可以流式解码,而不必先把所有未压缩扫描线保存在内存中。
但最终若调用方需要完整 RGBA 位图,仍需:
[ W\times H\times 4 ]
字节或更多。4K RGBA8 一帧约:
[ 3840\times2160\times4=33{,}177{,}600\text{ bytes} ]
约 31.6 MiB,不包括解码器缓冲、GPU 纹理、原文件和中间副本。
八、复杂度与性能
8.1 时间复杂度
设解压后的扫描线字节总量为 (N)。
| 阶段 | 典型复杂度 |
|---|---|
| Chunk 解析 | (O(\text{文件字节数})) |
| CRC 校验 | (O(\text{Chunk Type + Data})) |
| DEFLATE 解压 | 近似 (O(N)),具体常数取决于实现 |
| 反过滤 | (O(N)) |
| 调色板展开 | (O(\text{像素数})) |
| Alpha 合成 | (O(\text{像素数})) |
| Adam7 重组 | (O(\text{像素数})) |
编码端若对每行尝试五种 Filter,过滤计算量约为单一 Filter 的 5 倍;高压缩级别的 LZ77 搜索还会显著增加编码时间。
8.2 编码复杂度高于解码复杂度
解码器只需执行码流指定的:
- DEFLATE 解码;
- Filter 反变换;
- 像素展开。
编码器则需要搜索:
- 颜色表示;
- 调色板;
- 每行 Filter;
- LZ77 匹配;
- Huffman 块策略。
因此“同样是无损格式”,高质量 PNG 优化器仍可能很慢。
8.3 CPU 与 GPU 分工
典型浏览器管线:
网络 / 文件
↓
CPU:格式解析、DEFLATE、反过滤、颜色展开
↓
系统图像缓冲
↓
GPU:纹理上传、缩放、混合、滤镜、显示
PNG 不像 H.264/H.265 那样普遍依赖专用视频解码硬件。多数性能收益来自:
- 浏览器原生解码器;
- SIMD;
- 减少像素复制;
- 异步解码;
- Worker;
- 合理缓存;
- 限制超大图。
8.4 并行化限制
- 单个 zlib 流具有历史依赖;
- Up、Average、Paeth 依赖上一扫描线;
- 当前行内 Sub/Paeth 还依赖已重建的左侧字节;
- 因此单图内部并非任意分块并行。
可行并行方向:
- 多张独立 PNG 并行;
- 解析与网络读取流水化;
- SIMD 加速 Filter;
- 解码后 GPU 并行处理像素;
- 缩略图任务分发到 Worker 池。
8.5 常见瓶颈
- 大尺寸 RGBA 输出导致内存峰值。
- 在主线程用 JavaScript 自己 inflate 和 unfilter。
getImageData()把 GPU/内部图像复制回 CPU。- 同一图片重复解码。
- 生成大量 Object URL 后未释放。
ImageBitmap、VideoFrame未关闭。- 解码后又创建多个全尺寸 Canvas 副本。
- 上传 GPU 前发生颜色格式转换和预乘转换。
- 解析器信任恶意宽高或 Chunk 长度。
- 把数百张 PNG 当视频帧实时播放。
九、实际场景
A. AI 生成视频网页
9.1 PNG 的正确定位
适合:
- 人物分割 Mask;
- 透明贴纸、Logo、字幕底图;
- 控制图、深度图或姿态图的无损交换;
- 模型调试时保存中间结果;
- 封面、缩略图;
- 需要精确像素的 UI 素材。
不适合:
- 长时间连续视频帧传输;
- 高帧率预览;
- 大量写实 AI 帧的正式交付;
- 依赖低延迟和低带宽的播放链路。
9.2 边生成边预览
错误设计:
模型每生成一帧
↓
编码一张 PNG
↓
浏览器下载并逐帧替换 <img>
问题:
- 不利用帧间冗余;
- 每帧都有 PNG 与 zlib 结构开销;
- 解码和纹理上传频繁;
- 网络抖动时难以连续播放;
- 缺少统一媒体时间戳和音频同步。
更合理:
低频关键预览:JPEG / PNG
连续播放:H.264 / AV1 等视频编码 + MP4/fMP4/WebM
透明蒙版:PNG 或专用纹理/压缩格式
9.3 Backpressure
即使只是缩略图,也要限制:
- 待下载图片数;
- 待解码图片数;
- 活跃 ImageBitmap 数;
- GPU 纹理数量;
- 缓存总字节数。
用户滚动离开或切换任务时,应取消旧请求并释放资源。
B. 在线 Web 视频编辑器
9.4 素材导入
PNG 常作为:
- 透明贴纸;
- Logo;
- 水印;
- UI 截图;
- 文字卡片;
- 绿幕替代的透明素材;
- 时间线缩略图。
导入时建议记录:
Asset ID
原始宽高
颜色类型
是否含 Alpha
ICC / sRGB 信息
文件字节数
解码后预计内存
缩略图缓存
9.5 透明边缘黑边
常见链路:
PNG straight alpha
↓
浏览器或 GPU 转为 premultiplied alpha
↓
缩放 / 模糊 / 滤镜
↓
又按 straight alpha 错误解释
↓
透明边缘黑边
排查清单:
- 输入是否 straight alpha;
- GPU 纹理上传是否预乘;
- Shader 是否按同一约定混合;
- Canvas 导出时是否发生转换;
- Alpha 为 0 的像素 RGB 是否被污染;
- 滤镜是在 premultiplied 还是 unpremultiplied 空间执行;
- 颜色空间是否一致。
9.6 时间线缩略图
PNG 适合少量需要无损文字边缘的缩略图,但大量视频缩略图通常应:
- 限制尺寸;
- 量化颜色;
- 选择 JPEG/WebP 等更小格式;
- 使用 Sprite Sheet;
- 建立 LRU 缓存;
- 仅缓存可视区域附近。
9.7 导出
最终导出视频时:
PNG 素材
↓
解码为 RGBA
↓
按时间线变换
↓
GPU 合成
↓
得到视频帧
↓
视频编码
↓
MP4 / WebM
PNG 只负责静态图像资产,不负责视频时间戳、音频、GOP 或最终容器。
十、Web 实现视角
10.1 普通显示
优先使用:
<img src="asset.png" alt="">
或 CSS background。浏览器负责:
- 网络加载;
- 原生解码;
- 颜色处理;
- 缓存;
- 渲染。
不应为了“掌握底层”而在生产代码中默认自己实现完整 PNG 解码器。
10.2 createImageBitmap
适合异步解码和向 Canvas/GPU 传递位图:
const blob = await fetch(url).then((r) => r.blob());
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
优势:
- 避免依赖 DOM
<img>; - 更适合 Worker/OffscreenCanvas 管线;
ImageBitmap可显式释放。
仍需注意:
- 不是零成本;
- 解码后仍可能占据完整像素内存;
- 颜色、Alpha 和方向选项要明确。
10.3 WebCodecs ImageDecoder
运行时探测:
async function canDecodePngWithImageDecoder(): Promise<boolean> {
if (!("ImageDecoder" in globalThis)) return false;
const Decoder = (globalThis as typeof globalThis & {
ImageDecoder: {
isTypeSupported(type: string): Promise<boolean>;
};
}).ImageDecoder;
return Decoder.isTypeSupported("image/png");
}
典型用途:
- 需要逐帧控制 APNG;
- 需要得到
VideoFrame; - 希望与 WebCodecs 媒体管线统一;
- 在 Worker 中进行解码。
使用完输出帧后及时:
frame.close();
降级路径:
ImageDecoder
↓ 不支持
createImageBitmap
↓ 不支持或不适用
HTMLImageElement
↓
服务端预处理
不要仅凭浏览器名称推断支持情况。
10.4 Canvas
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D unavailable");
ctx.drawImage(bitmap, 0, 0);
读取像素:
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
这通常会产生显著复制和同步成本。在线编辑器不应每帧都对全分辨率画布调用 getImageData()。
10.5 Canvas 导出 PNG
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(value) => value ? resolve(value) : reject(new Error("PNG export failed")),
"image/png",
);
});
注意:
quality参数不应被当成 PNG 的统一“质量等级”;- PNG 是无损格式;
- 不同浏览器或编码器的过滤和 DEFLATE 策略可能不同;
- 重新导出可能改变元数据、颜色 Chunk 和完全透明像素中的 RGB。
10.6 Worker 与 OffscreenCanvas
推荐把以下任务移出主线程:
- 大量缩略图解码;
- 图像缩放;
- 像素级滤镜;
- 自定义 Chunk 检查;
- PNG 编码;
- 资产预处理。
但不要盲目来回传完整 RGBA 数组。优先传:
- Blob;
- ArrayBuffer;
- 可转移 ImageBitmap;
- 可转移或可关闭的媒体资源。
10.7 CORS 与 Canvas 污染
跨域图片若没有正确 CORS 响应,通常可以显示,但一旦绘制到 Canvas 后,读取像素或导出可能被安全策略阻止。
工程上应:
- 配置资源服务器 CORS;
- 在加载前设置正确的跨域模式;
- 避免先加载后再设置;
- 对第三方素材提供服务端代理或转存机制。
10.8 自定义解析的适用边界
适合自己解析 Chunk:
- 学习格式;
- 读取宽高和颜色类型;
- 检查元数据;
- 安全扫描;
- 文件优化器;
- Debug。
不建议默认自己实现完整解码:
- DEFLATE 容错复杂;
- 颜色管理复杂;
- Adam7 和低位深打包容易出错;
- APNG 增加状态机;
- 安全边界难以覆盖;
- 浏览器原生实现通常更快、更成熟。
十一、易错点
-
PNG 与 DEFLATE 是同一个东西。 错。PNG 定义图像结构;DEFLATE 只是其中的压缩层。
-
zlib 与 gzip 相同。 错。二者都可承载 DEFLATE,但头部和校验格式不同。
-
PNG 无损,所以文件字节每次保存都相同。 错。像素可无损恢复,编码选择和元数据可以变化。
-
Filter 直接压缩数据。 错。Filter 保持数据长度,目的是改善后续可压缩性。
-
PNG 每张图都使用 Paeth。 错。每行可以选择不同 Filter,编码器策略不同。
-
Bit depth 就是每像素位数。 错。它通常是每样本位数;索引色时是每索引位数。
-
所有 PNG 解码后天然就是 RGBA8。 错。文件可为灰度、索引、16-bit 等,RGBA8 只是常见输出格式。
-
PLTE 只要出现就表示像素是索引。 错。真彩色图中 PLTE 可以只是建议调色板。
-
多个 IDAT 是多个独立压缩流。 错。它们的数据字段拼接后形成同一个 zlib 流。
-
CRC 包含 Length。 错。Chunk CRC 只覆盖 Type 和 Data。
-
Adler-32 校验整个 PNG 文件。 错。它属于 zlib 流,校验解压后的 zlib 数据。
-
tRNS 与完整 Alpha 完全等价。 错。灰度/真彩色 tRNS 只能指定单一透明颜色。
-
PNG 使用 premultiplied alpha。 错。文件样本使用 straight alpha。
-
透明像素的 RGB 无关紧要。 不完全正确。滤镜、插值、错误的预乘转换会让这些 RGB 污染边缘。
-
PNG 适合所有网页图片。 错。照片常常更适合有损格式。
-
PNG 适合用作视频帧传输。 通常不适合。它不利用帧间冗余,也没有视频时间轴语义。
-
Adam7 能让任意区域随机访问。 错。它提供渐进显示,不等于随机访问索引。
-
Canvas
toBlob("image/png", 0.5)会输出半质量 PNG。 不应这样假设。PNG 是无损,质量参数不是统一压缩级别。 -
能显示 APNG 就一定能逐帧读取。 错。显示能力和帧级 API 能力不同。
-
文件只有几 MB,解码内存也只有几 MB。 错。压缩后文件大小与完整 RGBA 内存可能相差数十倍。
十二、面试题
12.1 基础题 10 道
1. PNG 为什么是无损,但不同软件导出的大小不同?
- 标准回答: Filter、DEFLATE 搜索、Huffman 块、调色板和元数据策略不同,但解码像素相同。
- 追问: 哪些选择属于 PNG 标准,哪些属于编码器策略?
- 常见错误: “压缩等级越高,像素损失越多。”
- 高级回答: 还应讨论颜色类型转换、无损调色板化、IDAT 切分和透明像素 RGB 规范化。
2. JPEG 与 PNG 的核心差异是什么?
- 标准回答: JPEG 常用 DCT、量化和熵编码,通常有损;PNG 使用可逆过滤和 DEFLATE,无损并支持 Alpha。
- 追问: 为什么文字截图用 JPEG 容易出现噪声?
- 常见错误: “PNG 一定比 JPEG 清晰,所以永远更好。”
- 高级回答: 应从内容统计特性、目标码率和透明需求判断。
3. PNG 为什么先 Filter 再 DEFLATE?
- 标准回答: Filter 把高度相关的像素转换为小残差和重复模式,提升 LZ77 与 Huffman 的效果。
- 追问: Filter 后字节数是否减少?
- 常见错误: “Filter 会删除重复像素。”
- 高级回答: Filter 是可逆 decorrelation,优化的是符号分布和重复串。
4. 一个 PNG Chunk 有哪些字段?
- 标准回答: Length、Type、Data、CRC。
- 追问: CRC 是否覆盖 Length?
- 常见错误: “CRC 覆盖整个文件。”
- 高级回答: 说明 Chunk Type 大小写位的语义。
5. PNG 有哪些颜色类型?
- 标准回答: 灰度、真彩色、索引色、灰度 Alpha、真彩色 Alpha,对应 0、2、3、4、6。
- 追问: 为什么没有 Color type 1、5?
- 常见错误: “Color type 等于通道数。”
- 高级回答: Color type 是 palette、truecolor、alpha 标志位组合。
6. PNG 如何表示透明度?
- 标准回答: 显式 Alpha 通道或 tRNS。
- 追问: Color type 2 的 tRNS 能否表示半透明?
- 常见错误: “tRNS 就是每像素 Alpha。”
- 高级回答: 区分调色板 Alpha 表和单一透明样本值。
7. 多个 IDAT 如何解码?
- 标准回答: 连续拼接所有 IDAT Data,再作为一个 zlib 流解压。
- 追问: 能否逐个 IDAT 独立 inflate?
- 常见错误: “每个 IDAT 都有独立 zlib 头。”
- 高级回答: IDAT 切分只是 Chunk 层分段,便于流式和固定缓冲。
8. CRC 与 Adler-32 有什么区别?
- 标准回答: CRC 位于每个 Chunk,覆盖 Type+Data;Adler-32 位于 zlib 流尾部,校验解压数据。
- 追问: 为什么两个都要有?
- 常见错误: “二者算法完全相同。”
- 高级回答: 说明其分属 PNG 结构层和 zlib 压缩层。
9. Adam7 的作用是什么?
- 标准回答: 七遍交错传输,支持渐进显示。
- 追问: 是否一定更小?
- 常见错误: “Adam7 是压缩算法。”
- 高级回答: 它通常略增体积,并改变 reduced image 的扫描线与过滤过程。
10. 为什么 PNG 不适合连续视频?
- 标准回答: 每帧独立,无帧间预测,带宽、解码和纹理上传成本高,也缺少媒体时间轴。
- 追问: 哪些例外场景可接受?
- 常见错误: “因为 PNG 不能动画。”
- 高级回答: 低帧率调试、透明序列和离线中间帧可使用,但正式播放应使用视频编码。
12.2 底层实现题 10 道
1. 如何安全解析 Chunk?
- 标准回答: 验证 Signature、剩余长度、Length 上限、整数溢出、CRC、顺序和关键 Chunk。
- 追问: 恶意 Length 会造成什么问题?
- 常见错误: 直接
offset += length + 12。 - 高级回答: 增加总元数据预算、尺寸预算和流式读取状态机。
2. Sub 反过滤为什么要读已经重建的左侧字节?
- 标准回答: 编码预测基于原始左侧字节,解码时只有重建后的左侧字节与之相同。
- 追问: RGBA8 的
bpp是多少? - 常见错误: 从 filtered row 读取左侧。
- 高级回答: 讨论低位深图时
bpp至少取 1。
3. Paeth 如何处理距离相等?
- 标准回答: 按规范优先顺序选择,常见实现先 a,再 b,再 c。
- 追问: 为什么不能随意改变?
- 常见错误: 随机选择任意一个。
- 高级回答: 编码和解码必须确定性一致,否则像素会错误。
4. 如何处理 1-bit 索引图?
- 标准回答: 按扫描线高位优先拆 bit,再通过 PLTE 查 RGB。
- 追问: 行尾剩余 bit 如何处理?
- 常见错误: 跨扫描线继续使用剩余 bit。
- 高级回答: 每条扫描线从字节边界重新开始。
5. 如何合并 PLTE 和 tRNS?
- 标准回答: PLTE 给 RGB,tRNS 依次给部分调色板项 Alpha,未提供项默认为 255。
- 追问: tRNS 可以比 PLTE 长吗?
- 常见错误: 未提供 Alpha 的项默认为 0。
- 高级回答: 先验证索引范围,再输出 RGBA。
6. PNG 能否只保留一行缓冲解码?
- 标准回答: 反过滤至少需当前行和上一行;若调用方流式消费,可不保留所有扫描线。
- 追问: 为什么最终仍可能需要完整位图?
- 常见错误: “解码 PNG 必须一次性分配全部中间数据。”
- 高级回答: 区分解码状态、输出缓冲和 GPU 上传策略。
7. 遇到未知 Chunk 怎么办?
- 标准回答: 未知关键 Chunk 不能忽略;未知辅助 Chunk 可按规范规则跳过。
- 追问: 编辑后能否原样复制未知辅助 Chunk?
- 常见错误: 所有未知 Chunk 都丢弃。
- 高级回答: 结合第四字节 safe-to-copy 位和是否修改关键图像数据。
8. 如何选择最佳 Filter?
- 标准回答: 可尝试五种并以残差绝对值等启发式评分。
- 追问: 局部评分最优是否等于最终 DEFLATE 最小?
- 常见错误: 固定 Paeth 必然最优。
- 高级回答: 全局压缩最优需考虑 DEFLATE 历史, exhaustive search 成本很高。
9. 如何防御 PNG 解压炸弹?
- 标准回答: 在解压前用宽高、位深、通道数计算理论输出并设上限;限制 Chunk、元数据和总 CPU 时间。
- 追问: 只限制文件大小够不够?
- 常见错误: “有 CRC 就安全。”
- 高级回答: 使用 checked arithmetic、取消信号、隔离 Worker/进程和像素预算。
10. APNG 解码器需要哪些额外状态?
- 标准回答: 帧区域、延时、序号、dispose、blend、输出缓冲和上一帧备份。
- 追问:
PREVIOUSdisposal 为什么昂贵? - 常见错误: 把每个 fdAT 当成完整 PNG。
- 高级回答: 讨论局部帧合成、备份区域而非整帧和时间调度。
12.3 数学题 10 道
1. 800×600 RGBA8 的未过滤扫描线数据多大?
- 标准回答: 每行 3200 字节;含 Filter Type 为 3201;总计 1,920,600 字节。
- 追问: 为什么不是最终文件大小?
- 常见错误: 忽略每行 Filter Type。
- 高级回答: 再估算 RGBA 输出、压缩文件和 GPU 纹理三份内存。
2. 宽 13 的 1-bit 灰度图每行多少字节?
- 标准回答: (\lceil13/8\rceil=2) 字节,过滤前每行 2 字节。
- 追问: 含 Filter Type 呢?
- 常见错误: 13 字节。
- 高级回答: 说明最后 3 个低位未使用。
3. 对 [10,13,15,20] 做 Sub。
- 标准回答:
[10,3,2,5]。 - 追问: 若发生负数怎么办?
- 常见错误: 保留负数。
- 高级回答: 字节结果按模 256 保存。
4. 上一行 [10,20,30],当前 [12,18,35] 做 Up。
- 标准回答:
[2,254,5]。 - 追问: 254 反过滤为何能得到 18?
- 常见错误: 把 -2 存成有符号整数。
- 高级回答: ((254+20)\bmod256=18)。
5. (a=90,b=100,c=80,x=95) 的 Paeth 残差是多少?
- 标准回答: (p=110),距离分别 20、10、30,选 b=100,残差为 251。
- 追问: 为什么 251 表示 -5?
- 常见错误: 输出 -5。
- 高级回答: DEFLATE 看到的是无符号字节,但评分时可解释为有符号残差。
6. 25% 白色覆盖黑色,输出通道值约多少?
- 标准回答: (0.25\times255\approx64)。
- 追问: 在 sRGB 编码值上直接算是否严格正确?
- 常见错误: 255。
- 高级回答: 正确光学合成要考虑线性光和传递函数。
7. [1,2,3] 的 Adler-32 是多少?
- 标准回答:
0x000D0007。 - 追问: 初始 (s_1) 为什么是 1?
- 常见错误: 两个和都从 0 开始。
- 高级回答: 解释长度和全零序列的区分。
8. 文件 200 KB,解压 RGBA 为 8 MB,压缩比是多少?
- 标准回答: 约 40:1。
- 追问: 这是否一定是攻击?
- 常见错误: 用 200/8000。
- 高级回答: 压缩比需结合尺寸、内容和资源预算判断。
9. 256 色 1920×1080 索引图的索引原始数据约多大?
- 标准回答: 8-bit 索引约 2,073,600 字节,另有调色板和每行 Filter Type。
- 追问: RGBA8 是多少?
- 常见错误: 仍按 4 字节每像素。
- 高级回答: 对比约 8.29 MB 的 RGBA8。
10. 同时保留原 Blob、RGBA、两个 Canvas 和一个 GPU 纹理,如何估算峰值?
- 标准回答: Blob + 至少四份像素级资源,4K 时可能超过 130 MB。
- 追问: 哪些资源可能共享?
- 常见错误: 只算文件大小。
- 高级回答: 强调实现相关、对齐、双缓冲和临时转换会继续增加峰值。
12.4 Web 实战题 10 道
1. 网页只需显示 PNG,优先用什么?
- 标准回答:
<img>或 CSS,让浏览器原生加载解码。 - 追问: 何时用 createImageBitmap?
- 常见错误: 默认 fetch ArrayBuffer 后自己解码。
- 高级回答: 结合缓存、Worker、Canvas 和生命周期选择。
2. 需要逐帧控制 APNG 怎么做?
- 标准回答: 先探测 WebCodecs ImageDecoder 与
image/png支持,否则降级到普通显示或服务端拆帧。 - 追问: 能播放 APNG 是否等于能逐帧解码?
- 常见错误: 按浏览器品牌硬编码。
- 高级回答: 设计能力探测和统一帧调度接口。
3. Canvas 合成后透明边缘发黑,如何排查?
- 标准回答: 检查 straight/premultiplied alpha、滤镜顺序、透明像素 RGB 和颜色空间。
- 追问: 为什么缩放会暴露问题?
- 常见错误: 只调 CSS 背景色。
- 高级回答: 在预乘空间做插值,边界处避免无效 RGB 污染。
4. 图片能显示但 getImageData 报安全错误,为什么?
- 标准回答: 跨域图片使 Canvas 被污染。
- 追问: 如何修复?
- 常见错误: 捕获异常后继续。
- 高级回答: 资源端 CORS、加载模式、代理转存和可信源策略。
5. canvas.toBlob("image/png", 0.2) 是否会降低画质?
- 标准回答: 不应把质量参数理解为 PNG 有损质量;PNG 输出仍是无损。
- 追问: 如何减小文件?
- 常见错误: 认为会像 JPEG quality 20。
- 高级回答: 缩尺寸、调色板化、移除元数据、优化 Filter/DEFLATE。
6. <img> 与 createImageBitmap 如何选择?
- 标准回答: DOM 展示用
<img>;绘制和 Worker 管线可用 ImageBitmap。 - 追问: ImageBitmap 用完要做什么?
- 常见错误: 认为 ImageBitmap 永远零复制。
- 高级回答: 讨论转移、关闭、内部资源和缓存策略。
7. 如何避免大量缩略图阻塞主线程?
- 标准回答: Worker、OffscreenCanvas、并发限制、可视区域加载、取消和 LRU。
- 追问: 并发越高越好吗?
- 常见错误: 一次
Promise.all解码全部。 - 高级回答: 以像素预算而不是文件数做调度。
8. ImageDecoder 输出为什么要 close?
- 标准回答: 输出帧可能持有大块系统或 GPU 媒体资源。
- 追问: ImageBitmap 呢?
- 常见错误: 依赖 GC 立即回收。
- 高级回答: 统一资源句柄、引用计数和取消路径。
9. 如何把 PNG 序列导出为 MP4?
- 标准回答: 解码每张 PNG 为帧,按时间戳送入视频编码器,再 mux 为 MP4。
- 追问: VideoEncoder 输出就是 MP4 吗?
- 常见错误: 把 PNG 字节直接塞进 MP4 视频轨。
- 高级回答: 说明帧率、时间基、颜色、Alpha 降级和 Backpressure。
10. 浏览器不支持所需解码 API 时如何降级?
- 标准回答: ImageDecoder → createImageBitmap →
<img>→ 服务端转换。 - 追问: 如何保持业务层接口稳定?
- 常见错误: 只弹“不支持”。
- 高级回答: 抽象 DecodeProvider,统一输出尺寸、时间戳、颜色和释放接口。
12.5 系统设计题 5 道
1. 设计 AI 视频网页的 Mask 资产管线
- 标准回答: 原始 Mask → 选择灰度/索引 PNG → 对象存储 → CDN → 按需解码 → GPU 纹理 → LRU 释放。
- 追问: 何时不用 PNG?
- 常见错误: 每帧全尺寸 RGBA PNG。
- 高级回答: 二值 Mask 可位打包;连续 Mask 可用视频或专用压缩;建立版本和颜色语义。
2. 设计 Web 编辑器的透明素材系统
- 标准回答: 导入探测 → 元数据与安全校验 → 缩略图 → 原图缓存 → ImageBitmap/纹理 → 统一预乘约定 → 导出重用。
- 追问: 如何处理 16-bit 或 ICC?
- 常见错误: 所有素材立即转 RGBA8 并永久缓存。
- 高级回答: 按编辑质量、预览质量和最终导出质量分层。
3. 设计十万张 PNG 缩略图服务
- 标准回答: 服务端预生成多级尺寸、内容哈希、CDN、现代格式协商、客户端虚拟列表和 LRU。
- 追问: 为什么不让浏览器下载原图缩放?
- 常见错误: 客户端无限并发。
- 高级回答: 像素成本计费、缓存键包含色彩与裁剪参数、异步任务去重。
4. 设计在线 PNG 优化器
- 标准回答: 解析 → 安全限制 → 可选无损调色板化 → 多 Filter/DEFLATE 策略 → 元数据策略 → 像素哈希验证。
- 追问: 如何证明无损?
- 常见错误: 只比较文件 CRC。
- 高级回答: 解码前后比较规范化像素、Alpha、颜色语义和需保留元数据。
5. 设计不可信 PNG 的浏览器预览沙箱
- 标准回答: 尺寸和字节预算、Worker 隔离、超时取消、原生解码优先、禁止无限元数据、失败回退。
- 追问: 为什么 CRC 不能替代安全校验?
- 常见错误: 只检查扩展名。
- 高级回答: 内容嗅探、CSP、对象 URL 生命周期、进程隔离和服务端扫描。
十三、练习
13.1 手算题
练习 1:五种 Filter
上一行:
10, 20, 30, 40, 50
当前行:
12, 22, 31, 44, 55
灰度 8-bit,分别计算:
- None;
- Sub;
- Up;
- Average;
- Paeth。
再对结果执行反过滤,确认恢复原行。
练习 2:RGBA 的 bpp
给出 RGBA8 当前扫描线前 12 个字节,解释为什么 Sub 的左侧参考不是前 1 字节,而是前 4 字节。
练习 3:扫描线尺寸
计算:
- 1920×1080、RGB8;
- 1920×1080、RGBA16;
- 641×480、1-bit 灰度;
- 1000×1000、4-bit 索引。
分别给出每行字节数和进入 DEFLATE 前的数据量。
13.2 码流分析题
使用十六进制查看器或 xxd:
xxd -g 1 input.png | head -n 30
完成:
- 找到 Signature;
- 找到第一个 IHDR;
- 读取 IHDR 长度;
- 手工解析宽高;
- 记录 color type、bit depth、interlace;
- 找到全部 IDAT;
- 确认 IEND;
- 验证至少一个 Chunk CRC。
13.3 伪代码题
实现:
parsePngHeader(bytes): {
width: number;
height: number;
bitDepth: number;
colorType: number;
interlace: number;
}
要求:
- 不分配整图像素;
- 检查 Signature;
- IHDR 必须首先出现;
- 检查 IHDR Data 长度为 13;
- 使用大端读取;
- 拒绝宽高为 0;
- 拒绝非法 color type 与 bit depth 组合。
13.4 架构设计题
设计一个“在线贴纸素材库”:
- 10000 个 PNG;
- 支持透明预览;
- 时间线上最多同时显示 50 个;
- 支持缩放、旋转、模糊;
- 移动端内存预算 300 MB。
输出:
- 服务端预处理格式;
- 缩略图策略;
- 原图加载时机;
- 解码并发;
- GPU 纹理缓存;
- 取消机制;
- LRU;
- 导出时如何拿到高质量原图。
13.5 性能估算题
一张 4096×4096 RGBA8 PNG,文件大小 12 MB。
估算至少包含:
- 原始 Blob;
- 解码 RGBA;
- Canvas backing store;
- 一份 GPU 纹理;
- 一个缩小预览;
- 编码导出临时缓冲。
说明为什么实际峰值可能显著高于简单相加。
13.6 Debug 题
症状 A:透明边缘有黑线
逐项排查:
- 输入 Alpha 表示;
- 透明像素 RGB;
- Canvas;
- WebGL blending;
- Shader;
- 缩放;
- 导出;
- 颜色空间。
症状 B:某些 PNG 解码失败
逐项排查:
- Signature;
- IHDR;
- color type/bit depth;
- Chunk 顺序;
- CRC;
- IDAT 是否连续;
- zlib header;
- Filter Type;
- Adam7;
- 文件截断。
症状 C:滚动素材库后内存持续上升
逐项排查:
- Object URL;
- ImageBitmap;
- VideoFrame;
- Canvas;
- GPU texture;
- DOM 引用;
- Promise 队列;
- 缓存上限;
- 请求取消;
- Worker 消息中的 ArrayBuffer。
13.7 本周验收
不看资料完成:
- 画出 PNG 编码与解码流程。
- 写出 Signature。
- 写出 Chunk 四字段。
- 解释五种 Filter。
- 手算一个 Paeth。
- 区分 zlib、DEFLATE、CRC、Adler-32。
- 写出五种 color type。
- 解释 tRNS。
- 解释 PNG straight alpha 与渲染 premultiplied alpha。
- 设计 PNG 在 Web 视频编辑器中的资产管线。
达到 8/10 才进入第 5 周。
十四、本章速查表
14.1 名词
| 名词 | 一句话解释 |
|---|---|
| PNG | 基于 Chunk、可逆过滤和 DEFLATE 的无损图像格式 |
| Chunk | Length、Type、Data、CRC 组成的数据单元 |
| Scanline | 从左到右的一行像素序列 |
| Filter | 把像素字节变成可逆残差 |
| DEFLATE | LZ77 + Huffman 的无损压缩格式 |
| zlib | DEFLATE 外层头部与 Adler-32 封装 |
| PLTE | RGB 调色板 |
| tRNS | 调色板 Alpha 或单一透明颜色 |
| Adam7 | 七遍交错渐进显示 |
| straight alpha | RGB 未乘 Alpha 的表示 |
14.2 五种 Filter
| Type | 名称 | Predictor |
|---|---|---|
| 0 | None | 0 |
| 1 | Sub | (a) |
| 2 | Up | (b) |
| 3 | Average | (\lfloor(a+b)/2\rfloor) |
| 4 | Paeth | PaethPredictor((a,b,c)) |
统一形式:
[ F(x)=(x-\text{Predictor})\bmod256 ]
反过滤:
[ x=(F(x)+\text{Predictor})\bmod256 ]
14.3 关键码流
Signature
IHDR
[PLTE]
[Ancillary Chunks]
IDAT
IDAT
...
IEND
14.4 核心公式
[ S=\left\lceil\frac{W\cdot B_{pp}}8\right\rceil ]
[ N_{\text{filtered}}=H(S+1) ]
[ p=a+b-c ]
[ C_o=\alpha C_f+(1-\alpha)C_b ]
14.5 面试结论
- PNG 的无损来自所有步骤可逆,不是因为“没有压缩”。
- Filter 不减少长度,而是提升 DEFLATE 可压缩性。
- IDAT 使用 zlib 格式承载 DEFLATE。
- 多个 IDAT 拼接为一个 zlib 流。
- PNG 文件保存 straight alpha,渲染管线常使用 premultiplied alpha。
- 文件小不代表解码内存小。
- PNG 适合 UI、透明素材和无损中间图,不适合连续视频传输。
- 浏览器生产代码优先使用原生解码,并对 ImageDecoder 做运行时探测。
- 自定义解析器必须防御尺寸、长度、解压和元数据攻击。
- 编码器搜索复杂,解码器流程相对确定。
十五、参考资料
-
W3C, Portable Network Graphics (PNG) Specification (Third Edition) https://www.w3.org/TR/png-3/
-
RFC 1950, ZLIB Compressed Data Format Specification version 3.3 https://www.rfc-editor.org/info/rfc1950
-
RFC 1951, DEFLATE Compressed Data Format Specification version 1.3 https://www.rfc-editor.org/info/rfc1951
-
W3C, WebCodecs https://www.w3.org/TR/webcodecs/
-
WHATWG, HTML Living Standard — Canvas https://html.spec.whatwg.org/multipage/canvas.html
-
zlib Manual https://zlib.net/manual.html
本周总结
PNG 的完整知识链不是“无损图片”四个字,而是:
颜色表示
↓
像素打包
↓
扫描线
↓
可逆 Filter
↓
zlib / DEFLATE
↓
Chunk 与 CRC
↓
Alpha、颜色管理和浏览器输出
真正需要记住的工程结论是:
PNG 用空间预测改善通用无损压缩,用 Chunk 提供结构和扩展能力;它非常适合 UI、透明素材和精确像素,但不应被误用为连续视频格式。
下一周将从单帧图像进入时间维度,学习视频编码的共同框架:帧内预测、帧间预测、运动估计、残差、变换、量化、熵编码、参考帧和 GOP。