合并和拆分是每个人最先想到的两个页面操作,它们涵盖了很大的范围。但它们并没有涵盖所有内容。有一类独立的工作是重新排列页面而不是移动整个文件:将四张幻灯片平铺到一张纸上作为讲义、将页面从文档背面拖到前面,或者将第 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 是移动完成后第一个页面所在的位置。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 写入你创建的目标,因此要释放源文档和目标文档。ImportNPagesToOne 返回一个由方法创建的新文档;它是独立的、由调用方负责释放的句柄。将结果初始化为 nil,调用后检查,并沿唯一清理路径释放它,可以同时避免泄漏和双重释放
如果你实际要进行的工作是组合整个文件而不是重新排列页面,请参阅将多个 PDF 文件合并为一个文档。如果是相反的情况(将一个文档拆分为多个文件),请参阅将 PDF 文档拆分为多个文件。此处描述的拼版和重排方法作为 Delphi 和 C++Builder 的 PDFium Component 的一部分提供,同时还包括本博客其他地方介绍的加载、渲染和编辑 API