技術文章

在 Delphi 中編輯 PDF 大綱並重新對應頁面

從兩百頁手冊中抽掉七頁,每個書籤就落到錯誤的地方。解法不是從一份平坦的標題清單重建大綱。PDFiumPas 提供 TPdfOutlineEditor:它載入真正的大綱樹,讓您移動項目、重新指定目標,然後執行 ApplyPageMap,把每個顯式目的地按您的頁面計畫移位

為什麼刪頁會弄壞每個書籤?

因為大綱項目不儲存頁碼。它儲存的是對頁面物件的參照;當頁面物件改變,該參照不是指向一個移走的頁面,就是什麼也沒指。ISO 32000-1 §12.3.2.2 把顯式目的地定義為一個陣列:第一個元素是對頁面字典的間接參照,之後跟著一個適配名稱,例如 /Fit/XYZ。刪掉頁面,您只剩一個懸空參照;重新排序頁面,參照仍有效,卻描述了不同的章節。PDFiumPas 在載入時把該陣列解析回頁碼,所以 TPdfOutlineItem.PageNumber 給您的是從一開始的頁面索引,與公開的 TPdf API 一致,而不是物件編號。這正是此抽象的全部意義:您的重新對應邏輯,與您在分割、重排或拼版文件時早已建好的頁面計畫,運作在同一座標系中。如果您正在建構那份計畫,同一套從一開始的慣例貫穿把 PDF 文件分割成多個檔案n-up 拼版與頁面重排

大綱是雙重連結的樹,不是清單

您不能簡單地把平坦的標題陣列序列化,原因在於 ISO 32000-1 §12.3.3 把每個大綱項目接進五條各自獨立的連結:/Parent/Prev/Next/First/Last。因此移動單一子樹,會重寫舊父節點、新父節點、切口兩側與插入點兩旁的鄰近兄弟,以及被移動節點自身的父指標。其中一個弄錯,合規檢視器就會顯示截斷的樹,或陷入迴圈。PDFiumPas 把編輯狀態保存為 TPdfOutlineItem 記錄的深度優先陣列,帶著穩定的整數 Id,所以子樹是一段連續切片,兄弟鏈結是導出的,絕不手工維護。TPdfOutlineEditor.Move 抬起該切片,在新父節點下、依要求的兄弟索引重新插入,並只重新指派該區塊的根。它也拒絕兩種會毀損圖譜的移動:把項目移進它自己的子樹,以及指定一個不存在的父節點

Delphi 中的 PDFiumPas 大綱編輯:把第三章移出第一部、移到文件根之下,會重寫被移動節點的 /Parent 指標,以及切口與插入點周圍的 /First 與兄弟 /Prev、/Next 連結
一次 Move 呼叫會重寫被抬起子樹的父指標,以及切口與插入點兩側的兄弟連結

為什麼 /Count 帶正負號?

因為正負號攜帶的是展開狀態,不是大小。正的 /Count 代表項目已開啟,數字是目前可見的後代數;負的 /Count 代表項目已摺疊。PDFiumPas 為每個有子女的項目寫入後代數,並在 IsOpenFalse 時將其取負;載入時則把狀態讀回為 IsOpen := HasCount and (CountValue > 0)。這是手刻大綱寫入器最常見的單一錯誤:發出不帶正負號的計數,悄悄迫使整棵樹展開

PDFiumPas 在 Delphi 中如何編碼大綱展開狀態:正的 /Count 代表項目已開啟並計算可見後代,負的 /Count 代表已摺疊,不帶正負號的計數會迫使每個檢視器展開整棵樹
/Count 的正負號是展開狀態,幅值是可見後代數,所以不帶正負號的計數會悄悄迫使整棵樹展開
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000、MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // 成為根的第二個子項目
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // 寫入負的 /Count
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget 處理規格允許的兩種形狀。把 DestinationInAction 傳為 False,PDFiumPas 會寫入直接的 /Dest 陣列;傳為 True,它會依 ISO 32000-1 §12.6.4.2 寫入一個 Go-To 動作:/A << /S /GoTo /D [ page ref suffix ] >>。不論哪種方式,它都會先從項目剝除任何既有的 /Dest/A,使兩者無法共存並互相矛盾。suffix 預設為 /Fit,且必須以 PDF 名稱開頭;這正是空的或畸形的 suffix 會立刻引發例外,而不是產生一個沒有檢視器能解析的目的地陣列的原因

ApplyPageMap 如何使用頁面計畫?

ApplyPageMap 接受的正是您的頁面計畫早已驗證過的陣列:NewPageNumbers,以舊頁碼減一為索引,持有新的從一開始頁碼;若該頁未存活則為零。它反向走訪項目陣列,使刪除子樹絕不會使它尚未拜訪的索引失效,並透過 RemappedDestinationCountRemovedDanglingItemCount 回報它做了什麼

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // 原始文件每一頁一個條目
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == 此頁被丟棄

  NewPageNumbers[0] := 1;                // 舊第 1 頁 -> 新第 1 頁
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // 舊第 10 頁 -> 新第 3 頁

  // True:刪除整個懸空子樹。False:保留項目,剝除其目標
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

DeleteDangling 旗標決定「對應到零的目的地」的政策,兩個分支都是刻意的。設為 True 時,PDFiumPas 刪除該項目及其整個子樹,因為目標消失的大綱節點,通常領著一個隨它一起消失的章節。設為 False 時,項目帶著完好的標題與階層存活,但 /Dest/A 被移除——這正是您想要的結果:當有人將在審查中重新指定目標。真正畸形的輸入仍會大聲失敗,而不是被包紮起來:負數條目,或指向所給對映終點之外的目的地,會傳回 False,並把 IssueKind 設為 poviInvalidPageMap

PDFiumPas ApplyPageMap 在 Delphi 中如何重新導向 PDF 書籤:以舊頁碼減一為索引的頁面對映,把存活的目的地送往新頁碼;對應到零的條目,則連同子樹刪除或被剝除目標
頁面對映以舊頁碼減一為索引,零值條目不是刪除懸空子樹,就是留下被剝除目標的項目

不透明條目,以及誠實的取捨

不是每個大綱項目都有 PDFiumPas 能推理的頁碼。有三類會原樣帶過:具名目的地、不是 /S /GoTo 的動作,以及產生該檔案的東西加上的未知字典鍵。這些項目載入時 PageNumber 等於零,在項目中保留原始位元組,並逐字寫回,除非您明確對它們呼叫 Retarget

  • 具名目的地是進入文件名稱樹的一把鑰匙,所以正確重新對應它意味著解析該樹並重寫目標條目,而不是在大綱層級猜測
  • /URI/Launch 或 JavaScript 動作完全沒有頁面語意,絕不能被悄悄轉換成 Go-To
  • 廠商專屬鍵與結構目的地會被保留,因為丟掉您不理解的東西,正是反覆存取遺失資料的方式

代價是真實的,值得直白說明:ApplyPageMap 會完全跳過那些項目,所以書籤全部使用具名目的地的文件,在經歷刪頁後,其大綱結構上有效、語意上過時。這是刻意的選擇——審查者抓得到的過時連結,勝過沒人注意、自信滿滿的錯誤連結。如果您在編輯前先對送來的檔案做分診,PDF 收件審查工作台中的清點步驟會告訴您哪些文件落進那個桶子

儲存:增量修訂,然後獨立重載

TPdfOutlineEditor.SaveIncremental 會追加一筆稀疏的增量修訂,而不是重寫檔案。載入的項目保留其原始間接物件參照,包括確切的世代,所以既有交叉參照維持有效;只有您新增的項目會抽取新編號,從該修訂最大物件編號再加一開始配置。目錄在同一修訂中更新;當來源完全沒有大綱時,會把缺失的 /Outlines 條目加進去

寫入之後發生的事,是值得抄走的部分。PDFiumPas 用一個完全獨立的編輯器重新開啟目的地串流,並把重載的樹與記憶體中的樹比對——項目數、標題、頁碼、目的地 suffix、動作式與直接式目的地的形態、樣式、展開狀態,以及父階關係。任何不一致,或任何載入失敗,都會清空目的地串流並傳回 poviVerificationFailure,而不是交給您一份看起來合理的檔案。加密來源會被當場拒絕,代碼為 poviEncryptedInput,因為新標題與目的地會建立字串內容,無法靠把 /Encrypt 尾頁物件向前抄錄而產生

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

把大綱當成它本來的樣子——一個帶著自身不變量的連結物件圖——刪頁就不再是書籤災難,而變成您交給一次方法呼叫的頁面對映。TPdfOutlineEditorApplyPageMap 與經驗證的增量寫入器,從 v3.98.0 起隨 PDFiumPas 出貨,適用於 Delphi、C++Builder 與 Lazarus;您可以在 PDFium Delphi 元件產品頁檢視完整 API 並下載試用版