Technical Article

在不重寫的情況下編輯 Delphi 中已載入的 PDF 中繼資料

您有一萬份來自十幾種不同產生器的合約 PDF,而法務部門希望每一份都帶有正確的 Author、修正後的 Producer 字串,以及在開啟時會顯示書籤面板的閱讀模式。最單純的修正方式是載入每個檔案,重新佈局頁面,並寫入一份全新的文件。但這麼做,您等於丟棄了所有現有的物件編號、增量更新 (incremental-update) 歷史、任何數位簽章,以及原始工具所產生的經過仔細調校的 xref。頁面看起來一樣,但在結構上,這份檔案已變得完全陌生。對於中繼資料的編輯來說,這完全是個錯誤的交易。

正確的做法是將已載入的文件視為可以在原地修改的物件圖:深入 Info 字典、/Metadata 串流以及 Catalog,更改您關心的少數幾個項目,然後將結果寫回。HotPDF 是 Delphi 與 C++Builder 原生的 VCL PDF 元件,透過其 loaded-document write API 準確地提供了這樣的操作介面。本文將探討如何正確地使用它,以及幾乎每個人都會犯的一個錯誤:編輯了 Info 字典,卻忘記了同一份中繼資料的第二個副本存在於 XMP 中。

有兩個地方儲存了相同的中繼資料,且它們互不一致

PDF 在兩個平行的位置承載文件資訊,這也是大多數「我更改了標題,但 Acrobat 仍然顯示舊標題」問題的根源。第一個是文件資訊字典 (document information dictionary),即經典的 /Info 物件,包含 /Title/Author/Subject/Keywords/Creator/Producer 鍵,由 ISO 32000-1 §14.3.3 所定義。第二個是 XMP 封包 (packet),這是一份 XML 文件,作為串流附加在 Catalog 下的 /Metadata 中,由 §14.3.2 定義,並建構於 Adobe XMP 資料模型之上。

兩者都可以保存標題。規範中沒有任何內容強制它們必須一致。現代的檢視器與大多數 PDF/A 驗證器在 XMP 封包存在時偏好使用它,並在不存在時退回使用 Info 字典。因此,如果您只更新 /Info——這是絕大多數「設定 PDF 中繼資料」程式碼的做法——信任 XMP 的閱讀器將繼續顯示過時的值,而 PDF/A 檢查工具將會標記不一致。對任何已經具有 XMP 封包的檔案,正確的操作是雙重寫入 (double write):更改 Info 條目並且重新產生 XMP,以保持兩者的一致性。HotPDF 為您提供了這兩半功能;將它們一起使用的紀律則在於您。

編輯 Info 字典

Info 端的輔助函式單純且可預期。SetLoadedTitleSetLoadedAuthorSetLoadedSubjectSetLoadedKeywordsSetLoadedCreatorSetLoadedProducer 均接收一個 AnsiString,並將對應的鍵寫入已載入的 Info 字典中——若該鍵存在則取代其值,若不存在則新增。要完全移除一個鍵——例如洩漏您內部工具名稱的 /Creator——請使用純鍵名呼叫 RemoveLoadedInfoKey。這些全都不會觸及 XMP;它們純粹在 LoadFromFile 解析檔案時定位到的 /Info 物件上運作。

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
    begin
      Pdf.SetLoadedTitle('Master Services Agreement 2026');
      Pdf.SetLoadedAuthor('Legal Department');
      Pdf.SetLoadedSubject('Executed contract, retention 7 years');
      Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
      Pdf.SetLoadedProducer('Acme Document Pipeline');
      Pdf.RemoveLoadedInfoKey('Creator');  // 移除來源工具名稱
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

一個必須誠實面對的細節:這些函式接收的是 AnsiString。對於 ASCII 標題這不是問題,但需要非拉丁字元的 PDF 文字字串必須按照規範的要求——帶有位元組順序記號 (BOM) 的 UTF-16BE 或 PDFDocEncoding——在傳遞給它們之前進行編碼。函式庫會將您給它的位元組寫入字串物件中;它不會為您猜測編碼。如果您的標題是純英文,請忽略這一點。如果標題帶有重音或中日韓 (CJK) 字元,請刻意進行編碼,並在真實的檢視器中測試。

重寫 XMP 封包

SetLoadedXMPMetadata 是雙重寫入的另一半。將完整的 XMP 封包作為 AnsiString 傳遞給它,它會執行以下兩件事之一:如果 Catalog 已經參照了一個 /Metadata 串流,它會在原地取代該串流的內容,並保持相同的物件編號;如果沒有中繼資料串流,它會建立一個,將其標記為 /Type /Metadata/Subtype /XML,分配一個物件編號,並從 Catalog 連結它。無論哪種方式,您最終都會得到一個檢視器可讀取的有效中繼資料物件。

您提供 XML,這意味著您控制了結構描述 (schema)——dc:titledc:creatorxmp:CreatorTool 等等。這既是能力也是責任:函式庫不會解析或驗證您的封包,它會未經壓縮地寫入位元組,不套用任何串流過濾器。格式錯誤的封包會順利通過呼叫,並在日後浮現為中繼資料損壞的抱怨。請仔細建構 XML,並精確地反映您寫入 Info 字典的值,確保這兩種視圖永遠不會互相矛盾。

const
  XMP_TEMPLATE =
    '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>' +
    '<x:xmpmeta xmlns:x="adobe:ns:meta/">' +
    '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
    '<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
    '<dc:title><rdf:Alt><rdf:li xml:lang="x-default">%s</rdf:li></rdf:Alt></dc:title>' +
    '<dc:creator><rdf:Seq><rdf:li>%s</rdf:li></rdf:Seq></dc:creator>' +
    '</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
begin
  // 設定 Info 字典後,將相同的值反映到 XMP 中:
  Pdf.SetLoadedTitle('Master Services Agreement 2026');
  Pdf.SetLoadedAuthor('Legal Department');
  Pdf.SetLoadedXMPMetadata(
    AnsiString(Format(XMP_TEMPLATE,
      ['Master Services Agreement 2026', 'Legal Department'])));
  Pdf.SaveLoadedDocument('contract-out.pdf');
end;

這個順序——先 Info,再 XMP,然後儲存——是必須內化的模式。這兩個呼叫是獨立的;一致性之所以存在,僅因為您提供給它們相同的字串。如果在已有 XMP 封包的檔案上略過 XMP 呼叫,您又會回到整個小節都在試圖預防的靜默過時 (silent-staleness) 錯誤。

展示 PDF Info 字典與 XMP 中繼資料串流同時保存標題與作者的圖解,與書籤大綱樹一起原地編輯
中繼資料存在於兩個地方——Info 字典與 XMP 串流中——加上 Catalog 層級的閱讀提示與大綱樹。原地編輯會觸及每一個部分而無需重建文件。

引導檢視器如何開啟檔案

三個 Catalog 條目決定了閱讀器在開啟文件瞬間所看到的內容,而這三個在已載入的圖上都只需要單行編輯。SetLoadedPageMode/PageMode 寫入為一個名稱物件 (name object):傳入 'UseOutlines' 來彈出書籤面板、'UseThumbs' 顯示縮圖欄、'FullScreen' 進入簡報模式,或者 'UseAttachments' 顯示附件窗格 (ISO 32000-1 §7.7.3.1,表 28)。SetLoadedPageLayout 以相同方式寫入 /PageLayout——'SinglePage''OneColumn''TwoColumnLeft' 等等。兩者都接收不帶前導斜線的名稱;函式庫會在輸出時加上。

SetLoadedLanguage 寫入 Catalog /Lang 條目,這是針對整個文件的自然語言標籤——'en-US''de-DE',一個 BCP 47 標籤。請注意常讓人絆倒的類型差異:/PageMode/PageLayout 是 PDF 名稱物件,而 /Lang 是一個字串。HotPDF 在內部正確地處理了這一點,但如果您曾檢查過輸出,您會看到 /PageMode /UseOutlines 對上 /Lang (en-US),現在您知道原因了。/Lang 條目比看起來更重要:輔助技術 (assistive technology) 依賴它來選擇發音,而且它是 PDF/UA 無障礙合規性的硬性要求。

if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
  Pdf.SetLoadedPageMode('UseOutlines');     // /PageMode,名稱
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout,名稱
  Pdf.SetLoadedLanguage('en-US');           // /Lang,字串
  Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;

不擾亂樹狀結構的情況下重新命名書籤

書籤標題是例行的清理工作——標題裡的錯字,或是在大綱建立後章節被重新編號。SetLoadedOutlineTitle 接收一個指向最頂層大綱條目的以零起始的索引,以及一個新標題,沿著 Catalog → /Outlines/First/Next 鏈條走到該位置,並取代該條目的 /Title 字串。它只更改標題;目標位置 (destination)、展開/摺疊狀態以及子結構均保持原樣。

if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
  Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
  Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
  Pdf.SaveLoadedDocument('report-renamed.pdf');
end;

重新命名之所以安全,正是因為它從未觸及結構計數器。刪除大綱條目才是會咬人的情況,即使您只是在重新命名,也值得去了解,因為它告訴您不要手動編輯什麼。每一個大綱節點都帶有一個 /Count,而根據 ISO 32000-1 §12.3.3 的規定,這個計數並不是直接子節點的數量。它是可見後代節點的總數:正值的 /Count 為 N,表示目前有 N 個後代節點是展開的;而負值則表示該節點有後代,但是是摺疊的。當一個頂層條目被移除時,/Outlines 根計數不能簡單地減一;它必須透過對每個留存的頂層節點求和來重新計算,公式是「節點本身算一,加上其正值的 /Count」,並略過任何已摺疊 (負值計數) 節點的後代。一旦做錯,閱讀器顯示的書籤總數就會偏移——每次刪除跳躍的幅度會大於一。重新命名避開了所有這些問題,這也是偏好使用目標輔助函式而非自己去戳弄字典的另一個原因。

儲存如何保持原地進行

上述每個編輯都會修改記憶體中的物件;在執行 SaveLoadedDocument 之前,任何內容都不會寫入磁碟。這種方法之所以廉價,是因為儲存操作並未重新產生文件——它保留了現有的物件編號以及 HotPDF 在載入時解析出的結構,僅寫回同一個圖以及您少量更改與新分配的物件。這就是讓中繼資料修改免於重寫整個檔案的原因,這也是讓物件串流與增量更新得以運作的同一套原地更新機制。如果您的來源檔案來自 Word 或其他辦公套件,它們的物件佈局有其自身的怪癖,在編輯前值得去了解;關於 Office PDF 中的混合參照交互參照串流的文章涵蓋了這些檔案的結構以及什麼能在往返操作中存活下來。

有兩個邊界需要遵守。首先,這是一個原地編輯模型,而不是遮蔽 (redaction) 或淨化工具:移除一個 Info 鍵確實移除了該鍵,但它不會清除可能留存於同一份檔案先前增量更新世代 (generation) 中的舊值。如果您的需求是真正移除敏感的中繼資料,那將是一個不同且更繁重的操作。其次,XMP 寫入是字面上的 (literal)——函式庫信任您的 XML 且不會驗證它——因此對於任何指定給 PDF/A 或嚴格驗證器的內容,請從已知的良好範本產生封包並驗證輸出。在這些界線內使用,原地中繼資料編輯就是大小適中的工具:它修復了少數錯誤的位元組,並將佔檔案百分之九十九已經正確的部分,維持原產生器寫入時的原貌。

此處展示的 loaded-document write API 隨附於標準的 Delphi 與 C++Builder 版 HotPDF Component 中,同時也提供了完整的中繼資料、大綱與 Catalog 編輯方法。