技術文章

為什麼無作用的 PDF 存檔也會破壞 Info 與 XMP 中繼資料

PDF Library for Delphi v3.539.18 與 v3.539.20 修掉兩種「什麼都沒改的 PDF 存檔仍會破壞文件中繼資料」的路徑:當 /CreationDate 與 /ModDate 參照同一個字串物件時,自動更新 ModDate 會把兩者一起改寫;而當 XMP 物件在原始的 /Metadata 串流被讀取之前就被建立時,預設封包會取代原本的那一份。修法改為替換字典參照而不是改動共用物件,並在延遲初始化 XMP 之前先把既有封包取出來

存檔是 PDF 程式庫做過最無聊的操作:載入一個檔案、用新名字存出來、中間什麼都不碰。前後頁面渲染一模一樣。內容串流的雜湊值相符。這個檔案通過了我們手上每一項檢查,而它在兩個任何渲染器都不會顯示給你看的地方仍然是錯的。兩個缺陷都坐在每一次真實編輯都會經過的讀取—修改—寫入路徑上,所以任何一次存檔都足以觸發它們,而它們會被抓到,只是因為第二個獨立剖析器比對了兩個檔案的非視覺語意

為什麼存一次 PDF 會改掉它的 CreationDate

因為文件資訊字典被允許讓兩個鍵參照同一個間接字串物件,而程式庫更新的是那個物件,不是那個鍵。ISO 32000-1 §7.3.10 允許任何字典值可以是間接參照,而 §14.3.3 Table 317 也沒有任何一句說 /CreationDate 底下的值必須是與 /ModDate 底下不同的物件。一個在建立時把同一個時間戳寫了兩次的產生器,完全可以合法地把兩個鍵都指向同一個 2728 0 R,而我們本地語料庫裡的一份 CJK 設計文件就正好這麼做了

觸發點是自動修改日期。除非設了 UserModDate,SaveToFile 會在寫入前用目前時間呼叫 SetInfo('ModDate', ...),這條路會走到 SetRawInfo。舊的 SetRawInfo 會依鍵查出物件,如果查到一個 TPDFString,就對它呼叫 SetTo。那是一次就地寫入,寫進那個鍵當下解析到的任何物件,而當那個物件是共用的,/CreationDate 這下也回報存檔時間了。文件仍然照常開啟、列印,逐像素與先前一樣地渲染,所以視覺回歸測試套件連眼睛都不眨就過了

PDFlibPas 中共用 Info 字串的改動:/CreationDate 與 /ModDate 合法地參照同一個字串物件 2728 0 R,舊的 SetRawInfo 對鍵解析到的任何物件呼叫 SetTo,把兩個日期一起改寫成存檔時間;新的 SetRawInfo 則在該鍵底下加入一個全新的字串,同時保留十六進位字串模式
更新字典項目現在是替換該項目的參照,而不是改動共用物件,所以一次自動 ModDate 寫入再也不會動到 CreationDate,而被取代的物件仍保留給其他參照使用
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate,8 = ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

TPDFDocument.SetRawInfo 的修法很小,背後的原則卻很通用:更新字典項目就是替換該項目的參照,絕不改動它剛好解析到的那個物件。新程式碼會讀出既有的 TPDFStringMode,讓十六進位字串維持十六進位、字面字串維持字面,然後用 FStructure.NewString(Value, StringMode) 在該鍵底下加入一個全新的字串。另外兩個細節跟這個主要改動一樣重要。舊分支對值為串流的項目,會在替換前用 SetTo('') 清空那個串流,這會把還指著同一個串流的其他鍵的值一起清掉,所以那段清空已經移除。而被取代的物件不會被刪除,因為結構擁有它,其他參照可能還需要它

// 之前:改動那個鍵當下解析到的任何物件
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// 之後:保留表示法,只替換這個鍵的參照
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Tests\SharedInfoSemantics.inc 裡的回歸測試刻意自己造出別名關係,而不是靠語料庫檔案:一個十六進位字串同時被兩個日期鍵參照、一個直接字串由 /Title 與 /Subject 共用、一個串流由 /Author 與 /Keywords 共用。更新每一對其中一個鍵之後,另一個必須仍然讀到原本的值,而更新過的字串必須仍然是十六進位。現在 SetInformation 的公開參考文件用一句話把保證講明:更新一個 Info 欄位只替換該欄位,即使其他欄位參照同一個物件也一樣

為什麼既有的 XMP 封包會被預設值取代

因為兩行程式碼的順序。TPDFDocument.GetMetadata 有一條快速路徑:當 XMP 欄位已經被指派,它就回傳 XMP.SaveToString,而不是去解碼目錄裡的 /Metadata 串流。好幾個呼叫點用 XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); 做延遲初始化,讀起來很自然,但是錯的:等 GetMetadata 執行時,XMP 已經被指派了,所以被載入的那個「來源」,其實是一行之前建立之物件的序列化預設封包。原始封包,連同它的 dc:creator、自訂命名空間與任何標準識別,從來沒進到那個物件裡,並且在存檔時被覆蓋。同一個自動修改日期就足以觸發它,因為 SetInfo 會在碰 Info 字典之前先初始化 XMP,好讓 xmp:ModifyDate 與 /ModDate 保持一致。注意這個缺陷躲在什麼後面:第一個 bug 的 Info 字典比對會通過,因為 /Info 裡的 /Author 與 /Title 完全沒被動到。只有 XMP 樹變了,而只有真的去剖析並比對那棵樹的檢查才會發現

PDFlibPas 中 XMP 的延遲初始化順序:在呼叫 GetMetadata 之前先建立 XMP 物件,會讓快速路徑序列化出預設封包,並丟掉 dc:creator、自訂命名空間與標準識別;而在 TPDFlibXMP.Create 之前先取出 Source,才會從目錄載入原始的 /Metadata 串流
任何一次存檔都會觸發調包,因為 SetInfo 會初始化 XMP 以讓 xmp:ModifyDate 與 /ModDate 同步,所以文件中每一處延遲初始化現在都收斂到同一個 EnsureXMP,由它在建立物件之前先取出既有封包
// 錯的:GetMetadata 現在序列化的是上一行才建立的物件
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// 對的:先取出 /Metadata 串流,再建立並載入
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

這個修法做兩件事。TPDFDocument.EnsureXMP 現在會在 TPDFlibXMP.Create 之前先取出 Source := GetMetadata,而文件中每一處延遲初始化都改成呼叫它:SetInfo、SetXMPInformation、GetXMPInformation、PDF/A、PDF/X、PDF/E、PDF/VT、PDF/VCR 與 PDF/UA 的模式設定器,以及中繼資料修復路徑。SetXMPProperty 這類公開入口本來就走 EnsureXMP,而 GetXMPProperty 是經由 GetDocumentMetadata 讀取,所以整個介面共用同一套初始化順序。三段式序列的正確版本留一份,勝過十份今天剛好彼此一致的複製品

同一條路徑上還有兩個較小的陷阱

Windows 上的 XMP 序列化器用的是平台 XML writer,它會產生一段封包不該帶的 XML 宣告。舊程式碼移除它的方式是刪字元,一路刪到 <?xpacket 為止。ISO 16684-1 §7.3.2 讓 xpacket 外層可有可無,而只寫一個裸露 <x:xmpmeta> 元素的產生器並沒有違反標準,所以在這種封包上,那個迴圈把整份有效的文件都刪了。序列化器現在會定位宣告的結尾 ?>,只移除那一段。Tests\XMPRetentionSemantics.inc 會把它的保留檢查跑兩次,一次帶外層、一次把外層切掉,並斷言自訂命名空間標記與原始作者能在 SetInfo、GetMetadata、SaveToString 與重新載入之後存活。第二個陷阱是一個前置處理器符號:SetInfo 裡 Info 對 XMP 的同步被 NOVCL 守著,而它對 Free Pascal 建置是設起的,但 XMP 後端取決於作業系統而不是框架,因為 PDFlibXMP.pas 只在 OS_WINDOWS 不存在時才定義 NO_XMP。所以一個 Windows 上的 Lazarus 建置,會有能用的 XMP 物件,卻有一個默默跳過更新它的 SetInfo。現在守衛換成 NO_XMP,Windows 上的 Free Pascal 應用程式因此得到與 Delphi 相同的同步行為

如何在原樣轉存的存檔中保住原本的 ModDate

在 TPDFlibSaveOptions 裡設定 KeepModDate,並改用 SaveToFileOptions 存檔。這個選項會在呼叫期間設起 UserModDate,SaveToFile 於是跳過自動時間戳,而那也正是延遲初始化 XMP 物件的那一步。一份您從未碰過其中繼資料、也沒有啟用任何合規模式的文件,會讓 Info 字典與 /Metadata 串流都維持載入時的樣子。呼叫 SetInformation(8, ...) 有同樣的效果,而且是永久的,因為自己設定修改日期就等於把它標記為使用者控制

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // 不自動寫 /ModDate,也不做延遲 XMP 初始化
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

對於這能買到什麼,要誠實。KeepModDate 對那種輸出應該描述與輸入相同修訂版本的原樣轉存步驟來說是正確選擇,但對任何真的會編輯內容的操作來說是錯的,因為 §14.3.3 期待 /ModDate 反映最近一次修改。它也不會回過頭修好一個會改動共用物件的程式庫,它只是避開那個把缺陷暴露出來的一次寫入。上面兩個修法才是讓一般存檔變安全的東西,而這個選項是讓刻意的無作用變得誠實的東西

如何驗證一次存檔除 ModDate 之外什麼都沒改

不是用像素,也不是用串流雜湊,因為這兩個缺陷讓每一頁與每一條內容串流都逐位元組不變。抓到它們的檢查,是由一個獨立剖析器,一個與受測程式庫不共用任何程式的剖析器,分別對來源檔案與存檔後檔案取下的非視覺語意快照,然後做結構化比對。快照涵蓋排除 /ModDate 之後的 Info 字典、把每個書籤解析成頁碼而不是物件編號的大綱樹、以同樣方式解析的具名目的錨點與連結目標、表單欄位值、以雜湊表示的附件位元組,以及當成樹來剖析而不是當文字來比對的 XMP 封包。物件編號刻意不在其中,因為完整重寫會重新編號所有東西,而以它們為鍵的比對只會回報雜訊

PDFlibPas 存檔的非視覺語意驗證:一個不共用任何程式的獨立剖析器快照不含 /ModDate 的 Info 字典、大綱與目的錨點的頁碼、表單值、附件雜湊與 XMP 樹,再比對來源與存檔後檔案,並把 /ModDate、xmp:ModifyDate 與 xmp:MetadataDate 當成預期變動剔除
兩個缺陷之下像素與串流雜湊都逐位元組一致,所以比對工作在解析後的語意而不是物件編號上,而存活的其中繼資料會誠實地回報為保留,而不是回報為符合 schema 或符合 PDF/UA 與 PDF/A

被排除的項目與被納入的項目一樣重要。/ModDate、xmp:ModifyDate 與 xmp:MetadataDate 本來就預期會變,比對前先剔除,而來源完全沒有 XMP 的檔案,不會因為多出一個封包而被扣分。這個檢查沒有宣稱的事也一樣明確:留住既有封包,並不代表那個封包符合 schema,也不代表文件滿足 PDF/UA 或任何 PDF/A 部分。那些是另外的問題、要用另外的工具,而把「中繼資料活下來了」與「中繼資料是合規的」混為一談,正是第一個 bug 能躲那麼久的原因。在程式庫這邊,這兩個回歸測試現在會在 Delphi Win32 與 Win64、Free Pascal Win32 與 Win64 的每一次目標化測試中執行,而語意比對是實際文件語料庫基準測試的通過條件

如果您想往下挖到這些修法的下一層,存檔如何改寫物件的機制記在增量更新與附加式存檔,那是唯一會把共用物件就留在原地的存檔模式;另一處在修改層級與修訂差異比對,那是過期或被改寫的日期會誤導讀取者的另一個地方。同一組 Info 與 XMP 在修復端的視角,也就是不只要保住、還要讓兩半彼此一致,記在轉換為 PDF/A 與修復中繼資料

PDF Library for Delphi 是給 Delphi、C++Builder 與 Lazarus 的原生 Pascal PDF 程式庫,而這裡描述的讀取—修改—寫入路徑,就是您自己行程裡每一次編輯都會走的那一條,所以上面那些保證,無論您一天存一次還是一千次都成立。支援的編譯器與平台請見 PDF Library for Delphi 產品頁