技術文章

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

PDFium Component 用 TPdf.InspectPdfAMetadata 檢查 PDF/A 的 Info 對 XMP 中繼資料等價性,用 TPdf.NormalizePdfAMetadata 修復它。ISO 19005-1(經 Cor.1 修正)要求八個映射的 Info 項目——從 Title 到 ModDate——每一項都攜帶與其 XMP 屬性相同的值,而不只是存在;這個檢查讀的是正確的 RDF 形狀、按 URI 比對命名空間、把日期當時刻比較

通常開啟這個話題的 bug 回報看起來無害。文件管理系統在每次增量存檔時把新的 /ModDate 蓋進 Info 字典,XMP packet 原封不動,六個月後歸檔稽核把成千上萬個檔案標成不符合規範。兩個日期都在。只是從第一次編輯起它們就不再一致,而存在檢查從來沒發現。透過只寫 Info 的 API 做的標題編輯,以及被某些工具拆成兩個 dc:creator 項目的 Finance; Controlling 這種 Author 字串,都以同樣方式失敗

PDF/A 為什麼拒收兩邊都存在的中繼資料?

PDF/A 拒收它,是因為 ISO 19005-1 §6.7.3 是一條值規則,不是存在規則:Table 1 把八個 Info 鍵映射到 XMP 屬性,而一旦某個 Info 鍵存在,被映射的 XMP 屬性就必須持有等價的值。PDFium Component 的 PDF/A 預檢驗證所述的位元組層級掃描器只確認 xmp:CreateDate 與 xmp:ModifyDate 存在(pvaiMissingXmpDates)。從 v3.72.0 起,TPdf.ValidatePdfA 額外跑完整的值比對,當 XMP packet 存在但與 Info 不一致(解析不了的 packet 也算不一致)時,把 pvaiInfoXmpValueMismatch 加進問題集。缺 packet 仍然回報為 pvaiMissingXmpMetadata,所以兩個問題不會對同一個缺陷重複計數

每個映射的 XMP 屬性需要什麼 RDF 形狀?

八個映射各自有固定的 XMP 型別,而放錯容器的正確值照樣失敗。FPdfPdfa.pas 裡的 ComparePdfAInfoAndXmp 按命名空間 URI 查找屬性,所以把 http://purl.org/dc/elements/1.1/ 綁到奇怪前綴的 packet,讀起來與用 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 形狀裡持有等價值,所以放錯容器的值照樣失敗
<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 加組合符號,都算貨真價實的不一致。Info 側永遠來自 PDFium 透過 FPDF_GetMetaText 對 PDFDocEncoding 與 UTF-16 文字的解碼,這樣函式庫就不用自己重寫字串解碼、然後錯得微妙;XMP 側則只跟產生它的位元組一樣乾淨,這正是Free Pascal 下腐蝕 XMP 中繼資料的字碼頁陷阱在這裡也相關的原因

PDF 日期與 XMP 日期什麼時候相等?

PDF 日期與 XMP 日期相等,是當它們以秒為單位描述同一個時刻、且兩邊帶著相同的時區認知時。兩個剖析器都接受合法的縮減精度,所以 D:2026 與 2026 都代表 2026 年 1 月 1 日 00:00:00。兩邊都帶時區時,先轉成 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、非零小數秒無從表達、解析不了的值分別回報
相等意味著以秒為單位的同一時刻加上兩邊相同的時區認知,所以憑空發明位移或把小數秒捨掉都會掩蓋真實的分歧

存在有自己的規則。TPdfAMetadataValues.Present 是一個集合,靠走訪作用中 trailer 的 /Info 字典填入,它把「鍵不存在」與「鍵存在但字串為空」分開。缺席的鍵得到 pamsNotRequired、對 XMP 什麼也不要求;/Title () 是存在的,所以 XMP packet 必須也帶一個空的 x-default 標題

存檔之前怎麼檢視 Info 與 XMP 中繼資料?

TPdf.InspectPdfAMetadata 回傳一個 TPdfAMetadataReport,每個欄位一個 TPdfAMetadataComparison,各自裝著 Info 值、XMP 值與一個 TPdfAMetadataState,所以失敗可以直接解釋,不必逆向工程某個驗證旗標。MismatchFields 彙總失敗集合,HasXmpPacket 告訴您有沒有找到 packet,XmpParseError 在 packet 存在但讀不了時帶著剖析器訊息

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;

NormalizePdfAMetadata 改什麼,又拒絕什麼?

TPdf.NormalizePdfAMetadata 把 Info 字典當成事實來源,只重寫落到 MismatchFields 的欄位對應的 XMP 屬性;packet 裡其他一切原樣存活。Title 與 Subject 寫進 x-default 項目、其他語言替代保持原樣,Author 變成單一項目的 rdf:Seq,未知命名空間與無關屬性被保留,缺席 Info 鍵的 XMP 屬性原封不動。帶時區的 Info 日期寫成帶 Z 後綴的標準 UTC XMP 日期;無時區的保留本地分量。檔案多載經由暫存檔與原子式替換存檔,XMP 更新本身則以增量更新附加

用 PDFium Component 在 Delphi 裡修復 PDF/A 中繼資料時 NormalizePdfAMetadata 重寫的範圍:Info 是事實來源,只有 MismatchFields 項目被寫回為 x-default 的 Alt 文字、單一項目的 Seq 或標準 UTC 日期,而未知命名空間、無關屬性與缺席鍵的屬性原樣存活
修復拒絕缺失的 XMP packet、畸形的 Info 日期與已簽署文件,因為建立完整的 PDF/A 中繼資料集合是 SaveAsPdfA 的工作,不是針對性等價修復的工作

拒絕是刻意的。沒有 XMP packet 時方法丟 EPdfError,因為建立完整的 PDF/A 識別與中繼資料集合是 SaveAsPdfA 的工作,見用 PDFium Component 產生 PDF/A 歸檔檔案。畸形的 Info 日期丟 EPdfXmpError,而不是寫下一個看起來像樣的錯值,而且什麼都不存。簽署文件被拒,除非呼叫端傳 AllowSignedDocument = True。等價性也只是 ISO 19005-1 的其中一條規則,所以正規化過的檔案不自動就是合規檔案

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 日期或讀不了的 packet
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

對您自己的 XMP packet 跑比較

ComparePdfAInfoAndXmp 與 SynchronizePdfAInfoToXmp 是 FPdfPdfa 裡的普通函式,作用於未載入文件的 TPdfXmpPacket,適合單元測試與從範本組裝 XMP 的管線。唯一的陷阱是 Present:用 Default(TPdfAMetadataValues) 初始化的記錄集合為空,每個欄位都回報 pamsNotRequired,不管您填了什麼值,比較都會空洞地通過

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;

如果您的管線要歸檔別的系統持續編輯的文件,就把每晚一次的 InspectPdfAMetadata 巡掃與 NormalizePdfAMetadata 配成一對處理漂移的檔案,並把 ValidatePdfA 留作任何東西送往長期儲存之前的關卡。型別化的報告、修復路徑與其餘 PDF/A 工具都隨 PDFium Component for Delphi and C++Builder 出貨