要從 Delphi 把來源檔案附加到 PDF/A-3 文件,PDFium Component 寫出的是 PDF 2.0 的關聯檔案鏈:一條帶 MIME /Subtype 的內嵌檔案串流、一份帶 /AFRelationship 的檔案規格,以及掛在目錄或頁面上的 /AF 陣列。InjectAssociateFiles 與 TPdf.SaveAsWithAssociateFiles 用一次增量更新就把鏈建好,而且 v3.121.2 起 MIME 型別序列化成單一、跳脫正確的 PDF 名稱。本文接下來會談驗證器檢查什麼、弄壞 text/plain 的一字元 bug,以及舊版本默默做了別的事情的幾個地方
PDF/A-3 關聯檔案到底需要什麼?
PDF/A-3 附件要過驗證,三個物件得彼此對得上:內嵌檔案串流宣告 /Type /EmbeddedFile 加上 MIME /Subtype,檔案規格字典(ISO 32000-2 §7.11.3)帶著 /F、/UF、/EF 與 /AFRelationship,而文件裡要有東西透過 /AF 陣列(ISO 32000-2 §14.13)引用那份檔案規格。走 /Names /EmbeddedFiles 名稱樹的普通內嵌——也就是 TPdf.CreateAttachment 做的事——完全不設任何關聯欄位。PDFium Component 自己的 PDF/A-3b 驗證夾具把這份依賴變得具體:只改 /AFRelationship 這個鍵名,檔案就恰好掛在 ISO 19005-3 第 6.8 條的一條規則上;只拿掉 MIME /Subtype,掛的是另一條 6.8 規則;把同一個附件塞進 PDF/A-1b 候選檔,則直接被拒——PDF/A-1 禁止內嵌檔案,metadata 再整齊也一樣
關係值是最容易猜錯的部分。FPdfAssocFiles 裡的 TPdfAFRelationship 把每個列舉成員對應到注入器能寫出的一個名稱記號,其中只有前五個屬於 ISO 19005-3 認可的子集:
afSource→/Source:PDF 的原始出處,例如文書處理檔案或試算表afData→/Data:可見內容所衍生或所代表的機器可讀資料afAlternative→/Alternative、afSupplement→/Supplement、afUnspecified→/UnspecifiedafEncryptedPayload、afFormData、afTemplate:PDF 2.0 新增,落在 PDF/A-3 子集之外,封存輸出請勿使用
/Subtype /text/plain 為什麼弄壞了驗證?
這個 MIME bug 是記號切分錯誤,不是合規缺口:v3.121.2 之前,注入器把呼叫端的字串直接接在斜線後面,做出 /Subtype /text/plain。按 PDF 語法,第二個斜線開啟新的名稱物件(ISO 32000-1 §7.3.5),於是串流字典突然多出 /Subtype 這個鍵、/text 這個名稱,還有一個懸空的 /plain 把鍵值配對弄得失衡。獨立的 PDF/A 驗證器在解析 EmbeddedFile 字典時就把檔案拒了,根本還沒走到任何 PDF/A 規則,所以這個失敗看起來像檔案損毀,而不是少了哪個附件屬性
修正把 MIME 值導過 EscapePdfName,產出 /text#2Fplain:一個解碼後為 text/plain 的名稱。跳脫範圍刻意比斜線更廣。每個不大於 32 的位元組(空格、tab、CR、LF)、每個不小於 127 的位元組、定界符 ()<>[]{}/% 以及跳脫字元 # 本身,一律變成 #XX。只跳脫斜線會留下另一個洞:含 >> 或空白字元的 MIME 字串可能提前關掉字典或塞進多餘的鍵,所以迴歸測試會餵一個帶全部定界符、外加 tab、LF 與 CR 的惡意值,並核對精確的編碼輸出
// MIMEType = 'text/plain' 時注入器寫出的內容
// v3.121.2 之前: /Type /EmbeddedFile /Subtype /text/plain (兩個名稱)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (一個名稱)
//
// 呼叫端永遠傳入普通的 MIME 值。自己先跳脫一次會把 '#' 重複編碼,
// 'text#2Fplain' 就變成 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
用 InjectAssociateFiles 產出 PDF/A-3 檔案
PDF/A-3 輸出的正確流程是:先用 TPdf.SaveAsPdfAToStream 產出合規的基礎文件,再對那份串流呼叫 InjectAssociateFiles——驗證夾具在通過 PDF/A-3b 之前跑的正是這條兩步管線。TPdf.SaveAsWithAssociateFiles 是方便的包裝,但它走的是普通的 SaveAs 路徑並帶 saRemoveSecurity,而不是 PDF/A 寫入器,所以不會補上 PDF/A 要求的 XMP 識別與 output intent。注意記錄型別分居 FPdfAssocFiles 與 FPdfPdfa,兩個單元都得進您的 uses 子句。v3.121.3 起,FileName 與 Description 不必再是純 ASCII:/UF 與 /Desc 寫成 PDF 文字字串,可列印 ASCII 原樣照寫,其餘以帶位元組順序記號的 UTF-16BE 寫出;舊有的 /F 名稱則永遠是可攜的可列印 ASCII,其他字元一律換成 _,於是用自家字碼頁解碼 /F 的讀取器看到的是底線而不是亂碼。更早的建置在 Delphi 上把三者全透過系統 ANSI 字碼頁轉換、在 Free Pascal 上寫出原始 UTF-8 位元組,所以若舊版建置必須產出相同的輸出,名稱就維持純 ASCII
uses
System.SysUtils, System.Classes, System.IOUtils,
PDFium, FPdfPdfa, FPdfAssocFiles;
procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
PdfAOptions: TPdfASaveOptions;
Options: TAssocFilesOptions;
Base: TMemoryStream;
Output: TFileStream;
begin
PdfAOptions := TPdfASaveOptions.Default;
PdfAOptions.Conformance := pac3b;
Options := TAssocFilesOptions.Default; // TargetPage = 0:目錄層級的 /AF
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'invoice-data.xml';
Options.Files[0].Description := 'Structured invoice data';
Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
Options.Files[0].Relationship := afData;
Options.Files[0].MIMEType := 'application/xml'; // 寫成 /application#2Fxml
Base := TMemoryStream.Create;
try
if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
raise Exception.Create('PDF/A-3 base save failed');
Output := TFileStream.Create(OutPath, fmCreate);
try
InjectAssociateFiles(Base, Output, Options); // 倒轉 Base;失敗時舉發 EPdfAssocFilesError
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
目錄還是頁面:/AF 陣列掛在哪裡?
TAssocFilesOptions.TargetPage 決定 /AF 陣列的歸屬:0 把它掛到目錄,成為文件層級的關聯;1..N 掛到對應頁面字典,從 1 起算。注入器以固定版面(先內嵌串流、再檔案規格、再 /AF 陣列、最後改寫目錄或頁面物件)把所有東西追加成單次增量更新,既有物件的位置不動,也沒有任何東西被重新壓縮。目標字典上既有的 /AF 條目會被替換而非合併,重複存檔因此具有冪等性,但也意味著帶不同檔案清單的第二次呼叫會勝出。有兩種行為曾經值得您在自己的程式碼裡加防護,如今都變了。v3.122.0 之前,越界的 TargetPage 不會失敗,而是退回目錄,一個打錯字就能把頁面層級關聯悄悄變成文件層級,毫無訊號。v3.122.0 起,SaveAsWithAssociateFiles 與 SaveAsWithAssociateFilesToStream 在 TargetPage 落在 0..PageCount 之外時舉發 EPdfError;InjectAssociateFiles 則對負數或指向不存在頁面的 TargetPage 舉發新的 EPdfAssocFilesError,且不動目標串流。v3.121.4 之前,頁面查找按檔案順序在存好的位元組裡掃 /Type /Page 字典,一旦頁面物件的存放順序與顯示順序不一致——例如頁面被重排或插入之後——檔案就可能掛到另一頁;v3.121.4 起,TargetPage 指的是文件頁序中該位置上的頁面
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
const CsvBytes: TBytes; const OutPath: string);
var
Options: TAssocFilesOptions;
begin
// v3.122.0 起,越界的 TargetPage 會舉發 EPdfError(較舊的建置
// 默默退回目錄層級的 /AF);先檢查才能正確點名頁面
if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);
Options := TAssocFilesOptions.Default;
Options.TargetPage := PageNumber;
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'chart-data.csv';
Options.Files[0].Content := CsvBytes;
Options.Files[0].Relationship := afSource;
Options.Files[0].MIMEType := 'text/csv';
if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
raise Exception.Create('Associated-file save failed');
end;
怎麼把 AFRelationship 可靠地讀回來?
TPdf.AttachmentRelationship[Index] 透過原生匯出函式 FPDFAttachment_GetAFRelationship 回傳附件的 /AFRelationship 名稱,但空字串有兩種可能含義,所以先呼叫 AttachmentRelationshipFeaturesAvailable。綁定載入得很寬容:PDFium DLL 缺那個匯出時,所有關係都讀成空字串,與單純沒帶 /AFRelationship 的檔案規格無從區分。這個屬性也與 AttachmentCount 共用索引,後者數的是 /Names /EmbeddedFiles 名稱樹裡的條目。注入器只寫 /AF 鏈、不加名稱樹條目,所以透過 InjectAssociateFiles 附加的檔案不在那個索引範圍內;要確認注入的鏈,請檢查存好的位元組或跑一次 PDF/A 驗證器。那棵名稱樹的內部構造,在 Delphi 用 PDFium Component 操作 PDF 附件一文有完整說明
procedure ReportRelationships(const FileName: string);
var
Pdf: TPdf;
I: Integer;
Rel: string;
begin
if not AttachmentRelationshipFeaturesAvailable then
begin
Writeln('This PDFium build cannot report /AFRelationship');
Exit; // 空答案有歧義,所以別問
end;
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for I := 0 to Pdf.AttachmentCount - 1 do
begin
Rel := Pdf.AttachmentRelationship[I];
if Rel = '' then
Rel := '(no /AFRelationship)';
Writeln(Pdf.AttachmentName[I], ': ', Rel);
end;
finally
Pdf.Free;
end;
end;
SaveAsWithAssociateFiles 不保證什麼?
TPdf.SaveAsWithAssociateFiles 擔保的是檔案格式的外殼與要求的檔案確實被注入,不是合規。注入這半邊是新的:v3.122.0 之前,存好的位元組若讀不出 trailer 或找不到目錄字典,InjectAssociateFiles 會把輸入原封不動複製過去,方法照樣回傳 True。v3.122.0 起,InjectAssociateFiles 在寫入任何東西之前就對這些情況舉發 EPdfAssocFilesError,SaveAsWithAssociateFiles 回傳 False;而且它現在會先在暫存區建好完整輸出再開目標檔,被拒或失敗的存檔不會再截斷既有檔案。空的 Files 陣列依設計仍把文件原樣複製過去。酬載內容同樣是您的責任:注入器不檢查 XML 是否格式完好、MIME 型別是否與位元組相符,也不檢查基礎文件到底是不是 PDF/A。驗證器看過之前,把最終檔案當成未驗證品——這正是PDFium Component 與 PDF/A 封存合規一文描述的紀律。若您也自己解析收到的字典,同一套 #XX 名稱規則反向適用,解析 PDF 字典時的名稱記號陷阱一文談的就是這個
關聯檔案、PDF/A 輸出、附件 metadata 與驗證都在同一個元件裡,上面的管線不需要再多請一個 PDF 函式庫進建置。API 參考、試用下載與授權選項都在PDFium Component 產品頁