技術文章

PDFium Component 檢查 PDF/A 中繼資料等價

Info 字典寫著 /ModDate (D:20260827093659+08'00')、XMP 封包寫著 2026-08-27T01:36:59Z 的 xmp:ModifyDate,兩者描述同一秒,PDF/A 驗證器沒有理由不接受這一對。但把文件的 Subject 字串複製進 dc:subject,就算文字逐字相同,封包照樣不合格。ISO 19005-1 §6.7.3 要求的是等價的值,放在特定的 XMP 屬性裡、擺成特定的 RDF 形狀

通常用來開場的錯誤報告看起來無害。文件管理系統每次漸進式存檔都往 Info 字典蓋一個新的 /ModDate,XMP 封包原封不動。幾個月後檔案稽核標記出上千個檔案。兩個日期都在;它們從第一次編輯起就不再一致,而只檢查屬性存不存在的檢查根本看不見。走 Info-only API 改標題的失敗方式如出一轍,Finance; Controlling 這種被某個工具拆成兩個 dc:creator 項目的 Author 字串也一樣

ISO 19005-1 對 Info 與 XMP 的要求

ISO 19005-1 表 1 把八個 Info 鍵對到 XMP 屬性,§6.7.3 說:Info 鍵一旦存在,映射的 XMP 屬性就必須帶等價的值。鍵缺席則無所求。PDF/A-2 與 PDF/A-3 對同樣八個鍵維持相同的等價要求。PDF/A-4 反其道而行,直接約束 Info 字典本身:它只能與 catalog 的 /PieceInfo 並存,而且只能放 ModDate;TPdf.ValidatePdfA 透過 pvaiPdfA4InfoWithoutPieceInfo 與 pvaiPdfA4InfoInvalid 回報這種情況

在 PDFium Component 裡,這個比較住在 FPdfPdfa.pas 的 ComparePdfAInfoAndXmp。每個屬性都按命名空間 URI 查找,所以把 http://purl.org/dc/elements/1.1/ 繫到少見前綴的封包,讀起來與用 dc 的完全一樣。映射的形狀如下:

  • Title → dc:title、Subject → dc:description:一個 rdf:Alt 語言替代陣列,只跟它的 x-default 項比較;語言標籤不分大小寫,沒有 x-default 的 Alt 視同缺失
  • Author → dc:creator:一個只有單一文字項的 rdf:Seq,該項裝著完整的 Info 字串,分號隔開的作者清單因此維持單一條目
  • Keywords → pdf:Keywords、Producer → pdf:Producer(命名空間 http://ns.adobe.com/pdf/1.3/):簡單文字屬性
  • Creator → xmp:CreatorTool、CreationDate → xmp:CreateDate、ModDate → xmp:ModifyDate(命名空間 http://ns.adobe.com/xap/1.0/):簡單文字屬性
ComparePdfAInfoAndXmp 在 Delphi 裡為 PDF/A 中繼資料等價性檢查的八對映射:Title 與 Subject 需要 x-default 項目的 rdf:Alt、Author 需要單一項目的 rdf:Seq,Keywords、Producer、Creator 與兩個日期是簡單文字,全部按 XMP 命名空間 URI 而非前綴查找
一旦 Info 鍵存在,ISO 19005-1 就要求被映射的 XMP 屬性在指定的 RDF 形狀裡持有等價值,所以放錯容器的值照樣失敗

Subject 這一列是手寫範本最容易翻車的地方。Dublin Core 的 dc:subject 是無序的關鍵字袋子,名字又誘惑人把 Info 的 Subject 丟進去。比較要找的是 dc:description,找不到,於是把 Subject 回報成 pamsXmpMissing,而「正確」的文字就停在旁邊一個屬性之外。正確形狀的三行長這樣:

<dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report 2026</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>Finance; Controlling</rdf:li></rdf:Seq></dc:creator>
<pdf:Producer>PDFium Component</pdf:Producer>

字串明明正確,為什麼還是過不了比較?

正確的字串放錯容器、或差了一個碼位,照樣失敗。文字值按精確的 Unicode 碼位序列比較,不修剪、不折疊大小寫、不做正規化。尾巴一個空格就是不符。預組的 é 對上分解的 e 加結合重音符也是不符,儘管兩者在螢幕上通常長得一模一樣

形狀錯誤有專屬狀態。寫成純文字的 dc:title、宣告成 rdf:Bag 的 dc:creator、帶兩個項目的 dc:creator Seq,都產出 pamsXmpTypeMismatch 而不是 pamsValueMismatch,報告因此分得清「工具選錯 RDF 結構」與「工具寫了不同的文字」。這兩種問題在管線裡通常出自不同的地方,修錯邊要白白賠上一個下午

Info 側永遠來自 PDFium 自己對 PDFDocEncoding 與 UTF-16BE 字串的解碼,走 FPDF_GetMetaText,與 TPdf.GetMetadata 同一條路,所以帶位元組順序記號或八進位跳脫序列的字串,讀起來就是引擎讀到的樣子。XMP 側則只跟產生它的位元組一樣乾淨。用錯字碼頁拼出來的封包可以帶著任何比較都無法原諒的亂碼,這正是Free Pascal 下損毀 XMP 中繼資料的字碼頁陷阱要列在同一份檢查清單上的原因

PDF 日期與 XMP 日期何時算相等?

PDF 日期與 XMP 日期相等,指的是描述到「秒」的同一時刻,且兩邊的時區認知一致。兩個剖析器都接受合法的縮寫精度,D:2026 與 2026 都代表 2026 年 1 月 1 日的午夜。兩邊都帶時區時,各自先轉成 UTC:D:20260827093659+08'00' 等於 2026-08-27T01:36:59Z。兩邊都沒有時區時,本地時間各分量按原樣比較

只有一邊帶時區時,結果是 pamsValueMismatch。替無時區的那邊發明一個偏移是猜測,而恰好猜中伺服器時區的猜測,會在這台機器通過、在另一台失敗。XMP 裡 .250 這種非零小數秒也強制不符,因為 PDF 日期無從表達;.000 則被接受。完全解析不了的值分別回報為 pamsInvalidInfoDate 或 pamsInvalidXmpDate

PDFium Component 在 Delphi 裡如何判定 PDF Info 日期與 XMP 日期相等:兩個時區轉成 UTC 比較時刻、兩個無時區值就地比較、單邊時區是 pamsValueMismatch、非零小數秒無從表達、解析不了的值分別回報
相等意味著以秒為單位的同一時刻加上兩邊相同的時區認知,所以憑空發明位移或把小數秒捨掉都會掩蓋真實的分歧

兩套日期文法不能互換,剖析器也各管各的。PDF 側,偏移分鐘外的撇號可有可無,Z 也接受。XMP 側的偏移需要冒號:2026-08-27T09:36:59+08:00 解析得動,+0800 則是 pamsInvalidXmpDate。XMP 時間一出現 T 就至少要有時與分,超過 14 小時的偏移兩側都拒收。日期解析前會先修剪周圍空白——這是文字欄位享受不到的禮遇

存在與否另有一條規則。TPdfAMetadataValues.Present 是一個集合,走訪作用中 trailer 的 /Info 字典來填,它把「鍵缺席」與「鍵存在但字串為空」分得清楚。缺席的鍵得到 pamsNotRequired。/Title () 算存在,封包因此也必須帶一個空的 x-default 標題

動檔之前,先讀逐欄位的報告

TPdf.InspectPdfAMetadata 回傳一個 TPdfAMetadataReport,每個欄位一條 TPdfAMetadataComparison。每條記錄帶著 Info 值、XMP 值與一個 TPdfAMetadataState,失敗因此能逐欄位解釋。MismatchFields 收集不合格的集合,HasXmpPacket 說明找沒找到封包,XmpParseError 在封包存在卻讀不了時帶著剖析器訊息。最後那種情況下,每個存在的欄位都回報 pamsXmpMissing,因為封包裡什麼都讀不出來

uses
  System.SysUtils, PDFium, FPdfPdfa;

const
  FieldNames: array[TPdfAMetadataField] of string = (
    'Title', 'Author', 'Subject', 'Keywords',
    'Creator', 'Producer', 'CreationDate', 'ModDate');
  StateNames: array[TPdfAMetadataState] of string = (
    'not required', 'equivalent', 'XMP missing', 'XMP type mismatch',
    'value mismatch', 'invalid Info date', 'invalid XMP date');

procedure ReportMetadata(Pdf: TPdf);
var
  Report: TPdfAMetadataReport;
  Item: TPdfAMetadataComparison;
begin
  Report := Pdf.InspectPdfAMetadata;
  if Report.XmpParseError <> '' then
    Writeln('XMP packet unreadable: ', Report.XmpParseError)
  else if not Report.HasXmpPacket then
    Writeln('No XMP packet at all');
  for Item in Report.Comparisons do
    if not Item.IsEquivalent then
      Writeln(Format('%-12s %-18s Info="%s" XMP="%s"',
        [FieldNames[Item.Field], StateNames[Item.State],
         Item.InfoValue, Item.XmpValue]));
end;

TPdf.ValidatePdfA 把同一套比較折進它的問題清單。PDFium Component 的 PDF/A 預檢驗證一文名下的位元組級掃描,在封包缺兩個日期屬性時回報 pvaiMissingXmpDates,在 Info 帶 CreationDate 或 ModDate 而封包沒有對應日期屬性時回報 pvaiInfoXmpDateMismatch。在此之上,ValidatePdfA 跑完整的值比較,只要封包存在卻與 Info 不一致——讀不了的封包也算——就加上 pvaiInfoXmpValueMismatch。完全沒有封包的文件只回報 pvaiMissingXmpMetadata,一個缺陷不會出現兩次。串流層級的 ValidatePdfACompliance 函式只做位元組級掃描

function MetadataGateOk(Pdf: TPdf): Boolean;
var
  Res: TPdfAValidationResult;
begin
  Res := Pdf.ValidatePdfA;
  if pvaiMissingXmpMetadata in Res.Issues then
    Writeln('No usable XMP packet: rebuild the file with SaveAsPdfA');
  if pvaiInfoXmpDateMismatch in Res.Issues then
    Writeln('Info has a date property the XMP packet lacks entirely');
  if pvaiInfoXmpValueMismatch in Res.Issues then
    Writeln('Mapped values disagree: run InspectPdfAMetadata for details');
  Result := Res.IsCompliant;
end;

NormalizePdfAMetadata 重寫什麼、在哪裡停手

TPdf.NormalizePdfAMetadata 把 Info 字典當唯一事實來源,只重寫落在 MismatchFields 裡的欄位對應的 XMP 屬性。Title 與 Subject 進 x-default 項,其他語言替代原樣不動。Author 變成單一項目的 rdf:Seq。帶時區的 Info 日期寫成以 Z 結尾的標準 UTC XMP 日期,無時區的保留本地分量。未知命名空間、無關屬性、缺席 Info 鍵的 XMP 屬性都不碰;屬性寫回時沿用既有的前綴繫結

PDFium Component 在 Delphi 修復 PDF/A 中繼資料時 NormalizePdfAMetadata 的重寫範圍:Info 是唯一事實來源,只有 MismatchFields 裡的條目會以 x-default Alt 文字、單項 Seq 或標準 UTC 日期寫回,未知命名空間、無關屬性與缺席鍵的屬性原樣存活
修復拒絕處理缺少 XMP 封包、Info 日期畸形,以及預設下的簽章文件,因為建出完整的 PDF/A 中繼資料集合是 SaveAsPdfA 的職責,不是針對性等價修正的工作

讓 Info 說了算,在真實檔案上是有代價的。把「Finance」與「Controlling」列成兩個人的 dc:creator Seq,會被換成讀作 Finance; Controlling 的單一項目;放錯容器的 dc:title 會重建為只含 x-default 的新 Alt,舊容器裡的東西就此消失。在這裡,這種損失可以接受:表 1 本來就只允許一個 creator 項,Info 字串已經把完整清單整塊握在手裡,真需要更豐富的清單,原始封包還在來源檔裡

拒絕出手是刻意的。沒有 XMP 封包時,方法丟 EPdfError:建出完整的 PDF/A 識別與中繼資料集合是 SaveAsPdfA 的職責,見用 PDFium Component 產生 PDF/A 典藏檔。讀不了的封包或畸形的 Info 日期會在寫下任何東西之前丟 EPdfXmpError,一個看似合理的錯日期寫進 XMP,比沒有輸出更糟。簽章文件丟 EPdfError,除非呼叫端傳 AllowSignedDocument = True;輸出是一次完整存檔、新中繼資料以漸進式更新附加,簽章位元組並非原樣延續

那條存檔路徑還有一個後果。就算每個欄位都已一致,NormalizePdfAMetadata 也會寫出新檔案,因為就算什麼都沒得同步,收尾仍然是一次存檔。先呼叫 InspectPdfAMetadata、跳過已等價的檔案;夜間工作把幾千個沒變動的典藏重寫一遍,換到的只有新的檔案時間戳記。檔案版本的重載經由暫存檔寫入、再原子式取代目標。文件載入期間來源檔一直開著,輸出請給不同的路徑

uses
  System.SysUtils, PDFium, FPdfPdfa, FPdfXmp;

procedure NormalizeArchive(const Source, Target: string);
var
  Pdf: TPdf;
  Report: TPdfAMetadataReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    Report := Pdf.InspectPdfAMetadata;
    if Report.IsEquivalent then
      Exit;                      // 已經一致,檔案不用動
    if not Report.HasXmpPacket then
      raise Exception.Create('No XMP packet: convert with SaveAsPdfA instead');
    try
      if not Pdf.NormalizePdfAMetadata(Target) then
        raise Exception.Create('Normalized save failed');
    except
      on E: EPdfXmpError do      // 畸形的 Info 日期或讀不了的封包
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
      on E: EPdfError do         // 未帶 AllowSignedDocument 的簽章輸入
        raise Exception.CreateFmt('Skipped %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

等價只是 ISO 19005-1 的其中一條。正規化過的檔案照樣得過字型、色彩、透明度與其餘每一項 PDF/A 檢查,文件進長期典藏之前,ValidatePdfA 仍是最後那道閘

不載入文件也比較封包

ComparePdfAInfoAndXmp 與 SynchronizePdfAInfoToXmp 是 FPdfPdfa 裡的普通函式,作用在 TPdfXmpPacket 上、不必開任何 PDF,單元測試與「文件還不存在就先從範本填 XMP」的管線正合用。SynchronizePdfAInfoToXmp 回傳它重寫的欄位集合,收到 nil 封包則丟 EPdfXmpError

唯一的陷阱是 Present。用 Default(TPdfAMetadataValues) 初始化的記錄集合為空,每個欄位於是都回報 pamsNotRequired,比較無論你填了什麼值都通過。記錄的字串欄位帶著值;決定標準實際要求哪些欄位的只有 Present

uses
  System.SysUtils, System.IOUtils, FPdfPdfa, FPdfXmp;

procedure AlignTemplate(const TemplateFile: string);
var
  Info: TPdfAMetadataValues;
  Packet: TPdfXmpPacket;
  Changed: TPdfAMetadataFields;
begin
  Info := Default(TPdfAMetadataValues);
  Info.Title := 'Quarterly Report 2026';
  Info.Author := 'Finance; Controlling';
  Info.ModDate := 'D:20260827093659+08''00''';
  // Present 決定哪些欄位是必填;光有值不算數
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

  Packet := TPdfXmpPacket.Parse(TFile.ReadAllText(TemplateFile, TEncoding.UTF8));
  try
    Changed := SynchronizePdfAInfoToXmp(Info, Packet);
    // 不一致的 xmp:ModifyDate 會變成 2026-08-27T01:36:59Z,
    // 不一致的 dc:creator 會變成單一項目的 rdf:Seq
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

同一份範本隨後過一遍 ComparePdfAInfoAndXmp,應該得到空的 MismatchFields——對重新產生範本的建置步驟來說,這是個便宜的斷言

被其他系統持續編輯的典藏,一次漂移一個欄位,逐欄位的報告會指出是哪一個動了。InspectPdfAMetadata、NormalizePdfAMetadata 與其餘 PDF/A 驗證、轉換工具都在PDFium Component for Delphi and C++Builder 裡