PDG 文件格式规范

Posted by Frank Lin on Mon, Feb 2, 2026

近日,我彻底搞清楚了超星 PDG 这种古老的文件格式中一些子类别(00H / 02H)的部分技术细节。鉴于互联网上没有找到关于此格式具体的描述,现将我了解的技术细节公布如下。有心者可据此 spec 文档,配合黑白游程表,即可重新实现一个 PDG 的解码器。

参考实现见: https://pdg-viewer.pages.dev

PDG 00H/02H 格式规范

本规范描述 00H/02H 变体的最小可用实现细节,读完即可复刻解密与解码流程。除非另有说明,所有多字节字段均为小端序(little-endian)。

1. 头部与关键字段

偏移量长度名称说明
0x002Magic固定为 ASCII "HH"
0x0F1Format0x00(00H)或 0x02(02H)
0x102Width图像宽度(像素)
0x122Height图像高度(像素)
0x184DataOffset加密数据起始偏移
0x1C4DataSize加密数据长度
0x400x30KeyMaterial解密密钥材料(48 字节)

若 Magic 不匹配或 Format 非 0x00/0x02,不属于本规范的范围,应拒绝处理。

除列出字段外,其余头部字节在本实现中被忽略,不参与校验或解密。

文件长度应不小于 DataOffset + DataSize。

2. 解密算法(XTEA-like)

解密对象为 DataOffset .. DataOffset + DataSize 的字节序列。算法为 16 轮、16 字节分组的 XTEA 变体。本算法为纯 ECB 风格,无反馈、无 IV、无跨块状态。

  • 00H:payload 不加密,直接进入后续 CCITT 解码。
  • 02H:payload 需按本节规则解密后进入后续 CCITT 解码。

2.1 Key 派生

KeyMaterial(0x30 字节)做 MD5,得到 16 字节摘要:

1md5 = MD5(KeyMaterial)
2a, b, c, d = unpack('<4I', md5)

2.2 常量

1DELTA    = 0x61C88647
2SUM_INIT = 0xE3779B90
3ROUNDS   = 16
4MASK     = 0xFFFFFFFF

2.3 16 字节块解密

将 16 字节块按小端拆为 v0, v1, v2, v3

 1v0, v1, v2, v3 = unpack('<4I', block)
 2s = SUM_INIT
 3for i in range(ROUNDS):
 4    v3 = (v3 - ( ((v0<<4)+c) ^ (s+v0) ^ ((v0>>5)+b) )) & MASK
 5    v2 = (v2 - ( ((v3<<4)+a) ^ (s+v3) ^ ((v3>>5)+d) )) & MASK
 6    v1 = (v1 - ( ((v2<<4)+c) ^ (s+v2) ^ ((v2>>5)+d) )) & MASK
 7    tmp = (s + v1) & MASK
 8    s = (s + DELTA) & MASK
 9    v0 = (v0 - ( ((v1<<4)+a) ^ tmp ^ ((v1>>5)+b) )) & MASK
10return pack('<4I', v0, v1, v2, v3)

2.4 尾部处理

解密阶段不对尾部做 padding;如果密文长度不是 16 的倍数,不足 16 字节的尾部保持原值,并直接作为后续 CCITT 位流的一部分。

3. 解密后 Payload:类 CCITT T.6 (Group 4) 2D 位流

解密结果是二值图像的 CCITT 2D 压缩数据流。输出为 1bpp 位图,行按 32-bit 对齐。该编码为 CCITT T.6 风格的 2D 行间预测编码,但在 bitstream 对齐、run-length 终止条件、行结束判定等方面存在实现特有偏差。

3.1 位流读取

  • 以 32-bit 为单位读取
  • MSB-first 位序(高位先出)
  • 若不足 4 字节,末尾以 0 填充

可用如下状态机读取单 bit:

1state.shift_reg = bswap32(next_word)
2state.bits_left = 32
3bit = (state.shift_reg >> 31) & 1
4state.shift_reg <<= 1
5state.bits_left -= 1

位流初始化时,解码器应立即装填首个 32-bit word 作为 shift register,其行为等价于从 payload 起始 MSB 连续读取。

3.2 Mode 解码(2D 模式码)

按位读取,输出模式值:

11                 -> 3
201                -> 4 or 7
3001               -> 2
40001              -> 1
500001             -> 5 or 8
6000001            -> 6 or 9
7000000            -> 0x0B or 0x0C

等价伪码:

1if b1==1: return 3
2if b2==1: return (b3?4:7)
3if b3==1: return 2
4if b4==1: return 1
5if b5==1: return (b6?5:8)
6if b6==1: return (b7?6:9)
7return (b7?0x0C:0x0B)

3.3 Run-length Huffman 码表

使用白/黑两套码表,每个码表包含 {code, len, run} 三元组,code 按 MSB-first 解释,len 为码字位数。

码表必须覆盖:

  • 终止码 0..63
  • 续接码 64 × NN = 1..40,即 64..2560

实际码表可与标准 CCITT 不同,必须按本实现所需码表填充。

由于本实现不区分 终止码 (terminating run)续接码 (makeup run),所有 run-length 码值均累加,直到某次返回值 ≤ 63 为止,方视为一个颜色段结束。

当前 PDG 02H 使用的白/黑 Huffman 码表在位级上是前缀无歧义 (prefix-free) 的;因此在现有码表与已知样本数据下,解码过程中不会出现多个可接受匹配。

解码时在实现时,允许码字在位级存在前缀关系;当出现多个可接受匹配时,选择可匹配的最长码字。解码器中对前缀歧义的处理逻辑属于防御性实现,在现有码表与样本数据下不会被触发。

3.4 行解码流程

每行输出由“变色点数组”表示,按白/黑交替的边界位置累加。

伪码(简化):

 1ref = 上一行边界数组
 2cur = 当前行边界数组
 3is_black = false
 4u = 0
 5
 6while True:
 7    mode = decode_mode()
 8    switch mode:
 9      case 1:   # pass
10        u = ref[ref_idx + 1]
11        ref_idx += 2
12      case 2:   # horizontal
13        # white then black (或反之)
14        run1 = sum(decode_run(color) while run>63)
15        u += run1
16        cur[cur_idx++] = u
17        run2 = sum(decode_run(!color) while run>63)
18        u += run2
19        cur[cur_idx++] = u
20        while ref[ref_idx] <= u: ref_idx += 2
21      case 3..6: # vertical +0..+3
22        u = ref[ref_idx] + (mode-3)
23        cur[cur_idx++] = u
24        ref_idx += 1
25        is_black = !is_black
26      case 7..9: # vertical -1..-3
27        u = ref[ref_idx] - (mode-6)
28        cur[cur_idx++] = u
29        ref_idx += 1
30        is_black = !is_black
31      default:
32        error
33
34    if u >= width:
35        cur[cur_idx..cur_idx+3] = u
36        return cur_idx

特别地,模式码 0x0B / 0x0C 在本实现中视为非法;若出现,应立即失败。

reference line 缓冲区必须显著大于可能的变化点数量,并在尾部填充多个单调递增的哨兵值,以保证 ref[ref_idx] <= u 的线性推进不会越界。

解码第一行前,reference line 必须初始化为仅包含 [0, width] 两个边界点,其后填充哨兵值。

reference line 与 current line 均为严格递增的变化点序列,索引奇偶性隐含当前颜色边界,不另行记录颜色数组。

本格式不包含 CCITT 标准定义的 EOL 或 RTC 标志,位流长度完全由外部 Height 控制。

3.5 行输出到位图

行宽 width,按 32-bit 对齐:

1row_words = (width >> 5) + ((width & 31) != 0)
2row_bytes = row_words * 4

对每对边界 (start, end)

  • [start, end) 的像素置为 1
  • 仅写入当前行 row_bytes

4. 输出格式

输出为 1bpp 原始位图,行按 32-bit 对齐。输出位图中,每字节以 MSB 表示最左像素,LSB 表示最右像素。

1total_size = row_bytes * height

若输出缓冲区不足,应报错。

若在第 N 行解码失败,解码过程提前终止;前 N−1 行输出有效,其余行未定义。

5. 失败条件

  • Magic 或 Format 不匹配
  • 解密数据为空
  • CCITT 位流解析遇到非法码字
  • 行解码过程中边界异常
  • 输出缓冲区不足

行解码过程中若出现非法 Huffman 码或非法 mode,则解码失败;run 溢出、u > width、reference line 越界在本实现中视为可容忍情况。

6. 参考实现要点

  • 位流读取必须 MSB-first
  • Huffman 表必须按位前缀树解码
  • 行边界数组需设置哨兵值(例如 0x7fffffff
  • 行宽对齐方式固定为 32-bit

7. 最小流程摘要

  1. 校验 Magic 与 Format
  2. 读取 Width/HeightDataOffset/DataSizeKeyMaterial
  3. MD5 派生 a,b,c,d
  4. 分块解密 payload
  5. 使用 CCITT 2D + Huffman 码表解码行
  6. 输出 1bpp 位图(32-bit 对齐)

8. 码表完整性说明

当前解码依赖指定的白/黑 Huffman 表。若输入数据包含未覆盖码字,将解码失败或输出错误。若要扩展兼容性,需补充码表并保证前缀无歧义。

参考资料

  • PDG科普篇,作者:马健
  • 超星文件加密方式说明v1.5,作者:Che Ming


comments powered by Disqus