合并和拆分是每个人最先想到的两个页面操作,它们涵盖了很大的范围。但它们并没有涵盖所有内容。有一类独立的工作是重新排列页面而不是移动整个文件:将四张幻灯片平铺到一张纸上作为讲义、将页面从文档背面拖到前面,或者将第 3、7 和 12 页拉取到一个简短的摘录中而不触及其他部分。PDFium 为此公开了三个方法,每个方法的行为都与你已经知道的合并和拆分不同。本文将介绍它们的作用、输出点的位置,以及曾实际在应用中导致崩溃的一个所有权细节。
这三个方法分别是:用于 N-up 拼版的 ImportNPagesToOne、用于就地重排的 MovePages 以及用于子集提取的 ImportPagesByIndex。合并将文档首尾相接,使得页面数等于输入文档页面数的总和。拆分从一个输入中写入几个输出文件。此处的三个操作介于两者之间:其中一个改变了共享一张纸的源页面数量,一个改变了单个文档内部的顺序,另一个将选定的少数页面复制到另一个文档中。了解哪个是哪个可以免去你强行进行“合并后删除”的繁琐步骤,而只需一次调用即可解决。
N-up 拼版到底在做什么
拼版(Imposition)是印前术语,指将几个源页面排列在一张更大的纸张上,以便打印和折叠后的结果能够以正确的顺序阅读。日常版本是 2-up 讲义、4-up 折页,或是在一页上容纳一打缩略图的接触印片(contact sheet)。PDFium 通过一次调用处理该几何形状:
function ImportNPagesToOne(
OutputWidth, OutputHeight: Single;
NumX, NumY : Cardinal): TPdf;
NumX and NumY 描述了网格。值 2, 1 将两个源页面并排放置;2, 2 将四个打包成象限布局;4, 3 构建一个十二页的接触印片。PDFium 按顺序读取源页面,将每个页面按比例缩小以适应其单元格,并从左到右、从上到下填充网格,每当当前网格填满时就开始一张新的输出纸张。源页面不会被修改。你得到的是一个页面为复合页的新文档。
输出大小以磅为单位,而不是像素
OutputWidth 和 OutputHeight 是 PDF 用户单位,而一个 PDF 用户单位是一磅(point),即七十二分之一英寸。该单位声明了输出纸张的物理大小,与屏幕像素或渲染 DPI 无关。这是拼版最容易出错的地方,因为习惯于位图的开发人员会获取像素计数,结果得到一张邮票或广告牌大小的纸张。
值得记住的数字是你最常用的两种纸张大小。US Letter 是 612 乘 792 磅,因为 8.5 英寸乘以 72 是 612,11 英寸乘以 72 是 792。A4 大约为 595 乘 842 磅,源于其 210 乘 297 毫米的尺寸。绑定本身的头部清楚地说明了规则,即一个单位是七十二分之一英寸,并且该单位提供了一个等于 72 的 PointsPerInch 常数,以便你在代码中根据英寸计算大小,而不是编写字面量。
const
LetterW = 612.0; // 8.5 in * 72
LetterH = 792.0; // 11 in * 72
var
Source, Composite: TPdf;
begin
Source := TPdf.Create(nil);
Composite := nil;
try
Source.FileName := 'slides.pdf';
Source.Active := True;
// Four source pages per Letter sheet, 2 by 2 grid.
Composite := Source.ImportNPagesToOne(LetterW, LetterH, 2, 2);
if Composite = nil then
raise Exception.Create('PDFium rejected the imposition arguments');
Composite.SaveAs('slides-4up.pdf');
finally
Composite.Free; // see the next section: this is mandatory
Source.Free;
end;
end;
返回的句柄由你来释放
请再次阅读签名。ImportNPagesToOne 返回 TPdf,而不是 Boolean。该返回值是一个全新的文档句柄,独立于源进行分配,调用者拥有它。你调用该方法的源 TPdf 完好无损,且仍然拥有自己的句柄;复合文档是第二个独立的对象。如果让返回的 TPdf 超出作用域而没有释放它,你就会泄露整个 PDFium 文档。
更危险的错误发生在另一个方向。在底层,该方法通过 FPDF_ImportNPagesToOne 向 PDFium 请求一个新的 FPDF_DOCUMENT,然后将该原始句柄包裹在返回的 TPdf 内部,以便包装器的生命周期管理该句柄。从那时起,该句柄有且仅有一个所有者,并且有且仅有一个应该被关闭的地方:当你 Free 返回的对象时。一个不小心的错误路径既释放包装器,又调用 FPDF_CloseDocument 来关闭它捕获的原始句柄,这会关闭同一个 PDFium 文档两次。这就是双重释放(double-free),这也是曾困扰此处调用者的特定错误。防止该错误的规则很简短:仅在一条路径上关闭文档(通过释放该方法交给你的 TPdf),永远不要越过包装器去关闭它已经接管的句柄。
由此产生两个推论。首先,当 PDFium 拒绝参数时(例如网格轴上有零或分配失败),该方法返回 nil,因此在触及结果之前应该进行 nil 检查。其次,在 try 之前将你的输出变量初始化为 nil 并在 finally 中释放它(如上面的示例所示),这样中途失败就不会让你释放未定义的引用或完全跳过释放。
无需重写即可重排页面
拼版构建了一个新文档。重排就地改变一个文档。MovePages 将一组页面从其当前位置提升并放置在目的地,并在移动的块周围移动所有其他内容,以便页面数保持不变:
function MovePages(
const PageIndices: array of Integer;
DestPageIndex : Integer): Boolean;
索引是基于 0 的。PageIndices 列出了要移动的页面(按照它们最终应有的顺序),而 DestPageIndex 是移动安顿后第一个移动页面落下的索引。Because PDFium 重新定位页面而不是复制和重新压缩它们的内容,所以该操作是廉价且无损的:页面对象保留了它们的流、它们的资源和它们的保真度。这是“拖拽以重排页面”面板背后的调用(用户将缩略图拉到新槽位,你通过一次移动提交新顺序)。当索引超出范围时它返回 False,因此请验证结果,而不是假定重新排列已成功。
var
Doc: TPdf;
begin
Doc := TPdf.Create(nil);
try
Doc.FileName := 'report.pdf';
Doc.Active := True;
// Move the last page (index 4 in a 5-page file) to the very front.
if not Doc.MovePages([4], 0) then
raise Exception.Create('MovePages rejected the index');
Doc.SaveAs('report-reordered.pdf');
finally
Doc.Free;
end;
end;
通过索引拉取子集
第三个操作将一组明确的页面从一个文档复制到另一个文档中。ImportPagesByIndex 接受源文档和基于 0 的索引数组,并将这些页面插入到目标的选定位置:
function ImportPagesByIndex(
Source : TPdf;
const PageIndices: array of Integer;
InsertAt : Integer= 0): Boolean;
你在目标文档上调用它,并将源文档作为第一个参数传递。PageIndices 指定要拉取的源页面(按你希望的顺序);InsertAt 是目标文档中第一个导入页面所在的基于 0 的槽位,因此 0 将它们放在现有第一页之前,并且追加目标文档的当前页数。空数组会导入每一页,这在你需要完全复制时使得调用成为完全复制。如果源文档中的任何索引超出范围,它会返回 False。
这就是与拆分的对比至关重要的地方。拆分写入独立的文件,一个操作在磁盘上产生许多输出。ImportPagesByIndex 做相反形状的工作:它将选定的一组页面收集到内存中的单个目标文档中,然后你只需保存一次。当任务是“将第 3、7 和 12 页作为一个简短的 PDF 提供给我”时,这是直接路线,并且在底层封装了 FPDF_ImportPagesByIndex。
var
Source, Excerpt: TPdf;
begin
Source := TPdf.Create(nil);
Excerpt := TPdf.Create(nil);
try
Source.FileName := 'manual.pdf';
Source.Active := True;
Excerpt.CreateDocument; // start an empty target
// Pull pages 3, 7 and 12 (zero-based 2, 6, 11) into the excerpt.
if not Excerpt.ImportPagesByIndex(Source, [2, 6, 11], 0) then
raise Exception.Create('A requested page index is out of range');
Excerpt.SaveAs('manual-excerpt.pdf');
finally
Excerpt.Free;
Source.Free;
end;
end;
干净利落地组合在一起
这三者的端到端形式是相同的:通过设置 FileName 并将 Active 切换为 True 来打开源,执行操作,使用 SaveAs 保存,并释放你拥有的内容。唯一需要注意的分支是哪些调用分配了新文档。MovePages 会修改你已经持有的文档,因此只有一个对象需要释放。ImportPagesByIndex 写入你自己创建的目标,因此你需要释放源 and 打开的目标。ImportNPagesToOne 是异常值,因为新文档是方法的返回值,而不是你构建的东西,忘记它是一个独立的、调用者拥有的句柄是导致泄露和双重释放的原因。将结果初始化为 nil,在调用后检查它,并在单条路径上释放它。
如果你实际要进行的工作是组合整个文件而不是重新排列页面,请参阅将多个 PDF 文件合并为一个文档。如果是相反的情况(将一个文档拆分为多个文件),请参阅将 PDF 文档拆分为多个文件。此处描述的拼版和重排方法作为 Delphi 和 C++Builder 的 PDFium Component 的一部分提供,同时还包括本博客其他地方介绍的加载、渲染和编辑 API。