在 Python、Node.js 和浏览器里解码 Base64 图片
2026-09-30 · 1163 词
Base64 是标准化的,但"解码器有多宽容"从来不是标准化的。Python 接受的字符串可能被 Node 悄悄弄坏,Node 愉快解码的字符串可能让 atob 抛错。弄清楚哪个运行时是什么脾气,就能把一个令人困惑的失败变成一个预期中的失败。
Python:严格、明确,也是做校验的最佳选择
Python 的 base64 模块是三者中最诚实的。默认情况下它会忽略换行以及字符串中间它不认识的字符;加上 validate=True 后,字母表之外的任何东西都会被拒:
import base64, binascii
payload = uri.split(',', 1)[1] if ',' in uri else uri
payload = payload.replace('-', '+').replace('_', '/') # base64url → 标准
payload += '=' * (-len(payload) % 4) # 补回被丢掉的填充
try:
raw = base64.b64decode(payload, validate=True)
except binascii.Error as e:
raise SystemExit(f'不是合法的 base64:{e}')
open('out.png', 'wb').write(raw)
print(len(raw), raw[:8].hex(' '))
这段里有三点值得指出。填充修复发生在校验之前,所以一个在传输中丢掉 = 的字符串会被接受。字母表转换发生在填充修复之前,因为填充的计算用的是替换之后的长度 —— 两者长度相同,所以这个顺序是安全的;但在一个假设不同的语言里反过来做,是差一 bug 的常见来源。要捕获的异常是 binascii.Error —— base64.b64decode 不会抛 ValueError。
如果你想要尽可能严格的检查,那就解完之后验证结果:
from PIL import Image
import io
img = Image.open(io.BytesIO(raw))
img.verify() # 文件被截断或结构损坏时抛错
print(img.format, img.size)
verify() 是最接近回答"这真的是一张完整图片吗"的东西 —— 而那是与"Base64 解码成功了吗"完全不同的问题。一张被截断的 JPEG 会完美解码,然后在这里失败。
Node.js:宽容到危险的程度
Buffer.from(s, 'base64') 遇到非法输入不会抛错。它会丢弃不认识的字符,然后返回它能解出来的部分 —— 也就是说,一个被截断或被污染的字符串会毫无报错地产出字节:
const raw = Buffer.from(payload, 'base64');
console.log(raw.length, raw.subarray(0, 8).toString('hex'));
对宽容输入很方便,对校验很糟糕,因为一个丢了最后 20% 字符的字符串依然返回一个 buffer。如果你解码的不是自己产出的数据,先校验形状再解码:
const clean = payload.replace(/[\r\n\t ]+/g, '');
if (clean.length % 4 === 1) throw new Error('被截断:长度 % 4 === 1');
if (/[^A-Za-z0-9+/=]/.test(clean)) throw new Error('载荷里有非法字符');
const raw = Buffer.from(clean, 'base64');
Node 还原生支持 base64url,可以完全省掉手动替换:
const raw = Buffer.from(token, 'base64url'); // 认识 '-' 和 '_',填充可选
对磁盘上的文件,整件事是一行:
import { writeFileSync } from 'node:fs';
writeFileSync('out.png', Buffer.from(payload, 'base64'));
浏览器:atob、字节,以及一个关键细节
atob 解码得到的是一个"二进制字符串" —— 每个字符对应一个字节 —— 这与字节本身不是一回事。把这些字符交给任何期待文本的地方,所有大于 0x7F 的内容都会被破坏:
const buf = atob(payload);
console.log(buf.length, buf.charCodeAt(0).toString(16));
const bytes = Uint8Array.from(buf, (c) => c.charCodeAt(0)); // 真正的字节
const blob = new Blob([bytes], { type: 'image/png' });
img.src = URL.createObjectURL(blob);
Uint8Array.from 这一步不能省。new Blob([atob(payload)]) 看起来等价,但对任何含大于 127 的字节的图片都会产出损坏文件 —— 那是绝大多数图片,因为压缩数据接近均匀随机。症状是一张"几乎"能用的图片:尺寸正确、出现绿色或灰色条带,或者解码器直接拒绝。
atob 也是三者中对空白最严格的。按现行规范,它遇到换行会抛 InvalidCharacterError,所以 MIME 折行的字符串必须先剥干净:
const bytes = Uint8Array.from(atob(payload.replace(/[\r\n\t ]+/g, '')), (c) => c.charCodeAt(0));
命令行,当没有脚本可用时
两个工具能做这件事,严格程度并不相同:
payload=$(printf '%s' "$B64" | sed 's/^.*,//')
printf '%s' "$payload" | base64 -d > out.bin # GNU coreutils,遇到坏输入会停
printf '%s' "$payload" | openssl base64 -d -A > out.bin # -A 忽略换行
GNU 系统上的 base64 -d 遇到字母表之外的字符会报 invalid input 并以非零退出,这让它成为一个还不错的校验器。macOS 用的是 BSD base64,选项是 -D 而不是 -d。无论用哪个,后面都跟一次签名检查 —— 那是唯一能知道"你拿到了什么"的办法:
file out.bin # → PNG image data, 1 x 1, 8-bit/color RGBA, non-interlaced
xxd out.bin | head -1
浏览器里的第五条路:交给 fetch
有一条完全绕开 atob、也避开上面所有体积限制的路,因为浏览器在内部完成了 data URI 的解码:
const blob = await (await fetch(uri)).blob(); // uri 是完整的 data: 字符串
img.src = URL.createObjectURL(blob);
const bytes = new Uint8Array(await blob.arrayBuffer()); // 如果你需要字节
fetch 把 data: 当作一种 scheme 来理解,所以这不需要任何网络请求。它直接返回 Blob,与 blob URL、与 arrayBuffer() 都天然搭配,而且它不构建中间那个二进制字符串 —— 所以 atob 加 Uint8Array 的双份内存问题不会出现。
两个注意点。严格的 Content-Security-Policy 可以拦掉它:这次 fetch 受 connect-src 管辖,所以一个不允许 data: 的策略会让请求失败,而错误信息谈的是连接,不是 Base64。另外 fetch 是异步的,这通常是好事,但确实会改变周围代码的形态。
对一个每次按键都要解码的转换器来说,这是五条路里最简单的一条;而对应一个必须先校验并修复输入的脚本来说,自己做归一化、再调用一个严格的解码器,仍然更清晰。
值得记住的行为对照
同样的输入,三个运行时,三种结果:
- 含
\n:Python 默认忽略;Node 忽略;浏览器atob抛错 - 含空格:Python 默认忽略;Node 忽略;
atob抛错 - 含
%3D:Python 加validate=True时报错;Node 跳过%;atob抛错 - 缺少填充:Python 报错;Node 能解;
atob抛错 - 长度 % 4 == 1:Python 报错;Node 能解(但字节是错的);
atob抛错 - 含
-或_:Python 报错(需要urlsafe变体);Node 的'base64url'能处理;atob抛错
规律是:Node 从什么都不告诉你,Python 你问它才说。两者对各自的用途都不算错 —— Node 为"宽容地吃进真实世界的数据"优化,Python 为"默认正确"优化。实用建议直接跟出来:用你手上最严格的运行时解码,归一化自己做。 显式修复输入、记录修了什么、事后验证输出字节,三个运行时就会得到一致的结果 —— 而你会发现一个被截断的字符串,而不是把半张图渲染出来。
最后那一步 —— 验证解出来的字节 —— 恰恰是人们会跳过的。两项检查能覆盖大部分:开头字节应当匹配某个已知格式的签名(见 图片文件签名),以及长度应当与生产者报告的一致。如果长度偏短,这个字符串是在传输中被截断的 —— 这项检查背后的算术见 换行与填充。如果你不想自己写,Base64 转图片 会执行完整流程 —— 归一化、修复、解码、识别、验证 —— 并报告它采取的每一步,包括它决定不采取的步骤,而那往往才是更有用的信息。