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/):簡單文字屬性
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
兩套日期文法不能互換,剖析器也各管各的。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 屬性都不碰;屬性寫回時沿用既有的前綴繫結
讓 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 裡