PDF 会把图像当作内容流里的一级对象来存放。页面引用照片、扫描件或图表时,像素数据会和页面几何信息一起放在 XObject 字典中。PDFium Component 通过 TPdf 上的两个属性把这些内容暴露出来:BitmapCount 返回当前页嵌入位图的数量,Bitmap[Index] 将其中一张解码成你拥有并且必须释放的 TBitmap。这就是完整的提取模型。循环只有四行,真正需要判断的是外围的支撑代码
先把文件打开
关于 TPdf,首先要知道 Active := True 从不会抛出异常。加载失败、密码错误、文件损坏,这些情况都会被内部吞掉,组件只会保持未激活状态。你必须在赋值后自己检查这个标志,否则你会进入页面循环,却发现 PageCount 返回 0,不知道为什么没有提取任何内容
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'report.pdf';
Pdf.Active := True;
if not Pdf.Active then
begin
Writeln('Failed to open: ', Pdf.FileName);
Exit;
end;
Writeln(Pdf.PageCount, ' pages');
// proceed to extraction
finally
Pdf.Free;
end;
end;
受密码保护的文件也遵循同样模式:在设置 Active := True 之前先赋值 Pdf.Password。如果密码错误,Active 仍然保持 False,你也不会得到可捕获的异常。在处理数百个文件的批量工具中,这种静默行为其实很有用:你可以把失败项收集到列表里,而不是让每个错误都展开调用栈
逐页遍历并提取位图
BitmapCount 是按页计算的,所以在读取之前要先设置 Pdf.PageNumber。页码从 1 开始,默认值是 0,表示没有加载页面。Bitmap[Index] 属性从 0 开始并返回调用方拥有的 TBitmap。你必须释放它。若在大文档的长循环中忘记释放,内存会迅速上升,因为每个位图在任何压缩之前都可能占用数 MB 的原始像素数据
procedure ExtractAllImages(Pdf: TPdf; const OutputDir: string);
var
Page, Idx: Integer;
Bmp: TBitmap;
OutPath: string;
begin
for Page := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := Page;
for Idx := 0 to Pdf.BitmapCount - 1 do
begin
Bmp := Pdf.Bitmap[Idx];
if not Assigned(Bmp) then
Continue;
try
OutPath := Format('%s\p%d_img%d.bmp', [OutputDir, Page, Idx + 1]);
Bmp.SaveToFile(OutPath);
finally
Bmp.Free;
end;
end;
end;
end;
Assigned 这个判断很重要。少数 PDF 生成器会写出像素尺寸为 0 或其他格式损坏的图像 XObject;在这些情况下,组件会返回 nil,而不是一个空位图。把 nil 当成错误并停止提取是不对的:跳过它,如果需要审计记录就记录页码和索引,然后继续。该页的其他图像仍然可能有效
注意外层循环会在每次迭代时设置 Pdf.PageNumber。这个赋值会把页面加载进组件的内部状态,并让 BitmapCount 变得有意义。跳过它,你就会反复读取同一页的数量。这个模式写起来像是多余,但 API 就是这样设计的:页面是游标,不是集合
选择输出格式
BMP 是无损格式,而且无需额外单元就始终可用,所以当你还不清楚图像内容时,它是一个稳妥的默认选择。文件大小重要时,返回的 TBitmap 的像素格式会告诉你该用哪种编解码器。32 位位图带有 alpha 通道;PNG 可以无损保留它。较大的 24 位连续色调图像适合用 JPEG。较小的图像,或使用有限调色板绘制的图像,通常更适合保留为 BMP,而不是转换为 JPEG,因为后者在低质量设置下会产生块状失真,在高质量设置下节省的空间又很有限
procedure SaveBitmap(Bmp: TBitmap; const FileName: string);
var
Jpg: TJPEGImage;
begin
case UpperCase(ExtractFileExt(FileName)) of
'.JPG', '.JPEG':
begin
Jpg := TJPEGImage.Create;
try
Jpg.Assign(Bmp);
Jpg.CompressionQuality := 85;
Jpg.SaveToFile(FileName);
finally
Jpg.Free;
end;
end;
else
Bmp.SaveToFile(FileName); // BMP: lossless, no extra units
end;
end;
实际使用时,格式选择主要取决于 Bmp.PixelFormat 和尺寸。如果 PixelFormat = pf32bit,你需要一种能保留 alpha 的格式;PNG 是显而易见的选择,不过在旧版 Delphi 中它需要 PNGImage 单元。对于宽度大约超过 300 像素的 24 位图像,质量 85 的 JPEG 相比 BMP 通常可以把体积缩小到三分之一,而且对大多数照片类内容几乎没有可察觉的损失。低于这个阈值时,BMP 的体积往往也相近,而且可以完全避免格式选择
BitmapCount 统计的内容与不会统计的内容
PDF 会区分图像 XObject 和通过路径运算绘制的矢量图形。一个视觉上很复杂的页面,如果所有元素都是矢量,BitmapCount 也可能返回 0。扫描页几乎总是只返回 1:扫描仪会把整页扫描件以单个全页图像 XObject 的形式写入,分辨率就是扫描时设置的值。把排版文字与嵌入照片混在一起的页面,会为每张照片返回一项。装饰线、阴影背景和表格边框通常根本不会计入位图数量
位图数量也不包括内联图像,这是一种较少使用的 PDF 结构,图像数据直接嵌入页面内容流,而不是作为命名 XObject。它们超出了这个 API 的暴露范围;在真实文档中也足够少见,大多数提取工具都不会处理它们
另一个值得记住的细节是,你读到的 BitmapCount 对应的是上一次 PageNumber 赋值时的当前页。如果你的代码在计数和取图之间分支,或调用了任何会修改 PageNumber 的函数,你可能会读到比预留空间更少的图像,或者索引越界。请把计数读取和 Bitmap[] 循环保持在同一页上,中间不要碰 PageNumber
在窗体应用中使用 TPdfView
TPdfView 组件暴露了相同的 BitmapCount 和 Bitmap[] 属性,但它读取的是视图当前显示的页面,而不是 TPdf.PageNumber。这两个页码指针彼此独立;设置一个不会移动另一个。在带有实时查看器的 VCL 窗体应用中,你可以调用 Pdf.PageNumber := N 让 TPdf 执行提取,同时查看器保持在用户最后滚动到的页面上。这种分离是有意的,它能在后台提取运行时保持查看器的显示状态整洁
批处理作业中的内存与性能
在大规模档案中,内存预算是最需要关注的部分。每次调用 Bitmap[] 都会在堆上分配一个新的 TBitmap,而在 300 DPI 的扫描页上,这在编码之前就很容易达到 25 MB 的原始像素数据。如果你在紧密循环中处理页面却不在每次迭代之间释放对象,工作集会随着图像数量线性增长。正确的做法始终是:获取一张位图,完成你需要的处理,释放它,再获取下一张。如果你需要同时保留几张位图用于比较,先用 BitmapCount 统计数量并相应分配容器,然后在每张用完后立刻释放,而不是拖到文档结束时再统一清理。对于一份包含 500 页扫描件的文档,这种差别可能意味着峰值 RSS 是 25 MB 还是 12 GB
这里展示的 BitmapCount 和 Bitmap[] 属性属于 Delphi 和 C++Builder 的 PDFium Component