技術文章

Delphi 裡的 PDF/A-3 關聯檔案與 AFRelationship

要從 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 再整齊也一樣

PDFium Component 裡 PDF/A-3 關聯檔案的三物件鏈:帶 MIME Subtype(如 application xml)的 EmbeddedFile 串流、F、UF、EF 齊備且 AFRelationship 設為 Data 的檔案規格,以及目錄或頁面掛出的 AF 陣列——ISO 19005-3 第 6.8 條通過前,驗證器檢查的就是這三個物件
串流、檔案規格與 AF 陣列必須對得上;TPdf.CreateAttachment 的普通名稱樹內嵌不設任何關聯欄位,未來也不會

關係值是最容易猜錯的部分。FPdfAssocFiles 裡的 TPdfAFRelationship 把每個列舉成員對應到注入器能寫出的一個名稱記號,其中只有前五個屬於 ISO 19005-3 認可的子集:

  • afSource → /Source:PDF 的原始出處,例如文書處理檔案或試算表
  • afData → /Data:可見內容所衍生或所代表的機器可讀資料
  • afAlternative → /Alternative、afSupplement → /Supplement、afUnspecified → /Unspecified
  • afEncryptedPayload、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 的惡意值,並核對精確的編碼輸出

MIME subtype text/plain 為何弄壞 PDFium Component 的 PDF/A-3 解析:把值接在斜線後做出兩個名稱物件——/text 當值、再附一個懸空的 /plain,EmbeddedFile 字典為之失衡;v3.121.2 的修正把值導過 EscapePdfName,/text#2Fplain 成為解碼後為 text/plain 的單一名稱
失敗看似檔案損毀,因為它發生在解析器裡、在任何 PDF/A 規則之前;跳脫後的名稱讓配對保持平衡,驗證器也就讀得下去
// 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 指的是文件頁序中該位置上的頁面

PDFium Component 裡 AF 陣列掛在哪裡:TargetPage 為 0 掛到目錄,1 到 N 掛到頁面字典;越界值在 v3.122.0 之前默默退回目錄,如今改為舉發例外;注入器以固定版面把所有東西追加成單次增量更新,既有位移不動、既有的 AF 條目一律替換
v3.122.0 之前,越界的 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 產品頁