最初のトップレベルブックマークがページ 5 を指す 40 ページのユーザーマニュアルで、「このブックマークから次のブックマークまで」で切るスプリッターは、合計 36 ページのチャプターファイルを書き出します。表紙、著作権ページ、2 ページの目次が、すべてのアウトライン項目の前に座っているので、どのファイルにも着地しないのです。アウトラインはセクションがどこから始まるかを示すだけです。最初の項目より前はカバーせず、形式は項目がページ順に列挙されることを何も要求しないのです。だから信頼できるスプリッターは、ブックマークのページを切れ目として集め、ソートし、前付けの明示的な範囲を足します
すべての分割モードの背後にある 1 つのループ
PDFium Component は、境界を何が決めるかにかかわらず、2 つ目の TPdf 上で同じ 4 工程で文書を分割します。CreateDocument がメモリ上に空の PDF を始め、ImportPages がソースからページ範囲をそこへコピーし、SaveAs がファイルを書き、Active := False が出力を再び閉じます。ページごとの分割、固定サイズの塊、ブックマークセクションの違いは、ImportPages に渡す範囲文字列だけです
各パスの最後のリセットは省略できません。CreateDocument は、まだアクティブなコンポーネント上では実行を拒み、メッセージが "Cannot perform this operation on an active" で始まる EPdfError を上げます。だから Active := False を忘れたループの 2 回目の反復は、ちょうど 1 個のファイルが書かれた後に例外で止まります。リセットを finally ブロックに入れると、失敗したインポートや保存が、次のパスに向けて出力を開いたままにするのを防げます。1 つの TPdf インスタンスが全走行に仕え、メモリに保持される出力文書は常に 1 つです
ソースを開くことには、それ専用の検査が値します。Active := True の代入は決して raise しません。ファイルがない、壊れている、供給されていないパスワードを必要とするとき、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 がまだアクティブの間は raise する
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 の第 3 引数は、最初にインポートされるページが着地する、宛先の 1 始まりの位置です。空の文書では 1 が唯一の位置です。範囲構文そのもの(単一ページ、'5-10' のような閉スパン、カンマリスト)と、マージされた束を逆にする位置の間違いは、複数の PDF ファイルを 1 つの文書へマージするで扱います。同じ API の逆向きです
固定サイズの塊と、呼び出し側が指定する範囲
N ページごとに文書を切るのは数えの問題で、注意が要るのは末尾だけです。230 ページのスキャンを 25 ページずつに切ると、完全な 9 部と、5 ページの 10 部目になります。だから各スパンの終わりは 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) // 1 ページの末尾は素の数値で
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 にし、その文字列の中のページは 1 つもコピーされません。ループの内側では、その失敗は、3 つのセクションファイルがすでにディスクにある後に、4 番目の項目として届きます。最初の CreateDocument の前に、すべての項目を Source.PageCount に対して確認すれば、数行のパースで済み、4 番目の項目の打ち間違いが何も書かない、やり直す人の期待する振る舞いになります
選択がすでに整数配列に住んでいるとき、たとえばサムネイルビューで選ばれたページは、ImportPagesByIndex(Source, PageIndices, InsertAt) が文字列の工程を飛ばします。インデックスも InsertAt も 0 始まりなので、範囲として '1,3-5' と読める同じ選択は、[0, 2, 3, 4] になります
ブックマークセクションが短くなったり消えたりする理由
ブックマークセクションは、コードがアウトラインの整いを信用するときに壊れます。TPdf.Bookmarks は、作成者が並べた順でトップレベルの項目を返します。それぞれ Title と 1 基まりの PageNumber を持つ TBookmark レコードです。現実のファイルには、素朴な「この項目から次の項目まで」の規則を壊すものが 4 つあります
- アウトライン項目は、ターゲットを
/Destエントリとして運ぶか、/Aの GoTo アクションとして運びます(ISO 32000-1 §12.3.3 と §12.6.4.2)。PageNumberは/Destから埋められます。アクションベースの項目では0のまま、ページは代わりにActionPageNumberに入ります - アウトラインの順序は、作成者が見た読み順です。2 番目に置かれた「索引」の項目や、章の前に列挙された付録は、「次のブックマークから 1 を引く」を現在の開始より小さくし、そのセクションは消えます
- 2 つのトップレベル項目が同じページを指せます。典型的には、部のタイトルとその最初の章です
- ページを、アウトラインに手を付けないツールで削除したとき、項目は最後のページの先を指せます
下のバージョンは、アウトラインを切れ目の供給源として扱います。各項目のページを解決し、どこも指さない項目と末尾の先を指す項目を落とし、共有ページでは最初のタイトルを残し、ページ番号でソートし、最初の切れ目がページ 1 より後なら「前付け」セクションを足します。前付けは、チャプター 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 アクションから解決
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 のガードは発火しないはずです。ブックマークのない文書は、このモードでは単純にファイルを生みません。チャンク分割へフォールバックするかどうかは、呼び出し側が決めます。1 段深く切るとき、たとえば部の中の章でなら、BookmarkChildren[Bm[I]] が、トップレベル項目の子を同じ TBookmarks 形で返し、同じ切れ目のロジックが結合リストに当てはまります
各出力ファイルが運ぶもの
各出力ファイルは、ページそのものと、ページ辞書が参照するものすべてを運びます。コンテンツストリーム、フォント、画像、各ページの注釈リストです。フォームウィジェットは注釈として付いてきますが、新しいファイルには AcroForm がないので、もはや動くフィールドではありません。ソースは読まれるだけです。ImportPages はオブジェクトを新しい文書へコピーし、開いたままのソースには手を付けないので、同じ Source インスタンスが毎反復に仕えます
アウトラインは後ろに置き去りになります。ブックマークは、どのページの外側、文書カタログの /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」というタイトルの 2 つの章が互いに上書きするのを防ぎます。残りは、自分で書く小さな関数です
// リーダー側のヘルパー。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 は、ターゲットフォルダの一時ファイルへ書き、保存が成功した後にだけ所定の場所へ入れ替えます。ターゲットは、したがって、前のファイルか完全な新しいファイルかのどちらかで、書きかけになることはありません。だから既存の出力フォルダの上で分割をやり直すのは無害です。失敗は 2 つの形で戻ります。文書をシリアライズできないとき、SaveAs は False を返し、一時ファイルは取り除かれます。存在しない出力フォルダ、OS が拒むパス、ビューアにロックされたターゲットのようなファイルシステムの問題は、ファイル層から例外を上げます
バッチがそうした失敗にどう反応するかは選択で、分割ではマージと違います。部品の欠けたマージ束は間違った文書なので、そこでは止まるのが正しいです。分割の出力は互いに独立です。section_147.pdf が、同僚が開いているせいで書けなくても、他のセクションはまだ良好です。分割の仕事では、ループの内側でファイルごとの例外を catch し、ファイル名とメッセージを記録し、続け、ループの終わりに、書かれたファイル数を計画された範囲数と比較してください。上の例が最初の失敗で raise しているのは、短く保つためだけです。200 個のうち 199 個が書かれ、ロックされていた 1 個の名前を告げるログなら、やり直し 1 回で直ります。ファイル 147 で中断した実行は、どの 53 個が欠けているか、誰かが考え込むことになります
ここで使った分割ループ、ブックマークレコード、暗号化保存はすべて、Delphi と C++Builder 向けの PDFium ベース PDF ライブラリ、PDFium Component に付いてきます