图片文件签名:PNG、JPEG、WebP 与 AVIF
2026-09-30 · 1175 词
给解码器三样东西 —— 一个以 .png 结尾的文件名、一个写着 image/jpeg 的头、一段以 FF D8 FF 开头的字节流 —— 其中只有一样值得相信。扩展名是一种命名约定,改个名它就变了。声明的 MIME 类型是产出这份数据的人写下的一句声明,而它错得足够频繁,把它当事实的后果就是:文件能在你浏览器里打开,却在其他所有工具里坏掉。
字节流是唯一无法撒谎的部分,因为解码器要画的正是这些字节。
签名到底是什么
每个容器格式都以一段固定图案开头,好让读取方不必解析整个文件就能识别它。这段图案位于已知偏移 —— 通常是第 0 字节,有时是第 4 字节 —— 而且很短:两到十二个字节。
PNG 89 50 4E 47 0D 0A 1A 0A 接着是 IHDR 块
JPEG FF D8 FF 接着是段标记
GIF 47 49 46 38 ("GIF8") 接着是 39 或 37 表示版本
WebP 52 49 46 46 __ __ __ __ 57 45 42 50 ("RIFF" 长度 "WEBP")
BMP 42 4D ("BM")
TIFF 49 49 2A 00(小端)/ 4D 4D 00 2A(大端)
ICO 00 00 01 00
AVIF 偏移 4 处 66 74 79 70,主品牌 "avif"
HEIC 偏移 4 处 66 74 79 70,主品牌 "heic" 或 "mif1"
JP2 00 00 00 0C 6A 50 20 20 0D 0A 87 0A
PSD 38 42 50 53 ("8BPS")
这张表里的两个细节,解释了人们遇到的大部分困惑。
第一,PNG 的签名里含有 0D 0A —— 一个 CRLF —— 以及 1A,DOS 的文本结束符。两者都是刻意为之:1990 年代这样选,是为了让 PNG 文件经过文本模式 FTP 传输时可见地损坏,而不是静默地被搞坏。这也是为什么 PNG 的开头字节在 hex dump 里看起来像文本控制字符。
第二,AVIF、HEIC 和 HEIF 共用同一个容器。它们都是 ISO 基础媒体文件,所以都以一个 ftyp box 开头;区别在于偏移 8 处那四个字符的品牌。只检查 ftyp 的解码器会愉快地把 AVIF 文件报成 "HEIC"。这就是一个正确实现应当返回候选列表而不是单一答案的原因,也是 Base64 转图片 会报告证据(ftyp box + 主品牌 "avif")而不只报格式名的原因。
用 JavaScript 读这些字节
四行,无依赖。取解码后的前十二个字节来比较:
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const head = bytes.subarray(0, 12);
const eq = (offset, ...sig) => sig.every((b, i) => head[offset + i] === b);
const ascii = (offset, len = 4) => String.fromCharCode(...head.subarray(offset, offset + len));
if (eq(0, 0x89, 0x50, 0x4e, 0x47)) return 'image/png';
if (eq(0, 0xff, 0xd8, 0xff)) return 'image/jpeg';
if (eq(0, 0x47, 0x49, 0x46, 0x38)) return 'image/gif';
if (eq(0, 0x52, 0x49, 0x46, 0x46) && ascii(8) === 'WEBP') return 'image/webp';
if (ascii(4) === 'ftyp') {
const brand = ascii(8);
if (brand === 'avif' || brand === 'avis') return 'image/avif';
if (brand === 'heic' || brand === 'heix' || brand === 'mif1') return 'image/heic';
}
return null;
注意签名检查之后发生了什么:head 只有前十二个字节,所以这段代码识别的是容器,不是内容。签名说的是"这是一条 PNG 流",从来不是"这张 PNG 是完整的"。这是两个不同的问题,把它们混为一谈,正是"文件看起来好好的、图片却下半截灰了"这类上报背后的大头 —— 尤其是 JPEG,它的头里没有总长度字段,所以一张被截断的 JPEG 在前几 KB 里看起来仍然完全合法。
你实际会遇到的四个冒名者
当解码器报"不是图片"时,更有用的问题是那它是什么。实践中,四个家族几乎覆盖了所有出现在"预期是图片"位置的东西。
- 压缩包。
1F 8B是 gzip,78 9C是 zlib 流,50 4B 03 04是 ZIP 条目,50 4B 05 06是空 ZIP 的中央目录结束记录。有人为了走文本通道而 Base64 编码的 gzip JSON 就落在这里,而且它是这类上报最常见的成因。 - 文档。
25 50 44 46是%PDF。浏览器里能行内预览的 PDF,在你只看到一个缩略图时很容易被误认成图片。 - 可执行文件。
7F 45 4C 46是 ELF,00 61 73 6D是 WebAssembly 模块,偏移 0 处的FF FE或FE FF表示 UTF-16 文本。 - 已经被编码过的文本。 一串
0-9a-f是十六进制,不是"字符有点奇怪的 Base64"。把十六进制字节当 Base64 解码,得到的是看起来合理的乱码而不是报错 —— 所以这个区分很重要:修法是按十六进制解析它,而不是继续重新编码它。
告诉别人"它解出了 200 KB,但字节是 gzip",和"输入无效"是两个完全不同、且有用得多的陈述。
为什么签名检查应该在 <img> 之前
如果你把解出的字节赋给 <img> 而格式不对,浏览器会静默失败:一个裂图图标、一个 error 事件,控制台里什么都没有。没有异常,没有诊断。一次签名检查把这份沉默变成一句话,而它的成本是微秒级,因为只读十二个字节。
同一个检查,也是敢于接受完全没有 data: 前缀的裸字符串的原因 —— 见 裸字符串解码器 —— 因为格式可以从字节确定,而不是从一个可能根本不存在的头确定。
它还是那把"静默失败"变成"一句话"的检查。当载荷能解码但格式不对时,有用的输出不是"无效图片"而是"这些字节是 gzip";Base64 图片不显示 讲的正是这个判断顺序,而 在 Python、Node 和浏览器里解码 讲的是你把字节拿到手时会遇到的运行时差异。
关于偏移有一个注意点:它们是解码后字节流里的偏移,而 Base64 解码必须先完成。编码形态里的空白、换行和填充不会在解码后移动任何东西,这正是先剥掉它们为什么安全。如果你想在 Base64 文本本身上推理签名,很快就会糊涂:3 个字节变成 4 个字符,所以 PNG 的八字节签名在编码形态里占大约十一个字符,而它们的值与 89 50 4E 47 毫无关系。先解码,再看。
签名是证据,不是保证
这一切都不证明文件是完好的。签名检查回答的是"我该把这个交给哪个解码器",剩下的由解码器自身的校验回答。这个分工是对的:签名是那个便宜、可靠的第一个问题,而格式自己的完整性规则 —— PNG 的块长度和终止 IEND、JPEG 的标记链 —— 之后再做昂贵的工作。
它也解释了为什么"强制按某格式解析"对极少数签名错、内容却正常的文件是合理的逃生口:真实数据偶尔在源头就被贴错了标签,字节应当拥有最终发言权。只是要意识到,你正在覆盖输入里唯一不是声明的部分,所以出货之前请验证结果。