PDFlibPas 用手写的 Object Pascal 解析器解码 TIFF,而非绑定 libtiff,3.534.1 版收紧的正是该解析器拒绝输入的位置。现在按名称拒绝 BigTIFF 魔数 43,在标签解析阶段拒绝 TileOffsets 和 TileByteCounts,每个缓冲区都通过 Int64 算术确定大小,并受 256 MiB 解码上限约束
这次堵上的缺陷从不出现在实验室里。它出现在一台安静运行了三年的扫描网关上,直到某个客户把地理空间归档或全切片医学影像送了进来。文件带着合法的 TIFF 头。能解析。出来的却是一页条纹噪声,或者一次把服务拖垮的数 GB 级内存分配,而整条链路上没有任何一环声明过输入无效。这才是值得针对性设防的失败形态:不是崩溃,而是信心满满地交付一个错误答案
为什么 II 或 MM 并不能证明你手里是经典 TIFF?
因为字节序标记是两种方言共用的。经典 TIFF 和 BigTIFF 都以 II 或 MM 开头,真正区分它们的是紧随其后的 16 位魔数:TIFF 6.0 规范定义的经典 TIFF 是 42,采用 64 位偏移的 BigTIFF 是 43。写成 FValidTIFF := PopWord = 42 的加载器对经典 TIFF 的判断并没有错,但它把两种截然不同的拒绝折叠成一个沉默的布尔值,于是一个 BigTIFF 就和某个被改名的截断 JPEG 无法区分了。PDFlibPas 现在把这些情形分开,并分别记录到 TPDFTIFF.LastError:不足四字节的文件头、非法字节序标记、魔数 43、其他任何魔数值,各自产生不同的文本。本库仍然不解码 BigTIFF,而坦坦荡荡说出来正是重点。调用方得到的是“这不是 TIFF”与“这是 TIFF,但其 64 位偏移布局内置解码器并未实现”之间的差别,也就是一条回复就能了结的支持工单和演变成一周猜测的工单之间的差别
var
Tiff: TPDFTIFF;
Page: Integer;
begin
Tiff := TPDFTIFF.Create;
try
Tiff.LoadFromFile('inbox\scan-0417.tif');
if not Tiff.ValidTIFF then
raise Exception.Create('TIFF rejected: ' + Tiff.LastError);
if Tiff.PageCount < 1 then
raise Exception.Create('TIFF carries no decodable page');
for Page := 1 to Tiff.PageCount do
Writeln(Format('page %d: %dx%d, %d spp',
[Page,
Tiff.PageInfo[Page].Width,
Tiff.PageInfo[Page].Height,
Tiff.PageInfo[Page].SamplesPerPixel]));
finally
Tiff.Free;
end;
end;
分块是另一种几何结构,不是又一组偏移数组
PDFlibPas 在解析标签时就拒绝分块 TIFF,早于任何像素数据被触碰。诱发缺陷的捷径一眼就能看到:标签 324(TileOffsets)和标签 325(TileByteCounts)是文件偏移与字节数的数组,结构上和条带数组完全相同,所以把现有条带字段指过去只要两行代码,还能干净地编译通过。但它就是错的。分块构成一个带边缘填充块的二维网格,每个分块内部有自己的行步长,而且完全没有 RowsPerStrip 语义,TIFF 6.0 的分块图像一节写得很清楚。因此把分块载荷喂给条带解码器并不会轰然报错。SimpleExtract 和 CompDecode 会按错误的步长走数据,吐出一幅尺寸正确、像素错误的图像。旧代码让问题更严重:TTIFFPage 里一直保留着 StripsAreTiles、ColumnsPerTile 和 RowsPerTile,即一个背后没有分块拼装器的解码器却记录了分块几何。在 3.534.1 中,标签 324 和 325 的处理程序会抛出分块错误并立即放弃该 IFD,于是拒绝带上了“tiled”这个词,而不是几周后才以渲染投诉的形式浮出水面
单做一道尺寸钳制并不是内存预算
把宽和高各自钳制到 65,535 是必要的,但远远不够,因为驱动分配的是一个乘积。RowsPerStrip * Width * SamplesPerPixel 在任一边接近自身上限之前就可能让 32 位算术溢出,即便不溢出,它也可能叫出一个任何服务都不该尝试的分配。PDFlibPas 用 Int64 计算行字节数,并同时执行三道上限:每维 65,535、32 个颜色分量、256 MiB 解码字节数
const
PDFLIB_TIFF_MAX_IMAGE_DIM = 65535;
PDFLIB_TIFF_MAX_COLOR_COMPONENTS = 32;
PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES = 256 * 1024 * 1024;
// 位于 TPDFTIFF.ValidatePageForDecode 内部
BitsPerPixel := Int64(P.BitsPerSample) * P.SamplesPerPixel;
RowBytes := (Int64(P.Width) * BitsPerPixel + 7) div 8;
if (RowBytes < 1) or
(RowBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
(Int64(P.Height) > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES div RowBytes) then
Exit(False);
DecodedBytes := RowBytes * P.RowsPerStrip;
if (DecodedBytes < 1) or
(DecodedBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
(DecodedBytes > MaxInt) then
Exit(False);
其中有三个细节比常量本身更重要。高度检测写成除法而不是乘法,于是超大乘积根本不会被构造出来。RowsPerStrip 小于 1 或大于图像高度时先归一化为图像高度,这正是 TIFF 6.0 已隐含的单条带读法,也挡住了用恶意标签吹大条带缓冲区的路子。而且这个例程是共享的:ValidatePageForDecode 在标签解析结束时运行一次,又在 SimpleExtract 和 CompDecode 的入口各运行一次,于是直达解码器的代码也无法绕过预算。这与 PDFlibPas 解析不可信 PDF 对象图时遵循的是同一条规则,因为只在三扇门中的一扇上执行的上限算不上上限
读取 PageInfo 之前调用方必须检查什么?
先查 ValidTIFF,再查 PageCount,然后才能索引 PageInfo。被拒绝的文件可能让 PageCount 停留在零,而 GetPageInfo 会用未初始化的 TTIFFPage 记录回应越界索引,于是一条在报告失败途中顺手读取分辨率或采样数的错误路径,最终读到的是噪声。3.534.1 版修好了库内的两处调用方:图像导入路径只在有效分支里读取 XRes 和 YRes,TPDFlib.GetImagePageCount 则要求 ValidTIFF 为真,而不是单凭非零页数就采信。下游,AddImageFromFile 的 Options 参数是多页 TIFF 的从 1 起始页码,所以 GetImagePageCount 必须在循环开始之前就可信,而不是之后才可信。零页现在是一个真实答案,意思是“这里没有可解码的内容”,而不是提前返回造成的意外,这一点在你归并交错双面扫描批次时最要命,因为只要有一页被悄悄错解,就会落进错误的位置
var
Pdf: TPDFlib;
Pages, I, ImageID: Integer;
begin
Pdf := TPDFlib.Create;
try
Pages := Pdf.GetImagePageCount('inbox\scan-0417.tif');
if Pages < 1 then
Exit; // 文件头损坏、BigTIFF、分块布局或超出预算
Pdf.NewDocument;
for I := 1 to Pages do
begin
Pdf.NewPage;
ImageID := Pdf.AddImageFromFile('inbox\scan-0417.tif', I);
if ImageID > 0 then
begin
Pdf.SelectImage(ImageID);
Pdf.DrawImage(0, 0, 595, 842);
end;
end;
Pdf.SaveToFile('scan-0417.pdf');
finally
Pdf.Free;
end;
end;
自造解码器还是链接 libtiff?
PDFlibPas 保留内置解码器,决定性因素是平台覆盖面而非作者身份。大约 1,873 行 Object Pascal 能编译到编译器所及的任何地方:Win32、Win64、macOS、iOS、Android,以及 Linux 上的 FPC。libtiff 4.7.1 是约 30,000 行 C 代码,分散在 34 个 tif_*.c 编译单元里,而现存的预编译目标文件只覆盖 Windows。采用它等于用完整的 TIFF 覆盖面换一张缩水到“只要能跑 C 工具链的机器”的支持平台清单,外加一次没人走过的链接工序
代价也值得不加粉饰地说清楚。内置解码器处理的是扫描文档工作真正会产生的东西:CCITT Group 3 一维和二维、Group 4、LZW、Deflate、PackBits 以及 TIFF 内嵌 JPEG,覆盖 WhiteIsZero、BlackIsZero、RGB、调色板和 CMYK 光度模型,Predictor 1 和 2。这些载荷与 ISO 32000-1 §7.4.4 和 §7.4.6 的 PDF 过滤器一一对应,这正是 TIFF 前端在扫描管线中举足轻重的原因。它不处理的是 BigTIFF、分块、浮点 Predictor 3、PixarLog 和 SGILog、老式 JPEG 压缩 6,以及子 IFD 金字塔。自 3.534.1 起,这其中的每一项都是有名有姓的拒绝而不是错误图像,库里还留着一份重新打开 libtiff 决策的书面触发清单:
- 客户报告了 BigTIFF 文件,并且需要原生支持而不是转换步骤
- 客户报告了来自医疗、GIS 或工业来源的分块 TIFF,需要就地解码
- 客户报告了浮点 Predictor 3 TIFF
- 有公开漏洞落在内置 CCITT 或 LZW 解码路径上
- 跨平台理由不再成立,无论是因为放弃 macOS、iOS 和 Android 支持,还是因为已有可复用的 libtiff 集成覆盖了 macOS 和 Linux
迁移本身是有界定范围的,不是假想:一个 USE_LIBTIFF 条件编译会保持 TPDFTIFF 公开接口原样,把 LoadFromStream 经流回调路由到 TIFFClientOpen,并把 Pascal 解析器留作非 Windows 的回退。在这些触发条件真正有一项落地之前,维护两套解码器和翻倍的测试矩阵买不到任何客户能感受到的东西。把退路白纸黑字写好之后再推迟一笔成本,和无视这笔成本是两回事
扫描文档管线至此处在什么位置
把 TPDFTIFF 当作闸门而不是转换器。加载文件,读取 ValidTIFF,只要它为假就把 LastError 原样记入日志,因为这个字符串现在是从现场报告到诊断的最短路径。被闸门挡下的文件仍可通过上游转换挽回,这是当下对 BigTIFF 和分块来源的实际答案。对于完全不属于 TIFF 的输入,PDFlibPas 另走AVIF、HEIF 和 JPEG XL 图像输入路径,于是哪个解码器管哪种格式的问题保持显式,而不是自发涌现
所有这些都藏在普通图像 API 后面,因此文档管线一行调用代码都不用改就能获得更紧的边界,唯一要补的是检查那个它本就该检查的页数。如果你正在权衡 Delphi 或 C++Builder 的原生 TIFF 转 PDF 路径,完整组件及其图像处理文档见 PDF Library for Delphi 页面