一份 40 页的用户手册,第一个一级书签指向第 5 页——一个按「从这个书签切到下一个」干活儿的拆分器,写出的章节文件加起来只有 36 页。封面、版权页和两页目录坐在每条大纲条目之前,永远不会落进任何文件。大纲只标记节从哪里开始:它不覆盖第一条之前的任何东西,而且格式里没有任何东西要求条目按页序列出。所以可靠的拆分器把书签页收集为切点、排序,并显式加上一段前置内容范围
所有拆分模式背后都是同一个循环
PDFium Component 拆文档用同一套四步,跑在第二个 TPdf 上,无论边界由什么决定:CreateDocument 在内存里起一份空 PDF,ImportPages 把源文档的一段页复制进去,SaveAs 写文件,Active := False 再把输出关掉。逐页拆、固定大小分块、书签分段,区别只在递给 ImportPages 的范围字符串
每轮末尾的重置不是可选项。CreateDocument 拒绝在仍处于 active 的组件上运行,抛出的 EPdfError 消息以「Cannot perform this operation on an active」开头,所以忘了 Active := False 的循环,恰好在写出第一个文件之后、第二轮以异常停摆。把重置放进 finally 块,导入或保存失败就不会让输出开着进入下一轮。一个 TPdf 实例跑完全程,内存里始终只持有一份输出文档
打开源文档值得单独一道检查。赋 Active := True 从不抛异常:文件缺失、损坏或需要未提供的密码时,TPdf 捕获加载错误、让 Active 停在 False。ImportPages 还会自己尝试激活源文档,这意味着没打开的源只以第一页上的 False 返回现形,对原因只字不提。显式打开并测 Active,就能在任何输出存在之前把问题变成一个点名文件的错误:
procedure SplitIntoPages(const SourceFile, OutputDir: string);
var
Source, PdfOut: TPdf;
I: Integer;
OutFile: string;
begin
Source := TPdf.Create(nil);
PdfOut := TPdf.Create(nil);
try
Source.FileName := SourceFile;
Source.Active := True; // 加载失败会让 Active 保持 False
if not Source.Active then
raise Exception.Create('Cannot open ' + SourceFile);
for I := 1 to Source.PageCount do
begin
PdfOut.CreateDocument; // PdfOut 仍 active 时会抛异常
try
// 范围从 1 起算;位置 1 是空文档的开头
if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
raise Exception.CreateFmt('Cannot import page %d', [I]);
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('page_%.4d.pdf', [I]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False; // 下一轮之前关闭输出
end;
end;
finally
PdfOut.Free;
Source.Free;
end;
end;
ImportPages 的第三个参数是目标文档里 1 起算的位置,第一个被导入的页落在这里。空文档里 1 是唯一存在的位置。范围语法本身(单页、'5-10' 这样的闭区间、逗号清单)以及会把合并包顺序颠倒的位置错误,见把多个 PDF 文件合并成一个文档——同一个 API 反着用
固定大小分块与调用方给定的范围
每 N 页切一份是数数问题,唯一需要小心的情形是尾巴。230 页的扫描按 25 页一份切,得到九个整份加一份五页的第十份,所以每段终点要钳到 Source.PageCount:
procedure SplitEvery(Source: TPdf; ChunkSize: Integer; const OutputDir: string);
var
PdfOut: TPdf;
FirstPage, LastPage, Part: Integer;
Range, OutFile: string;
begin
if ChunkSize < 1 then
raise Exception.Create('ChunkSize must be at least 1');
PdfOut := TPdf.Create(nil);
try
Part := 0;
FirstPage := 1;
while FirstPage <= Source.PageCount do
begin
LastPage := FirstPage + ChunkSize - 1;
if LastPage > Source.PageCount then
LastPage := Source.PageCount; // 最后一份通常更短
if FirstPage = LastPage then
Range := IntToStr(FirstPage) // 单页尾巴直接用数字
else
Range := Format('%d-%d', [FirstPage, LastPage]);
Inc(Part);
PdfOut.CreateDocument;
try
if not PdfOut.ImportPages(Source, Range, 1) then
raise Exception.Create('Cannot import pages ' + Range);
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('part_%.3d.pdf', [Part]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False;
end;
FirstPage := LastPage + 1;
end;
finally
PdfOut.Free;
end;
end;
用户敲进去的范围是另一回事。ImportPages 用返回值报告坏范围:超过 Source.PageCount 的页号、零、颠倒的区间或解析不了的文本,都会让调用返回 False,那个字符串里的页一页也不会复制。在循环里,这个失败在三个章节文件已经落盘之后、第四个条目上才到。第一次 CreateDocument 之前把每个条目对着 Source.PageCount 核一遍,多花几行解析,换来的是第四个条目里的笔误一个文件都不写——重试任务的人要的就是这个行为
选择结果已经躺在整数数组里时——比如缩略图视图里勾选的页——ImportPagesByIndex(Source, PageIndices, InsertAt) 跳过字符串一步。那里索引和 InsertAt 都是零起算,读作 '1,3-5' 的同一组选择变成 [0, 2, 3, 4]
为什么书签分段会变短或缺失?
书签分段出错,出在代码太相信大纲的整洁。TPdf.Bookmarks 按作者排布的顺序返回一级条目,每条是一个 TBookmark 记录,带 Title 和 1 起算的 PageNumber。真实文件里有四样东西会打破朴素的「这条切到下一条」规则:
- 大纲条目的目标可能放在
/Dest条目里,也可能作为/A里的 GoTo action(ISO 32000-1 §12.3.3 和 §12.6.4.2)。PageNumber从/Dest填充;action 型条目它保持0,页码在ActionPageNumber里 - 大纲顺序是作者眼中的阅读顺序。排在第二位的「Index」条目、或列在章节之前的附录,会让「下一书签减一」小于当前起点,那一节就此消失
- 两个一级条目可以指向同一页——典型情形是部标题和它的第一章
- 用一个没动大纲的工具删页后,条目可以指向最后一页之后
下面的版本把大纲当作切点来源:解析每条的页码,丢弃无处指或指过头的条目,共享同一页时保留第一个标题,按页码排序,第一个切点在 page 1 之后时补一个「Front matter」段。前置内容在这里拿自己的文件,而不是粘到第 1 章上,因为收到第 1 章的人期望它打开在书签点名的页面上,而不是目录上
procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
Bm: TBookmarks;
Starts: array of Integer;
Titles: array of string;
I, J, K, N, P, EndPage: Integer;
Known: Boolean;
PdfOut: TPdf;
OutFile: string;
begin
Bm := Source.Bookmarks; // 仅一级条目
if Length(Bm) = 0 then
Exit; // 无切点可用
SetLength(Starts, Length(Bm) + 1); // +1 给前置内容留位
SetLength(Titles, Length(Bm) + 1);
N := 0;
for I := 0 to High(Bm) do
begin
P := Bm[I].PageNumber; // 从 /Dest 解析
if P < 1 then
P := Bm[I].ActionPageNumber; // 从 /A 里的 GoTo action 解析
if (P < 1) or (P > Source.PageCount) then
Continue; // 未解析,或越过最后一页
Known := False;
for J := 0 to N - 1 do
if Starts[J] = P then
Known := True; // 同页第一个标题获胜
if Known then
Continue;
J := N; // 按页码插入排序
while (J > 0) and (Starts[J - 1] > P) do
begin
Starts[J] := Starts[J - 1];
Titles[J] := Titles[J - 1];
Dec(J);
end;
Starts[J] := P;
Titles[J] := Bm[I].Title;
Inc(N);
end;
if N = 0 then
Exit;
if Starts[0] > 1 then // 封面、目录以及它们之前的任何东西
begin
for J := N downto 1 do
begin
Starts[J] := Starts[J - 1];
Titles[J] := Titles[J - 1];
end;
Starts[0] := 1;
Titles[0] := 'Front matter';
Inc(N);
end;
PdfOut := TPdf.Create(nil);
try
for K := 0 to N - 1 do
begin
if K < N - 1 then
EndPage := Starts[K + 1] - 1
else
EndPage := Source.PageCount;
PdfOut.CreateDocument;
try
if not PdfOut.ImportPages(Source,
Format('%d-%d', [Starts[K], EndPage]), 1) then
raise Exception.CreateFmt('Cannot import pages %d-%d',
[Starts[K], EndPage]);
// 在本例程之前声明 SafeFileName(见下文)
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('%.2d_%s.pdf', [K + 1, SafeFileName(Titles[K])]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False;
end;
end;
finally
PdfOut.Free;
end;
end;
排序之后起点严格递增,每个算出的区间都有效,ImportPages 上的守卫在可读的源上永远不该触发。没有书签的文档在这个模式下干脆不产出文件;是否回退到分块由调用方决定。要往深一层切——比如部里的章——BookmarkChildren[Bm[I]] 以同样的 TBookmarks 形式返回一级条目的孩子,同一套切点逻辑作用于合并后的清单
每份输出文件带着什么
每份输出文件带着页面本身以及页面字典引用的一切:内容流、字体、图像和每页的注释列表。表单控件作为注释跟着过来,但新文件没有 AcroForm,它们不再是可以工作的字段。源文档只被读取:ImportPages 把对象复制进新文档、不动打开着的源,同一个 Source 实例喂每一轮迭代
大纲被留在原地。书签住在文档 catalog 的 /Outlines(ISO 32000-1 §7.7.2)里,在任何页面之外,所以由导入页构建的文件从一开始就没有任何书签。对章节文件这通常无妨。每个分件需要自己的导航时,必须为新页码重建大纲,那是编辑 PDF 大纲并重映射其页面的主题。内部链接同理:第 12 页上「见第 7 章」的链接指向一页进了别的文件的内容,在这份文件里它无处落脚
加密源在循环开始之前就需要密码。组件未激活时赋 Source.Password,再设 Active := True;密码不对时组件保持未激活,SplitIntoPages 里的检查把它变成错误。SaveAs 写出的输出是一份没有加密字典的新文档。这正适合要发给不同接收方的拆分文件;每份需要重新保护时,SaveAsEncrypted(FileName, Options) 接受一个带自己的用户口令和所有者口令的 TPdfEncryptOptions 记录
文件名、失败的保存与重跑
书签标题是 PDF 文本字符串,什么都能装——包括 Windows 拒绝放进文件名的字符。规则来自文件系统,与 PDF 无关:< > : " / \ | ? * 和控制字符非法,尾部空格和点会被 Win32 文件 API 剥掉,CON、PRN、COM1 这类名字是保留设备名。SplitByBookmarks 里的数字前缀已经解决了设备名问题、让文件在目录列表里保持阅读顺序,还防止两章同名「Summary」互相覆盖。剩下的归一个你自己写的小函数:
// 读取侧的辅助函数,不属于 PDFium Component
function SafeFileName(const Title: string): string;
const
Forbidden = '<>:"/\|?*';
var
I: Integer;
begin
Result := Title;
if Length(Result) > 80 then
SetLength(Result, 80); // 让完整路径稳稳待在 MAX_PATH 之下
for I := 1 to Length(Result) do
if (Ord(Result[I]) < 32) or (Pos(Result[I], Forbidden) > 0) then
Result[I] := '_';
while (Result <> '') and
((Result[Length(Result)] = '.') or (Result[Length(Result)] = ' ')) do
SetLength(Result, Length(Result) - 1);
if Result = '' then
Result := 'section';
end;
带文件名的 SaveAs 先写进目标文件夹里的临时文件,保存成功后才换到位。所以目标要么是旧文件、要么是完整的新文件,绝无半截,对着既有输出目录重跑一次拆分无害。失败有两条回来路:文档无法序列化时 SaveAs 返回 False,临时文件被移除;文件系统层面的问题——输出目录不存在、OS 拒绝的路径、被查看器锁住的目标——从文件层抛异常
批处理如何应对这些失败是一种选择,拆分与合并的选择不同。缺了一块的合并包是错误的文档,停下是正确做法。拆分产物彼此独立:section_147.pdf 因为同事开着它而写不进去,其他章节依然完好。拆分任务里,在循环内逐文件捕获异常、记下文件名和消息、继续跑,循环结束时把写出文件数与计划范围数对一对。上面示例在首个失败处抛异常只是为了省篇幅。一条写着「200 份写了 199 份」并点名被锁那份的日志,重跑一次就修好;在第 147 份中止的运行,留给某个人去数清到底缺了哪 53 份
这里用到的拆分循环、书签记录和加密保存,都随 PDFium Component——面向 Delphi 和 C++Builder 的基于 PDFium 的 PDF 库——交付