photocraft-codecs 全解析:纯 Rust 平面光栅编解码器的对称性、保真度与安全设计

原创2026-10-08 18:44:061,611 阅读
文章标签:图像处理桌面应用

photocraft-codecs 全解析:纯 Rust 平面光栅编解码器的对称性、保真度与安全设计

photocraft-codecs 是 PhotoCraft(纯 Rust 重写的 Adobe Photoshop 开源实现)中负责平面光栅图像格式读写的独立 crate。它不依赖工作区中任何其他 crate,自带 Image 类型,可编译到 wasm32-unknown-unknown,且完全不做 I/O(解码输入 &<a href="https://link.gitcode.com/i/708ca5597cd00e922114b85737489758" target="_blank">u8]、编码输出 Vec<u8>)。本文基于 [crates/codecs/README.md 与源码实现,系统讲解其“对称性、深度与元数据保持、诚实保真”三大设计原则,逐一给出 14 种格式的能力矩阵、保真警告机制、解压炸弹防护 Limits 以及测试策略,并标注每一条结论对应的源码路径,方便你在实际接入或二次开发时直接对照。

一、设计哲学:对称性、深度保持与诚实保真

1. 对称性(Symmetric)——避免 GIMP 的历史之坑

项目的架构文档在 docs/architecture.md 中专门讨论了“Avoiding GIMP's hole”:GIMP 因为把“像素深度、颜色模型、图层即像素缓冲区”等基本属性硬编码进内核,导致 8 位转 16/32 位浮点处理整整拖到 2018 年的 2.10 版本。photocraft-codecs 把这一教训落实为一条可测试的硬规则:

每一种我们能写入的格式,我们也一定能读取。

唯一的例外被集中登记在 ASYMMETRIC_EXCEPTIONS 常量中,且每一条都附带原因(见 crates/codecs/src/format.rs):

  • AVIF:编码用纯 Rust 的 ravif,但解码需要 C 语言实现的 dav1d,所以读取保持不支持;写入被放在非默认 avif feature 之后。
  • HEIF/HEIC:解码依赖可选的 photocraft-heif crate(内部是纯 Rust、无 unsafe 的 heic-rs),而写入需要一个 HEVC 编码器——成熟实现(x265、libheif)全是 C,因此写入保持不支持。

测试套件会断言“除 ASYMMETRIC_EXCEPTIONS 之外,每一个启用的格式都可读可写”,从机制上锁死对称性回归。

2. 深度保持(Depth-preserving)与元数据保持(Metadata-preserving)

图像可以持有 U8、U16、F16、F32 四种采样类型(见 crates/codecs/src/image.rs),每种格式保留它所能容纳的深度。ICC、EXIF、XMP、DPI 和文本等元数据,只要目标格式支持就全部保留(Metadata 结构见 crates/codecs/src/image.rs)。

3. 诚实保真(Honest)——警告与实际编码使用同一份方案

这是整个 crate 最值得称道的工程点:caps(format) 如实声明每种格式能装下什么;fidelity_warnings(&image, format) 在写入之前就列出这次导出会丢失什么。关键在于 crates/codecs/src/fidelity.rs 顶部注释明确写道:

encode 和 fidelity_warnings 共享同一个 plan,所以警告恰好出现在编码器真正会转换或丢弃数据的时候。

也就是说,警告不是“大致估计”,而是与编码路径共用同一个转换方案,警告出现 ⇔ 文件真的会变。解码入口方向则用 Image::warnings 列出解码结果里看不到的东西:被跳过的帧/页,或提前结束的数据(DecodeWarning 定义见 crates/codecs/src/image.rs)。

一个最小可运行示例(README 原样保留):

use photocraft_codecs::*;

let img = decode(&bytes)?;                               // detect + decode (default Limits)
for w in fidelity_warnings(&img, Format::Jpeg) {         // e.g. "16-bit will be reduced to 8-bit"
    println!("{w}");
}
let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;

二、能力矩阵(默认构建)

下表完整继承自 README,覆盖默认 feature 构建下的全部 14 种格式。read/write 两列反映当前构建的 feature 开关(例如 avif、heif 默认关闭)。

Format Read Write Depths (native) Layouts (native) Alpha ICC EXIF XMP DPI Text Lossy write Backend
PNG yes yes U8, U16 Gray, GrayA, RGB, RGBA yes yes (iCCP) yes (eXIf) yes (iTXt XML:com.adobe.xmp) yes (pHYs) yes (tEXt/zTXt/iTXt) no png
JPEG yes yes U8 Gray, RGB, CMYK no yes (multi-segment APP2) yes (APP1) yes (APP1) yes (JFIF) no yes zune-jpeg / jpeg-encoder
TIFF yes yes U8, U16, F32 all six (CMYK, CMYK+A included) yes yes (tag 34675) no yes (tag 700) yes yes (Description, Make, Model, Software, DateTime, Artist, Copyright) no tiff
WebP yes yes (lossless only) U8 RGB, RGBA yes yes yes yes no no no image-webp
GIF yes yes U8 RGBA 1-bit no no no no no yes (256-colour palette) image
BMP yes yes U8 RGB, RGBA yes no no no no no no image
TGA yes yes U8 Gray, GrayA, RGB, RGBA yes no no no no no no image
ICO yes yes U8 RGBA (at most 256×256) yes no no no no no no image
Netpbm (PBM/PGM/PPM/PAM/PFM) yes yes U8, U16, F32 (float: PFM gray/RGB only) all six (PAM CMYK/CMYK_ALPHA) yes (PAM) no no no no no no built in
QOI yes yes U8 RGB, RGBA yes no no no no no no image
OpenEXR yes yes F16, F32 Y, YA, RGB, RGBA yes no no no no no no exr
Radiance HDR yes yes F32 RGB no no no no no no yes (RGBE) image
AVIF no only with feature avif U8 RGB, RGBA yes no no no no no yes image + ravif
HEIF/HEIC with feature heif no U8, U16 (10/12-bit decodes to U16) RGB, RGBA yes (auxiliary alpha) yes (colr prof) yes yes no no n/a heic-rs

表中的能力声明由 caps(format) 函数集中产生(crates/codecs/src/format.rs),每个 FormatCaps 结构体还额外包含 animation(容器是否可容纳多帧/多页)与 lossy(编码器是否有损)两个字段(定义见 crates/codecs/src/format.rs)。

“Native”的含义:数据按原样存储并读回、不做转换;任何其他情况都由编码方案(encode plan)转换,并在丢失信息时由 fidelity_warnings 报告。READme 明确了四类转换规则:

  • 浮点图像写入整型格式时,若该格式支持 U16 则降到 U16;数值超出 0..1 时触发 RangeClipped 警告。
  • 整型数据写入纯浮点格式(EXR、HDR)时无损转成 F32。
  • CMYK 用非色彩管理的朴素公式转成 RGB(实现见 crates/codecs/src/image.rs 的 cmyk_to_rgb / rgb_to_cmyk)。此时触发 CmykConverted 且 ICC 会被丢弃——因为 CMYK profile 描述的不是 RGB 数据。
  • 灰度写入只支持 RGB 的格式时无损扩展成 RGB,不产生警告。

Image::convert 应用同样的 ICC 规则:只有颜色模型不变时才保留 profile(crates/codecs/src/image.rs,测试 icc_dropped_on_model_change 验证了这一点)。

三、格式识别与能力声明的源码实现

1. 魔数检测(detect)

detect(bytes) 通过文件头魔数识别格式(crates/codecs/src/format.rs):

  • PNG:\x89PNG\r\n\x1a\n;JPEG:FF D8 FF;TIFF(含 BigTIFF 的 II+\0/MM\0+ 两种新魔数)。
  • WebP:RIFF + 偏移 8 处的 WEBP;GIF:GIF87a/GIF89a;QOI:qoif;OpenEXR:v/1\x01;HDR:#?RADIANCE/#?RGBE。
  • HEIF/AVIF:解析 ftyp box 的品牌列表(avif/avis 判定为 AVIF;heic/heix/mif1/miaf 等判定为 HEIF,见 is_avif_ftyp/is_heif_ftyp)。即使序列文件(hevc/msf1)也会被识别,以便打开时给出“不支持”而不是“无法识别的格式”。
  • BMP:BM;ICO:00 00 01 00 头;Netpbm:P1–P7/PF/Pf。
  • TGA 没有魔数,因此放到最后,通过 18 字节头部合理性判断(looks_like_tga):图像类型、调色板字段、位深、宽高非零、描述字节高位为 0,以及 TRUEVISION-XFILE.\0 尾部签名。

from_extension 则按扩展名(大小写不敏感、容忍前导点和完整路径)查表,Format::extensions() 的第一个扩展名即写入时的首选扩展名。

2. 能力表如何随 feature 变化

caps 中 read/write 两列直接由编译期 feature 决定,例如:

Format::Avif => FormatCaps { read: false, write: cfg!(feature = "avif"), lossy: true, ..base },
Format::Heif => FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base },

(crates/codecs/src/format.rs)。这意味着同一份代码在默认构建下,AVIF 既不可读也不可写,对称性保证依然成立;打开 HEIF 时解码器桩会明确报出“本构建未包含 HEIC/HEIF 支持”。对应 feature 定义见 crates/codecs/Cargo.toml。

四、已知不对称与逐格式限制

1. 相机原始文件(Camera Raw)

DNG、CR2、NEF、ARW 等 TIFF 结构的原始文件会被识别并拒绝,返回 CodecError::Unsupported:它们是传感器数据而非平面图像。开发与解码由 photocraft-raw 负责,photocraft-io 负责路由。

2. HEIF/HEIC(只读,feature heif)

解码位于可选 crate photocraft-heif 中,由非默认 heif feature 开启(官方构建启用,发行方自选)。关闭时 HEIF 仍能被检测,解码返回 CodecError::Unsupported(提示信息为 “HEIC/HEIF support isn't included in this build”)。开启后,iPhone 和 Mac 照片通过纯 Rust、无 unsafe 的 heic-rs 解码:支持单张与网格平铺照片、8 到 12 位、辅助 alpha、ICC、EXIF、XMP。容器的 irot/imir/clap 会被应用,同时 EXIF Orientation 被改写成 1——因为 HEIF 的 EXIF 标签只是重复容器信息,两者都应用会把照片转两次。灰度解码为 RGB。图像序列、叠加层、身份派生、多层 HEVC 以及部分 4:2:2/4:4:4 流返回 Unsupported 或 Malformed,永远不会输出错误像素。写入需要 HEVC 编码器而成熟实现均为 C,因此 HEIF 被列入 ASYMMETRIC_EXCEPTIONS。

3. AVIF

编码走纯 Rust 的 ravif;解码需要 C 的 dav1d。因此 AVIF 保持读不支持,写入由非默认 avif feature 门控。默认构建下既不读也不写,对称性保证成立,同时列入 ASYMMETRIC_EXCEPTIONS。

4. 有损 WebP

没有纯 Rust 的有损 WebP 编码器,因此总是写无损 WebP;当 webp_lossless: false 时返回 CodecError::Unsupported。读取则同时支持有损与无损文件。

5. 动画与多页文件

APNG、动画 GIF/WebP、多页 TIFF 都只解码第一帧/第一页,写入单帧。FormatCaps::animation 标记能容纳多帧的容器;解码后的图像在 Image::warnings 中携带 DecodeWarning::MoreFrames/MorePages(文件声明总数时给出 total)。帧数与 TIFF 目录数不经过解码即可统计;降分辨率 TIFF 目录与透明度掩模不算页。任一 TIFF 页仍可通过 decode_tiff_page 打开(photocraft_io::import_tiff_page 同理)。tiff_info 会列出主 IFD 链及其 SubIFD(tag 330)中每一页的尺寸、种类(页/降采样副本/掩模)与存储方式,遍历使用 seen-set、深度限制和 10000 目录上限(crates/codecs/src/lib.rs)。

6. TIFF 中的 EXIF

以子 IFD 而非 blob 形式存储,当前尚未保留,因此 caps.exif = false for TIFF,并产生警告。

7. 方向(Orientation)处理

JPEG 的 EXIF tag 274、PNG 的 eXIf、WebP,以及 TIFF/BigTIFF 解码页自身的方向标签,在解码时像 Photoshop 一样自动应用:像素以正立状态返回,EXIF/XMP 方向元数据被改写为 1(DecodeOptions::keep_orientation 可退出)。编码器永远写 Orientation = 1(upright_exif、upright_xmp 只改动标签值本身并保留其原类型),保证正立像素永远不会被二次旋转。

解析是严格的:IFD0 落在 8 字节头内、或在 next-IFD 指针前被截断、Orientation 不是恰好一个 SHORT 或 LONG、或值越界,一律按 1 读取;出现重复条目时第一个生效(与 libexif 一致)。方向 5–8 会交换宽高,此时 Limits 会针对正立后的尺寸再次校验(5–8 交换宽高,见 crates/codecs/src/lib.rs),旋转缓冲通过 try_reserve 可失败分配而非崩溃(crates/codecs/src/orientation.rs)。Image::oriented 对任意布局与深度按并行 band 旋转,README 给出实测:24 MP RGB8 在 release 构建约 10 ms(并行通过 rayon,wasm 单线程,见 crates/codecs/Cargo.toml)。upright_exif 会把所有格式良好的重复 Orientation 条目都改写为 1,防止“后写者胜”的读取器再次旋转(crates/codecs/src/orientation.rs)。

8. JPEG 细节

  • 仅 8 位;两个解码后端都不支持 12 位。
  • 图像数据中途截断的文件宽容解码(baseline JPEG 缺失行以灰色补出),但绝不静默:图像携带 DecodeWarning::Truncated。在扫描数据开始前就结束的文件则是错误。
  • CMYK 永远以 4:4:4 写入(子采样 CMYK 不通用),采用 Adobe 反相 CMYK 并带 APP14 标记。
  • 不写文本(没有 COM 段支持)。
  • EXIF 与 XMP 必须各自装进一个 64 KiB 的 APP1 段,否则编码器报错;不写扩展 XMP。fidelity_warnings 对应地产生 MetadataTooLarge 警告(crates/codecs/src/fidelity.rs)。

9. TIFF 解码能力

  • 经典 TIFF 与 BigTIFF(版本 43、8 字节偏移)、两种字节序、strips 或 tiles、planar configuration 1 或 2。
  • 支持 1/2/4 位灰度、8/16/32/64 位整数(32/64 位降为 U16)、F16、F32、F64(转为 F32)、WhiteIsZero(#691:色调不再反转)、调色板图像(按 RGB 打开)、关联 alpha(转为 straight)与额外样本(除非第一个是 alpha 否则丢弃)。
  • 每个目录都可达:tiff_info 列出主 IFD 链及其 SubIFD(tag 330),含每页尺寸、种类(页/降采样副本/掩模)与存储方式,遍历受 seen-set、深度限制与 10000 目录上限保护;decode_tiff_page 可打开任意一个。decode 像 Photoshop 一样打开第一个全分辨率页(跳过缩略图)。
  • 带状解码:图像按最终尺寸一次性分配,每条 strip 或每行 tiles 直接解入其中,原生目标上并行执行,每个 worker 至多一个 strip/tile 大小的临时缓冲。README 实测(release):30 MP RGB8 下 Deflate 345 → 32 ms、LZW 428 → 43 ms。图像数据被文件结尾截断时以 DecodeWarning::Truncated 解码;文件尺寸远小于声明尺寸(超过任何压缩可能扩大的程度)时,在分配之前就拒绝。
  • YCbCr、CIE Lab、有符号整数以及 tiff crate 缺失的压缩(CCITT、JPEG-in-TIFF)返回 Unsupported。

10. TIFF 编码

默认写经典 TIFF;当 EncodeOptions::tiff_bigtiff 被设置、或文件可能超过 4 GiB 时自动写 BigTIFF(8 字节偏移)。

11. EXR

  • 从数据窗口读取第一个有效图层(全分辨率)。
  • 通道名按后缀匹配,所以 layer.R 视为 R。
  • 不支持子采样通道与 deep 数据(deep 文件经 decode_deep_exr 展开为 DeepImage,普通解码则合成扁平图像并带 DeepFlattened 警告,见 crates/codecs/src/lib.rs)。
  • 写入只提供无损压缩:None、RLE、ZIP1、ZIP16、PIZ。

12. Netpbm 写类型选择

按图像内容自动挑选子类型:浮点 → PFM,灰度 → P5,RGB → P6,带 alpha 或 CMYK → P7(PAM)。所以写 .pbm 文件时,实际产出的是与数据最匹配的那一种。

13. 颜色转换

CMYK↔RGB、RGB→灰度的转换是朴素公式(非色彩管理,Rec. 709 亮度权重见 crates/codecs/src/image.rs);色彩管理转换归属 photocraft-color crate。

五、解码安全:Limits 与解压炸弹防护

DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc }, .. } 守护整个解码过程(结构定义见 crates/codecs/src/options.rs):

  • 头尺寸在像素缓冲分配之前检查;
  • 预算同时传递给底层解码器作为分配上限;
  • 违反即返回 CodecError::LimitExceeded。

默认值:单边 262144 像素(2^18)、总计 2^30 像素、8 GiB 分配上限(32 位目标如 wasm 降为 2 GiB)。默认值选择考虑到了 Photoshop 用户会打开的大文档——一个 20000² 的 RGBA 16 位图像约 3.2 GB(见 Limits::default 注释,crates/codecs/src/options.rs)。Limits::none() 提供“近乎无限”的配置(仍受 usize 溢出检查约束)。校验函数 check_bytes 还额外做 usize 溢出检查,避免字节数在分配前就溢出内存寻址能力(crates/codecs/src/options.rs)。

值得一提的还有 finish_decode 中的最终防线:对无法预先检查头部的解码器,解码完成后仍会执行一次 Limits::check;方向 5–8 交换宽高后,还会对正立尺寸再查一次(crates/codecs/src/lib.rs)。

六、编码选项与默认值速查

EncodeOptions(crates/codecs/src/options.rs)集中了所有编码参数:

字段 默认值 说明
jpeg_quality 90 JPEG 质量 1..=100(启用时也是 AVIF 质量)
jpeg_chroma_subsampling true JPEG 4:2:0 色度子采样(false = 4:4:4)
png_compression Default PNG zlib 档位:None / Fast / Default / Best
png_interlaced false 写 Adam7 隔行 PNG
webp_lossless true WebP 无损(VP8L)或有损
webp_quality 80 有损 WebP 质量 0..=100(照片典型 75–85)
tiff_compression Deflate TIFF strip 压缩:None / Lzw / Deflate / PackBits
tiff_bigtiff false 始终写 BigTIFF;否则仅在可能超过 4 GiB 时
exr_compression Zip16 EXR 无损压缩:None / Rle / Zip1 / Zip16 / Piz
embed_icc true 格式支持时嵌入 ICC profile
embed_metadata true 格式支持时嵌入 EXIF/XMP/DPI/text

FidelityWarning 的完整清单见 crates/codecs/src/fidelity.rs,其中 WriteUnsupported 与 DimensionsExceeded 是致命警告(is_fatal() 为 true),会让编码直接失败;其余都是告知性的。所有警告都实现了 Display,可直接面向用户展示,例如“16-bit will be reduced to 8-bit”“HDR values outside 0..1 will be clipped”“CMYK will be converted to RGB (not colour-managed)”。

七、测试策略:用测试锁死设计承诺

cargo test -p photocraft-codecs 覆盖(README 原列):

  • 完整往返矩阵:每个可写格式 × 4 种采样类型 × 6 种布局;无损格式要求精确一致,有损格式要求高于 PSNR 阈值。
  • 逐格式元数据保留:对照 caps 断言。
  • 魔数检测,包括反例(误判防护)。
  • 保真警告,并交叉校验“警告与实际往返结果一致”(这正是“诚实”承诺的自动化验证)。
  • 对称能力保证。
  • 畸形输入:截断、位翻转与 proptest 随机字节。
  • Adam7 PNG 对照渐进式 PNG 与 image crate 作为 oracle。
  • 限制强制(limit enforcement)。

如果仓库根目录存在 corpus/pngsuite/*.png,其中每个文件都会与 image crate 的解码器逐像素比对;不存在时该测试自动跳过。feature 对应的真实语料测试还可通过 cargo xtask test-corpus 开启(crates/codecs/Cargo.toml)。

八、工程约束一览

源码层面对安全性的要求相当严格,crates/codecs/src/lib.rs 顶部即声明:

#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]

即全 crate 禁止 unsafe、禁止 unwrap/expect/panic 等可能在畸形输入上触发崩溃的写法——解码器面对任何输入都返回 CodecError::Malformed 而不是 panic(错误类型定义见 crates/codecs/src/error.rs)。配合 #![deny(...)] 的 clippy 规则,这从编译期就杜绝了“错误像素”与“意外 panic”两大类缺陷。

九、上层如何消费这个 crate

photocraft-codecs 是独立的“平面图像编解码”基础设施,上层通过它获得统一的分辨率、深度、色彩空间与元数据语义,再交给后续处理管线。值得注意的衔接点:

  • 相机原始文件被此处拒绝后,由 photocraft-raw 解码显影,photocraft-io 负责路由;
  • TIFF 的 Photoshop 资源块(tag 34377)与图层数据(tag 37724 ImageSourceData)在此处保持不透明传递,由 photocraft-io 经 photocraft-psd 读写(见 Metadata 中 photoshop_resources/photoshop_layers 字段,crates/codecs/src/image.rs);
  • 色彩管理转换(CMYK↔RGB、灰度等)在 photocraft-color 中做,本 crate 只提供朴素公式兜底。

这套“对称 + 深度保持 + 诚实警告 + 严格限制 + 无 unsafe”的组合,正是 PhotoCraft 把 Photoshop 级图像处理带到纯 Rust 世界时,在文件 I/O 边界上给出的工程答案。

登录后查看全文
photocraft