你的磁碟上有一份 PDF,这是客戶从一疊发票中扫描來的,而你的工作是将页面图片提取为位图 (bitmap),以便進行光學字符辨識 (OCR) 处理。你加载了文件,找到了图片 XObjects,然后发现了一个没有人会警告你的細節:这些流中的字节并不是像素 (pixels)。它们是 JPEG 編码流 (codestream)、經过小波压缩 (wavelet-compressed) 的 JPEG 2000 块、Group 4 传真数据流,或者是隐藏在调色板与 Flate 滤镜后的索引位图。图片对象知道自身的宽度与高度,但实际的样本卻被封裝在产生器所选择的任何滤镜中。要取得可用的 TBitmap,就意味著必须解开该滤镜,而 PDF 大約提供了八种不同的字节封裝方式
这正是 HotPDF(Delphi 与 C++Builder 原生的 VCL PDF 元件)中 ExtractLoadedImage 填補的空缺。它会列舉你所加载文件中的图片 XObjects,报告每一个图片是什么类型,并将它能夠解码的图片转回 24 位元位图。有趣的部分不在於 API 接口本身(那只有三個方法),而在於为什么一开始就必须存在一條独立的解码路径,以及它能(或不能)将什么东西转回像素
为什么已加载的图片没有被预先解码
HotPDF 的加载器是围繞著“直接通过 (pass-through) 保持保真度”而建立的。当你呼叫 LoadFromFile 时,图片流会完全按照它们在來源文件中出现的方式保留:原始滤镜、原始压缩字节、原始字典。这是刻意为之的。加载文件通常的目的是为了复制页面、合併文件、盖章 (stamp)、重新设置权限,然后将它们写回,而对於所有这些操作,最廉價且最安全的做法就是不觸碰每个图片流。如果在加载时就将每个图片解码为位图,会将内存与 CPU 消耗在大多数呼叫者根本不需要的工作上,而在保存时重新編码则会降低本应原样复制的图片质量
結果是,已加载的对象图 (object graph) 不攜带任何像素。一个 /Filter 为 /DCTDecode 的图片 XObject 存放著 JPEG 字节;HotPDF 从未对其执行过 JPEG 解码器,因为在复制并重写的路径中没有任何環節需要这麼做。因此,当你实际需要像素时,提取 API 必须親自从頭开始,針对该特定图片碰巧使用的滤镜進行解码。这也是編码端 (encode-side) 的編解码器 (codec) 独立於加载器的相同原因:关於在 Delphi 中将 JPEG 2000 图片新增至 PDF 的文章描述了 JPX 引擎如何插入建立端 (creation side),而该引擎在提取 API 需要它之前,根本就未連线至读取路径
三個方法的 API
接口很小。GetLoadedImageCount 传回加载的文件包含多少個图片 XObjects。GetLoadedImageInfo 透过索引为其中一个图片填入描述元紀录 (descriptor record)。ExtractLoadedImage 传回解码后的位图,若无法解码该图片则传回 nil。列舉是基於索引且对特定加载而言是稳定的:在内部,它会走訪間接对象表 (indirect-object table) 并收集每个 /Subtype 解析为 /Image 的流,因此你传递給 GetLoadedImageInfo 的索引,与传递給 ExtractLoadedImage 的索引是相同的
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
Bmp: TBitmap;
I, Count: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
Exit;
Count := Pdf.GetLoadedImageCount;
for I := 0 to Count - 1 do
begin
if not Pdf.GetLoadedImageInfo(I, Info) then
Continue;
if not Info.Decodable then
Continue; // filter or colour space not supported
Bmp := Pdf.ExtractLoadedImage(I);
if Bmp <> nil then
try
Bmp.SaveToFile(Format('img_%d.bmp', [I]));
finally
Bmp.Free; // caller owns the bitmap
end;
end;
finally
Pdf.Free;
end;
end;
这里有两个必须注意的合約細節。首先,传回的 TBitmap 由你負責释放;文件本身不会快取或擁有它。其次,请在呼叫前检查 Decodable,并在呼叫后检查結果是否为 nil。该方法在遇到不支援的滤镜时不会引发例外,它会传回 nil,而在批次迴圈中一个安靜的 nil,正是那种会在没有人注意到的情況下吞掉千页工作其中一页的陷阱
解码前读取描述元
THPDFLoadedImageInfo 在不承諾進行完整解码的情況下,告訴你图片是什么。它的字段直接來自图片字典:以样本 (samples) 計算的 Width 与 Height、描述解码后如何解释的 BitsPerComponent、ColorComponents 与 ColorSpace(1 代表灰階,3 代表 RGB,4 代表 CMYK)、作为命名压缩的 Filter、用於网板遮罩 (stencil mask) 的 IsImageMask、底层間接对象的 ObjectNumber,以及 Decodable
最后一个旗标是最誠实的。只有当执行中的組建 (build) 实际上能将这个特定的滤镜与色彩空間組合转换为位图时,Decodable 才会是 True。它編码了真实的支援矩陣,而不是一种期望:若某個图片的 Filter 目前的組建无法理解,它就会回报 Decodable = False,而你可以依据这个條件來建立分支,以記录日誌、略过,或者退回使用你自己的方式去提取原始流。请把它当作前置條件,而不是一个提示
// Triage every image before committing to a decode.
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
I: Integer;
begin
// ... Pdf loaded ...
for I := 0 to Pdf.GetLoadedImageCount - 1 do
begin
if not Pdf.GetLoadedImageInfo(I, Info) then
Continue;
if Info.Decodable then
// ExtractLoadedImage(I) will return a TBitmap
else
// unsupported filter/colour space: log the object and skip
Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
[I, Info.ObjectNumber, Info.Width, Info.Height,
String(Info.Filter), String(Info.ColorSpace)]));
end;
end;
一个实作細節經常咬到手动建立描述元紀录的人。THPDFLoadedImageInfo 包含两个 AnsiString 字段:Filter 与 ColorSpace。这些是具有参考計数的管理型別 (managed types),因此在这里習慣性地使用 FillChar(Info, SizeOf(Info), 0) 來清空紀录是错误的:它会在没有递减参考的情況下覆写字串参考,从而导致内存洩漏或損壞。HotPDF 之所以逐個字段初始化该紀录,正是基於这个原因,如果你在自己的程式码中复制这种模式,请採取相同的做法
一个分派器,八條滤镜路径
这个功能之所以花了一系列的版本发布,而不是一次搞定,是因为 PDF 并没有單一的图片格式。它擁有滤镜,且 ISO 32000-1 §8.9.5 允許图片 XObject 在 /Filter 中指定其中的任何一个,而样本的解读则由 /ColorSpace、/BitsPerComponent 以及一个選用的 /Decode 数组独立控制。ExtractLoadedImage 会读取滤镜名稱,并路由至各情況的专属解码器。从 v2.229 扩展至 v2.231 所建立的支援集合,现在涵蓋了八條不同的路径
- 原始位图 (FlateDecode、LZWDecode 或无滤镜)(8 位元 DeviceRGB 或 DeviceGray)。字节会解压缩为打包的位图,而唯一的转换是色頻交换 (channel swap)(下文会说明)
- DCTDecode (JPEG)。編码流会交給 VCL 的
TJPEGImage处理,由它解析几何与色彩,其結果会指派到一个 24 位元的位图中 - JPXDecode (JPEG 2000)。透过 OpenJPEG 后端解码(与 JPEG 2000 文章中描述的是同一个引擎),并将高位元深度 (high-bit-depth) 的元件重新取样降至 8 位元
- 索引色彩 (Indexed)。从
[/Indexed base hival lookup]数组读取调色板,并将每个样本透过查表法扩充为全彩 - DeviceCMYK。四色頻样本透过标準的“白底油墨 (ink-on-white)”公式转换为 RGB
- 低於 8 位元的 DeviceGray 与 Indexed(每元件 1、2 或 4 位元),逐個样本解开打包并缩放至 0–255 范围
- CCITTFaxDecode,即 Group 3 与 Group 4 传真滤镜,由专属的 T.4/T.6 后端進行解码
- JBIG2Decode,高压缩比的雙色滤镜,透过已註冊的 JBIG2 后端解码(这在原生的 JBIG2 压缩文章中已从編码端涵蓋)
所有路径最终都会落腳在同一个地方:一个 24 位元的 BGR 位图,因为这正是 VCL TBitmap 原生保存的格式,也是每个下游消費者所期望的
会默默改變像素的转换
这些路径中有两个涉及很容易出错的转换,即使你从不親自接觸解码器,也值得了解。第一个是色彩順序的交换。PDF 的 DeviceRGB 位图以紅-綠-藍 (red-green-blue) 的順序保存样本,頂部行優先。而 VCL 24 位元扫描线则以藍-綠-紅 (blue-green-red) 的順序保存它们。因此,解码純 RGB 图片并非單純的 ScanLine[0];進入扫描线的途中,每个像素的第一个和第三個字节必须互换。如果弄反了,紅色与藍色就会交换位置,这在灰階測試图片上看起來没問题,但在彩色图片上卻会错得離譜。不过,行順序 (Row order) 倒是直接对应的:PDF 由上而下的位图与 VCL 的 (255 - ink) * (255 - K) / 255 作为頂部视觉行完全吻合,因此不需要垂直翻转
第二個是 CMYK。PDF DeviceCMYK 图片带有四种油墨,且转换为 RGB 是一个逐色頻的計算,而不是查表:每个输出色頻为 /Indexed。这是一种设备近似值,而不是透过 ICC 设置档的色彩管理转换,因此結果对於顯示与重新光柵化來说夠正確了,但如果你需要列印級別的準確色彩,这并不是正確的途径。如果你的工作流需要保真度,请将提取的位图視为预览,并保留原始 CMYK 流供色彩管理的管线使用
Indexed 路径隐藏了它自己的一个解析陷阱。/Filter 色彩空間中的调色板可以保存为文字字串 (literal string) 或十六進位字串 (hexadecimal string),而 HotPDF 将十六進位字串的值保存为十六進位文字,而不是解码后的字节。因此,当调色板是十六進位字串时,寻找表必须先經过十六進位到字节 (hex-to-bytes) 的解码;如果是文字字串,则已經是原始字节。如果遗漏了这个分支,四色索引图片输出就会變成亂码,因为每一个调色板条目都在错误的字节边界上被读取
滤镜鏈:最后一个滤镜才是图片真正的滤镜
單一的 /Filter 名稱是簡單的情況。PDF 也允許一个滤镜鏈 (chain),这表示流已經依序通过几个滤镜,并按照在 [/ASCII85Decode /FlateDecode] 数组(例如 [/ASCIIHexDecode /DCTDecode] 或 [/ASCII85Decode /DCTDecode])中列出的順序進行处理(ISO 32000-1 §7.4)。其語意很精確:在編码时滤镜由左至右套用,因此在解码时你必须由右至左解开它们,而数组中的最后一个滤镜,才是真正定义该图片格式的滤镜。前面所有的滤镜,只不过是包裝在它外面的传输編码而已
提取器会透过剝離 (peeling) 的方式來处理这点。在任何图片解码器执行之前,滤镜鏈中除了最后一个滤镜之外的每一个滤镜都会先被套用,以产生最终滤镜预期的输入,然后才会对该最后一个滤镜進行分派 (dispatch)。因此,[/FlateDecode] 会先将流解除 ASCII85 編码,然后将結果路由到 JPEG 路径;而包裝在原始位图外面的 nil 会先解压缩,然后执行位图路径。这就是让八個解码器保持簡單的原因。它们都不需要知道 ASCII85 或 hex 传输包裝的事,因为当解码器看到这些字节时,包裝早已消失。这也代表著如果鏈中最后一个滤镜不被支援,它仍会在分派步驟中乾淨地失敗,而不是在半途出错
提取停止的地方,以及然后该怎麼做
誠实面对这些界限。一个最终滤镜超出了支援集合的图片会传回 GetLoadedImageCount,对於一个色彩空間无法被組建解读的图片也是一样。柔和遮罩 (soft masks) 与 Alpha 不会被重建到位图中;你会得到基础图片,而不是合成后的結果。來自 JPEG 2000 高於 8 的位元深度会被重新取样降級,这是有意为之的失真操作;如果你是要重新归档 (archiving) 而不是顯示,那这会是错误的做法。而且,图片遮罩 (image mask)——一个没有自身色彩的一位元网板 (stencil)——虽然描述元有記载它,但它与繪畫性質的图片是不同的东西;如果你期待解出一張照片而对其進行解码,你将会大吃一驚
当提取不足以满足需求时,原始流(包含滤镜在内的所有内容)仍然完好地保存在加载的对象图中,你可以逐個字节将其拉出,交給你自己专业的編解码器。这是直接通过 (pass-through) 的设計刻意保留的退路:原始字节永遠不会被丟棄,所以最壞的情況,也只是你必须親自解码,而不是数据憑空消失。不过,对於大多数实际工作而言,这八個受支援的滤镜已經涵蓋了扫描器、辦公室套件以及报表引擎实际会产出的格式,而只要透过带有 Decodable 防护的 GetLoadedImageCount 迴圈,短短几行程式码就能将加载的 PDF 转回一个裝满位图的数据夾
此处描述的已加载图片提取 API,連同整套的解码滤镜,均隨附於 Delphi 与 C++Builder 版的 HotPDF Component 中