PAYATHON 2026

使用 MiniZ 生成 PNG 时为什么只能显示黑色像素?

数据小许

结论:先修正 IDAT 的组装逻辑

mz_compress() 和 mz_compress2() 使用 DEFLATE,生成包含 zlib 头部和 Adler-32 尾部的完整 zlib 数据流,可以直接作为 IDAT 的数据。压缩结果无需与 Wikipedia 示例逐字节相同。

代码中可以直接确认三处错误:

  1. IDAT 的长度使用了主机字节序,而 PNG 要求大端序。
  2. CRC32 在写入 chunk type 和压缩数据之前计算,实际处理的是尚未填充的零字节。
  3. 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 是否正确。

备注:内容仅供参考。