使用 MiniZ 生成 PNG 时为什么只能显示黑色像素?
结论:先修正 IDAT 的组装逻辑
mz_compress() 和 mz_compress2() 使用 DEFLATE,生成包含 zlib 头部和 Adler-32 尾部的完整 zlib 数据流,可以直接作为 IDAT 的数据。压缩结果无需与 Wikipedia 示例逐字节相同。
代码中可以直接确认三处错误:
IDAT的长度使用了主机字节序,而 PNG 要求大端序。- CRC32 在写入 chunk type 和压缩数据之前计算,实际处理的是尚未填充的零字节。
- CRC32 的写入位置固定为
idat + 20,压缩长度变化后会覆盖压缩数据。
这些错误足以使 PNG 文件无效。图片查看器显示黑色像素是目前观察到的现象,不能据此判断压缩器把红色像素变成了黑色。
扫描线开头的 0x00 表示什么?
对于代码中的配置:
width = 1
height = 1
bit depth = 8
color type = 2
filter method = 0
interlace method = 0
压缩前的数据应为:
00 FF 00 00
│ └──────┘
│ RGB:红色
└─ 当前扫描线的 filter type:0
这里有两个不同的字段:IHDR 中的 filter method = 0 指定 PNG 的过滤方法;每条扫描线开头的字节选择该行使用的 filter type。type 0 表示不进行过滤。
在非交错、8 位 RGB 图像中,每行都需要自己的 filter type 字节。如果所有行都使用 type 0,压缩前的数据长度为:
height × (1 + width × 3)
不能只在整张图片的开头添加一次 0x00。
为什么 MiniZ 的输出与示例不同?
示例中的:
63 F8 CF C0 00 00
只有 DEFLATE 部分。mz_compress() 返回的是完整的 zlib 数据流:
zlib header | DEFLATE data | Adler-32
同一份输入可以有多种合法的 DEFLATE 编码。压缩级别、压缩策略和实现选择都可能影响输出;固定实现和参数也可以产生可重复的结果,因此不能简单地把 DEFLATE 理解为随机编码。
题目输出中的前缀:
78 9C 01 04 00 FB FF 00 FF 00 00 ...
包含 zlib 头部和一个存放原始数据的 DEFLATE stored block。对于只有 4 字节的输入,使用这种块并不异常。
使用 mz_compress() 后,直接复制它返回的全部有效字节即可,不需要再添加另一组 zlib 头部或 Adler-32。
另外,IHDR 的 compression method 值仍然是 0。固定示例中的 0x08 属于 zlib 头部,不能用来替换 IHDR 的该字段。
IDAT 的三个错误分别在哪里?
一个 PNG chunk 的布局为:
偏移 内容
0 length,4 字节,大端序
4 chunk type,4 字节
8 data,length 字节
8 + length CRC32,4 字节,大端序
length 只计算 data 的长度。CRC32 覆盖 chunk type + data,不包含 length 字段,也不包含 CRC32 自身。
长度没有按大端序写入
原代码计算了 swapped_length,随后却复制了 length:
swapped_length = _byteswap_ulong(length);
memcpy(idat, &length, 4);
在题目所示的小端环境下,长度 15 被写成了:
0F 00 00 00
正确内容应是:
00 00 00 0F
应改为:
memcpy(idat, &swapped_length, 4);
FIXED 分支也有同样的问题:长度 12 应写成 00 00 00 0C。即使某个查看器能够显示该文件,也不能据此认定文件符合 PNG 格式。
CRC32 在填充数据之前计算
原代码的执行顺序是:
uint8_t *idat = calloc(idat_length, sizeof(uint8_t));
crc = _byteswap_ulong(calc_crc32(idat + 4, length + 4));
memcpy(idat + 4, &type, 4);
memcpy(idat + 8, buffer_out, buffer_out_length);
calloc() 刚返回时,缓冲区内容全是零。此时计算的 CRC32 与之后写入的 IDAT 和压缩数据无关。
应先写入 type 和 data,再计算 CRC32。
CRC32 的偏移写死了
原代码使用:
memcpy(idat + 20, &crc, 4);
偏移 20 只适用于 data 长度为 12 的情况。题目中动态分支的数据长度为 15,CRC32 应从偏移 23 开始:
memcpy(idat + 8 + length, &crc, 4);
继续写到偏移 20 会覆盖 zlib 数据尾部。这也解释了为什么内存中的压缩结果可以成功解压,写进 PNG 后却出错:文件里的字节被后续的 CRC32 写入操作改坏了。
如何修改动态分支?
下面的代码可替换原来的 #else 分支,沿用外层已有的 length、swapped_length、type 和 crc 变量。代码针对题目中的 1×1 示例使用固定输出缓冲区,并按 MiniZ 的常见接口声明使用 mz_ulong;具体类型应与所用的 miniz.h 保持一致。
#else
uint8_t buffer_in[4] = {0x00, 0xFF, 0x00, 0x00};
uint8_t buffer_out[256];
mz_ulong buffer_out_length = (mz_ulong)sizeof(buffer_out);
int status = mz_compress(
buffer_out,
&buffer_out_length,
buffer_in,
(mz_ulong)sizeof(buffer_in)
);
if (status != MZ_OK)
{
fprintf(stderr, "mz_compress() failed: %d\n", status);
return 1;
}
length = (uint32_t)buffer_out_length;
swapped_length = _byteswap_ulong(length);
uint32_t idat_length = 4u + 4u + length + 4u;
uint8_t *idat = calloc(idat_length, sizeof(*idat));
if (idat == NULL)
{
fprintf(stderr, "Unable to allocate IDAT buffer\n");
return 1;
}
/* Write the length in big-endian order. */
memcpy(idat, &swapped_length, 4);
/* Write the chunk type and the complete zlib stream. */
memcpy(idat + 4, type, 4);
memcpy(idat + 8, buffer_out, (size_t)buffer_out_length);
/* Calculate CRC32 after the type and data have been written. */
crc = _byteswap_ulong(calc_crc32(idat + 4, length + 4u));
memcpy(idat + 8u + length, &crc, 4);
printf("IDAT OK\n");
#endif
动态分支分配的 idat 应在文件写入结束后调用 free(idat),打开文件失败等提前返回的路径也应释放它。固定分支中的 idat 是栈数组,不应对它调用 free()。
同时显式包含代码使用的标准头文件,避免依赖 miniz.h 间接包含:
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
MZ_FIXED | MZ_DEFAULT_COMPRESSION 应该保留吗?
这里直接使用 mz_compress() 即可。
mz_compress2() 的最后一个参数是压缩级别,不能把它当作接收任意策略标志的位掩码。MZ_FIXED 属于压缩策略概念,不应与 MZ_DEFAULT_COMPRESSION 按位或后传入该参数。
当 MZ_DEFAULT_COMPRESSION 定义为 -1 时:
MZ_FIXED | MZ_DEFAULT_COMPRESSION
结果仍然是 -1,没有实现预期的策略设置。如果需要指定策略,应查看所用版本 miniz.h 中带有 strategy 参数的初始化接口。生成合法 PNG 并不要求使用固定 Huffman 编码。
扩展到任意像素数组时还要检查什么?
先修复文件布局,再处理尺寸扩展:
- 压缩输入必须包含每条扫描线的 filter type 字节,并与
IHDR中的宽、高和颜色格式对应。 - 可以用
mz_compressBound()计算输出缓冲区容量,同时检查尺寸乘法和整数转换是否溢出,以及内存分配是否失败。 - 原
calc_crc32()把数据遍历索引n声明为uint16_t。当length超过 65535 时,索引会回绕;应另用uint32_t或size_t遍历数据。 mz_uncompress2()的源长度参数用于返回实际消耗的输入长度。做往返验证时,应使用独立变量,保留最初的压缩输出长度,并检查解压状态、输出长度和字节内容。- 检查
fwrite()和fclose()的返回值,确认文件完整写入;main()中的错误分支应返回明确的整数状态。
往返解压只能证明当时传给解压器的缓冲区可解码。最终还需要检查文件中的大端长度、完整 zlib 数据,以及每个 chunk 的 CRC32 是否正确。
备注:内容仅供参考。
