有个理赔团队正对着一台自动馈纸式扫描仪发愁,这破机器把他们积压了三十年的纸质档案一通猛扫,直接在文件夹里吐出了一大堆按着 0001.jpg、0002.jpg 这样往下排的单页 JPEG 图片。可人家档案室真正想要的是一个案子对应一个 PDF、里头的页面全按顺序排好的整齐货色,这样审查员点开一个文档就能干活,不用可怜巴巴地在一堆缩略图里挨个点着看。这最后临门一脚的活儿,就是要把这一大堆标着号的扫描图片揉成一个规规矩矩的 PDF,这也正是咱们这会儿要干的事
PDFium 组件直接就能把这活包圆了。别以为它只会渲染和抠字(提取文本),这组件还能凭空捏出一个 PDF 来:建个空壳文档,往里头随便塞一张你想多大就多大的白纸,然后按着用户空间坐标把图给拍上去,最后存盘。整条流水线就挂在 TPdf 这个组件上,所以你想搞个批量转换器,其实就是一个套着文件名的循环外加屈指可数的几个调用
转换的骨架长啥样
对付每张扫描图,你得连闯三关:决定纸盘有多大、把图扔到纸中间还得留出喘气的边距、最后翻到下一页去。PDFium 组件给这三关各配了一把钥匙:AddPage 负责按着你报的尺寸裁出一张白纸,AddImage(要是你手里已经捏着个 TPicture 那就用 AddPicture)负责把位图给糊到当前页上,而 PageNumber 则是用来发号施令,告诉组件接下来的画图操作该往哪页上使劲
这里头唯一的坑,也是无数人踩过的雷,就是坐标系。PDF 用户空间里,原点死死地钉在页面的左下角,Y 轴更是往天上爬越往上越大,这跟 Delphi 程序员骨子里那种把原点顶在脑门上、Y 轴一路往下砸的屏幕坐标系完全是反着来的。你传给 AddImage 的那对 X, Y,那是图片矩形框的左下角坐标;而那个 Width, Height,则是拿磅量出来的排版尺寸,压根不是你那源文件原装的几乘几像素。你要是敢把这规矩搞反了,那你那些扫描图绝对会飞到页面外头去,要么就是以一个让你抓狂的倒栽葱姿势挂在上面
建文档,一图一页给它排上
先捏个空壳子文档出来。CreateDocument 会直接霸占一块内存给你搞个新鲜出炉的 PDF 出来,而且组件自己就已经是激活状态了,所以压根用不着你再脱裤子放屁去弄个单独的 open 步骤。接下来你就顺着扫描文件列表往下溜达,每逮到一个文件,就给它塞一页白纸、把这页拉过来当当前页,然后把图给糊上去。这里的纸盘尺寸咱们按 A4 的磅数来算(竖版 595 × 842),这也是人家归档信件的雷打不动的标准身材
procedure TArchiveForm.ScansToPdf(const Files: TStrings; const OutputPath: string);
const
PageW = 595.0; // A4 width in points
PageH = 842.0; // A4 height in points
Margin = 36.0; // half-inch border around each scan
var
I: Integer;
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.CreateDocument; // new, empty, already active
for I := 0 to Files.Count - 1 do
begin
Pdf.AddPage(I + 1, PageW, PageH); // 1-based page index
Pdf.PageNumber := I + 1; // make the new page current
PlaceScan(Pdf, Files[I], PageW, PageH, Margin);
end;
Pdf.SaveAs(OutputPath);
finally
Pdf.Free;
end;
end;
每次循环里都是一造完纸就立马拿 PageNumber 把光标给移过去。这第二行代码绝不是摆设:AddPage 只管加页不管选页,而那些画图的方法全是一帮只会死盯着当前页下手的势利眼,所以设 PageNumber 就是为了把 AddImage 的枪口对准你刚捏出来的那张纸。你要是敢跳过这一步,你那些图绝对会全堆在前头不知道哪页的烂尾楼里
这个循环里头其实还藏着个要命的假设:Files 的顺序。扫描仪确实是乖乖地按着 0001.jpg 一路数到 0100.jpg 来起名的,但你要去拿目录枚举去抓文件的时候,人家可不保证把排序给你做好了;只要你这列表里碰上个 page9.jpg 和 page10.jpg,按着破字符串一排,第 10 页立马就跑第 9 页前头去了。在冲进循环之前,必须得明明白白地把列表给排个序,而且在扫描那头最好老老实实用补零的名字,这样按着字母排出来的顺序才能跟真正页码对上号。页码乱套可是审查员闭着眼都能抓出来的第一死罪,而把它掐死在摇篮里却是成本最低的买卖
把图拍上去,还得保住它的原汁原味(宽高比)
扫描图的身材极少能跟纸盘的尺寸严丝合缝。你要是霸王硬上弓把它拉扯满全屏,里头的字全得变形;你要是原封不动按像素大小硬塞,它又会挤到纸外头去。正经的做法是拿宽和高去分别比一下,挑那个最小的比例当缩放的紧箍咒,然后再把剩下的地盘匀一匀居中。既然这见鬼的原点是在左下角,居中说白了就是把剩下的空地劈成两半,然后往 X 和 Y 头上各加一半
procedure TArchiveForm.PlaceScan(Pdf: TPdf; const FileName: string;
PageW, PageH, Margin: Double);
var
Pic: TPicture;
AvailW, AvailH, Scale, DrawW, DrawH, X, Y: Double;
begin
Pic := TPicture.Create;
try
Pic.LoadFromFile(FileName); // BMP, JPG, PNG, etc. via the VCL graphics units
AvailW := PageW - 2 * Margin;
AvailH := PageH - 2 * Margin;
// Fit inside the margins without distorting the scan.
Scale := Min(AvailW / Pic.Width, AvailH / Pic.Height);
DrawW := Pic.Width * Scale;
DrawH := Pic.Height * Scale;
// Center: leftover space split evenly. Y measured from the page bottom.
X := (PageW - DrawW) / 2;
Y := (PageH - DrawH) / 2;
Pdf.AddImage(FileName, X, Y, DrawW, DrawH);
finally
Pic.Free;
end;
end;
这法子先把文件读一遍,把它的像素三围给摸底出来,算出个不偏不倚的缩放比例,最后把算好的框框甩给 AddImage。AddImage 只要闻着文件路径的味儿,就能把它直接塞进跟 AddPicture 走一条道的图片流水线里去,所以只要是 VCL 图形单元认识的破格式,全都能顺利通关不用开小灶。要是你在做预览界面的时候,早就已经把图给拆进 TPicture 里了,那直接拿这算好的框框去喊 AddPicture(Pic, X, Y, DrawW, DrawH) 就行了,别闲的没事再去读一遍文件
遇到 JPEG?直接跳过解包的坑
扫描仪吐出来的货十有八九都是 JPEG。你要是非得把 JPEG 塞进个 TPicture 里,它绝对会先把它给解包成一堆肥嘟嘟的位图,然后等存盘的时候,PDFium 又得把它给重新压缩一遍,这一来一回折腾的全是有损的无用功。AddJpegImage 直接就把原汁原味压缩好的字节流从流(stream)里一把薅出来拍进页面里,这要是对付成堆的批量任务,既快得飞起,出来的画面还能少糊一层
var
Stream: TFileStream;
begin
// ... after AddPage + PageNumber for the current page ...
Stream := TFileStream.Create(FileName, fmOpenRead);
try
// Embeds the JPEG bytes as-is; no decode/re-encode cycle.
Pdf.AddJpegImage(Stream, X, Y, DrawW, DrawH);
finally
Stream.Free;
end;
end;
不过你那 X、Y、DrawW 和 DrawH 还是得老老实实按原样算,毕竟缩放那关得靠像素尺寸来撑腰。去文件里或者直接剥个文件头把尺寸给摸出来,然后把原始数据流塞给 AddJpegImage。碰到 PNG 或者 TIFF 这种货色,老老实实去走 AddImage 的道;JPEG 这个抄近道的大招,就留给它真正该伺候的格式吧
给每页打个名牌
归档的扫描件要是每页脚下都踩着自己原始文件的大名,那日后查账可就省事多了。AddText 能在用户空间的随便哪个坑位上画出一串字来,所以搞个图注也就是把它拍在图片脚底下罢了。这会儿你得想起那个反人类的 Y 轴了:想把标签挂在扫描图的屁股下面,你得拿图片底边的坐标去做减法,而不是傻乎乎地去加
// Caption below the scan: Y decreases toward the page bottom.
Pdf.AddText('File: ' + ExtractFileName(FileName), 'Helvetica', 9,
X, Y - 14, clGray);
存盘这事还有最后一句嘴要碎。SaveAs 是个正儿八经带个布尔型返回值的函数,所以在生产环境里头,你最好去查查它的脸色,而不是闭着眼觉得肯定存上了;要是硬盘塞满了,或者是输出路径被谁给死死锁住了,它绝对连个屁都不放就给你摆烂。等循环跑完、文件落听,你手里攥着的绝对就是档案室做梦都想要的东西:一个案子一个按顺序排好、自适应缩放妥当、随便哪个阅读器都能顺利打开的 PDF 完美成品
拿着这套积木稍微换个玩法,把每页的排版规矩改一改,你就能搞出一本一页一张图的写真集;要是循环留着但改成去吃一个多页的 TIFF,那就成了个专门伺候传真归档的转换器。要是你想跳出这井口,去见识见识怎么拿代码凭空捏出一个完整的 PDF 来,去翻翻 在 Delphi 中使用 PDFium 组件从头开始创建 PDF 文档;要是以后想把这玩意儿再拉回屏幕上给人看,那 使用 PDFium 组件将 PDF 页面转换为 JPEG 图像 就是你得去啃的下一篇了
来自 loslab.com 的 PDFium 组件,可是把这整个系列里满天飞的建文档、渲染还有抓文字的 API 全给包圆了