PDF/A-3 檢查清單通常說 XMP 封包必須帶兩個識別條目:pdfaid:part 與 pdfaid:conformance。寫對的 PDF/A-4 檔案帶的是 pdfaid:part 4 與 pdfaid:rev 2020,基礎設定檔根本沒有 conformance 條目。加一個字母去「修」它反而把檔案弄錯:ISO 19005-4 用編號與修訂年份識別基礎部分,只有兩個擴充保留字母
PDF/UA-2 在下一層——結構樹裡——有個類似的陷阱。在自己的命名空間裡宣告自訂角色,看起來像做完了全部工作,畢竟命名空間正是第 2 部分新增的功能。其實只做了一半:https://example.com/ns/widgets 裡的 WidgetGroup 這種角色,仍須映射到標準角色,合規檢查器才會接受,而渲染出來的頁面上沒有任何東西暗示這條映射缺席
用 PDFACompliance 選擇 PDF/A-4
在 HotPDF 裡,PDF/A-4 設定檔是既有 PDFACompliance 屬性的三個值:'4' 基礎部分、'4E' 工程文件、'4F' 攜帶嵌入檔案的文件。選 '4' 時,XMP 封包拿到 pdfaid:part 4 與 pdfaid:rev 2020,pdfaid 命名空間裡再無其他。'4E' 與 '4F' 加上帶 E 或 F 的 pdfaid:conformance。解碼函式回報同樣的區分:三個值的 PDFAPart 都回傳 4,PDFAConformanceLevel 回傳 E、F,基礎設定檔則回傳 #0
三個值都強制 PDF 2.0 標頭。升級發生在 EndDoc 裡,所以從 PDF 1.4 起步的文件不必另做呼叫就直接以 2.0 出爐。唯一會擋下它的是 StrictVersionLock:這個旗標禁止自動升級,於是 EndDoc 會 raise「PDF/A-4 requires PDF 2.0; StrictVersionLock prevents the version upgrade」,而不是寫出一個中繼資料宣稱 part 4 的 1.7 檔案。為舊印刷流程釘死版本的程式碼庫,第一次有人翻動設定檔時就會看到這條訊息
兩條 PDF/A-4 規則改變了 trailer 與 Catalog 的內容。ISO 19005-4 §6.1.3 只在 Catalog 帶 PieceInfo 條目時才允許文件資訊字典,而且即使那時也只限 ModDate,所以 HotPDF 對 part 4 不寫 Info 字典,標題改以 XMP 裡的 dc:title 傳遞。Title 與 Lang 仍是必填:留空任一個,EndDoc 就 raise。沒有用 AddPDFAOutputIntent 加過輸出意向也會 raise;選了任何 PDF/A 設定檔時 EnableEncrypt 拒絕執行——加密的封存檔案是標準不允許的自相矛盾
PDF/A-4f 與 4e 之下的關聯檔案
關聯檔案是選 PDF/A-4f 的主要理由:渲染頁面背後的一份 XML 發票、下游系統要解析的 JSON 負載。AddPDFAssociatedFile 接受檔名、MIME 類型、描述、一個 AFRelationship 值與位元組內容,回傳間接的 FileSpec 字典,你可以再掛自己的選用鍵
var
Pdf: THotPDF;
Icc: TFileStream;
begin
Pdf := THotPDF.Create(nil);
Icc := TFileStream.Create('sRGB2014.icc', fmOpenRead or fmShareDenyWrite);
try
Pdf.FileName := 'invoice-archive.pdf';
Pdf.PDFACompliance := '4F'; // part 4、rev 2020、conformance F
Pdf.Title := 'Invoice 2026-0731'; // 只寫進 XMP 的 dc:title
Pdf.Lang := 'en-US';
Pdf.BeginDoc;
Pdf.AddPDFAOutputIntent('sRGB IEC61966-2.1', 'sRGB IEC61966-2.1',
Icc, 3, 'DeviceRGB');
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-0731');
// LoadInvoiceBytes 是你自己回傳 TBytes 的函式
Pdf.AddPDFAssociatedFile('invoice.xml', 'text/xml',
'Structured invoice data', 'Data', LoadInvoiceBytes);
Pdf.EndDoc;
finally
Icc.Free;
Pdf.Free;
end;
end;
每次呼叫做四件事:寫出一條 EmbeddedFile 串流,/Subtype 以 PDF 名稱持有 MIME 類型,帶一個記錄大小、修改日期與 MD5 校驗和的 /Params 字典;建出同帶 /F 與 /UF、描述放 /Desc 並帶關係名稱的 FileSpec;然後把這個 FileSpec 註冊兩次——Catalog 的 /AF 陣列與 EmbeddedFiles 名稱樹。兩處註冊都是必需的:只登記在一處的檔案也許仍能從檢視器的附件清單打開,這正是目測抽查對封存幾乎證明不了什麼的原因
關係字串必須是 Source、Data、Alternative、Supplement 或 Unspecified 之一;其他值在寫出任何位元組之前就 raise,空的 MIME 類型也一樣。設定檔也會被檢查:AddPDFAssociatedFile 接受 PDF/A-3、PDF/A-4e 與 PDF/A-4f,基礎 '4' 設定檔會 raise,因為它不允許關聯檔案。反方向,AddDocumentAttachment 在 PDF/A-3、4e、4f 之下拒絕執行,因為普通附件缺少這些設定檔需要的 /AF 註冊。針對 PDF/A-3 以 AddPDFA3AssociatedFile 寫的程式碼照樣編譯、行為不變;AddPDFAssociatedFile 是該方法的設定檔中立名稱
兩個擴充瓜分剩下的權限。'4F' 之下,EndDoc 會確認 Catalog 帶有 EmbeddedFiles 名稱樹,且 AddFileAttachmentAnnotation 被接受,基礎設定檔與 4e 則拒絕它。'4E' 之下允許 Add3DAnnotation 與 AddRichMediaAnnotation,連同 SetOCGState 與 GoTo3DView 動作。其餘每個 PDF/A 設定檔對這四個全都拒絕
帶命名空間的自訂角色為什麼還是過不了 PDF/UA-2?
自訂角色通過 PDF/UA-2 的條件有二:它的 /NS 條目指向結構樹根裡列出的命名空間字典,且該角色能透過那個命名空間的 RoleMapNS 解析到標準命名空間裡的角色。HotPDF 把這份工作拆給三個方法:RegisterStructureNamespace 建立間接的 /Type /Namespace 字典、加進 StructTreeRoot /Namespaces 並回傳它;用同一個 URI 再次呼叫會回傳既有字典而不另建。AddStructureElementNS 建立元素並設定其 /NS,順手註冊命名空間。AddStructRoleMapNS 負責寫映射
const
PDF2NS = 'http://iso.org/pdf2/ssn';
WidgetNS = 'https://example.com/ns/widgets';
var
DocElem, Group: THPDFDictionaryObject;
begin
Pdf.PDFUACompliance := True;
Pdf.PDFUAPart := 2; // PDF/UA-2 要求 PDF 2.0
Pdf.Title := 'Widget catalog'; // 空的 Title 會在 EndDoc raise
Pdf.Lang := 'en-US';
Pdf.BeginDoc;
DocElem := Pdf.AddStructureElement('Document', nil); // 綁到 PDF2NS
Group := Pdf.AddStructureElementNS('WidgetGroup', WidgetNS, DocElem);
// 少了這條映射,EndDoc 會 raise ua2.rolemap.unresolved
Pdf.AddStructRoleMapNS('WidgetGroup', WidgetNS, 'Div', PDF2NS);
Pdf.CurrentPage.SetFont('Arial', [], 18);
Pdf.CurrentPage.BeginMarkedContentMCID('H1', 0);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Widgets');
Pdf.CurrentPage.EndMarkedContent;
Pdf.AddStructureElement('H1', Group, 0, 0); // 頁面索引 0、MCID 0
Pdf.EndDoc;
end;
其中一部分掛名是自動發生的。PDFUAPart 設為 2 時,AddStructureElement('Document', nil) 會把元素綁到 PDF 2.0 標準命名空間 http://iso.org/pdf2/ssn,因為 PDF/UA-2 樹的根必須是該命名空間裡的 Document。PDF 2.0 詞彙表裡的每個角色都發生同樣的綁定,所以 H1、P 不需要命名空間引數。只存在於 PDF 1.7 集合的角色——如 Note、Code、BlockQuote——留在 1.7 命名空間
映射目標是個真正的選擇。給 AddStructRoleMapNS 傳空的目標命名空間,寫出的是短形式——對 PDF 1.7 集合解析的裸角色名;傳 PDF 2.0 URI 寫出的則是雙元素 [role namespace] 形式。在 PDF/UA-2 文件裡,映射到 PDF 2.0 命名空間:整棵樹隨之解析進同一套詞彙表,而 Aside、FENote 這類在 1.7 沒有對應物的目標也變得可用。兩種映射形狀在 EndDoc 直接被拒:繞回自身的鏈,以及顯式命名空間裡的角色映射到同一命名空間裡的另一個角色
呼叫順序比看上去寬鬆。映射可以在元素存在之前或之後加,因為樹完成之前沒有人檢查它。會立刻失敗的是 PDFUAPart 為 1 時呼叫 AddStructureElementNS:第 1 部分沒有命名空間,這個呼叫當場 raise「structure namespaces require PDFUAPart=2」
EndDoc 在寫出檔案之前檢查什麼
PDFUACompliance 開啟且 PDFUAPart 為 2 時,EndDoc 在序列化任何東西之前先跑完整的 PDF/UA-2 結構驗證,第一個錯誤就變成例外。其中四項檢查守護樹的骨架:根必須恰好含一個最上層 Document 元素;每個命名空間字典必須是間接的、型別 Namespace、帶僅出現一次的非空 URI;每個 /NS 參照必須解析到根 /Namespaces 陣列裡列出的字典;每個角色必須直接、或經 RoleMap 與 RoleMapNS 解析到標準命名空間。從 Document 元素走不到的元素也會被回報
同一輪還涵蓋文件層級要求:非空且有效的 Lang;MarkInfo /Marked 設為 true;DisplayDocTitle 開啟;以及帶非空 dc:title 與 pdfuaid:part 2 的 XMP 封包。PDF/UA 一開,標記旗標、檢視器偏好與 XMP 識別都由 HotPDF 自己設定,並在 part 數字旁寫上 pdfuaid:rev 2024。標題與語言得你自己給,任一為空,驗證器還沒開跑就會 raise。標題、清單、表格、註解與表單在同一輪裡一併檢查
等到 EndDoc 才檢查是刻意的。產生進行到一半時,樹理應不完整:Document 元素可能還沒有子節點,命名空間可能在任何元素使用它之前就註冊,映射可能在元素之後才到。每次呼叫都檢查會拒掉這些狀態。到了 EndDoc,樹已完成且仍在記憶體中,例外會指名規則代碼與破壞它的元素路徑
try
Pdf.EndDoc;
except
on E: Exception do
begin
// E.Message 的形狀是
// PDF/UA-2 validation failed [ua2.heading.skipped at ...]:
// 標題層級從 H2 跳到 H4
LogBuildFailure(Pdf.FileName, E.Message); // 你自己的記錄器
raise;
end;
end;
PDF/A-4 輸出也得到自家驗證器的同款待遇。PDFACompliance 帶 part 4 值時,EndDoc 對產生的圖形依 ISO 19005-4 檢查,raise 時附規則代碼與條款號,例如沒有有效關係的 FileSpec 是 pdfa4.filespec.relationship、多餘的 Info 鍵是 pdfa4.trailer.info_entries。Enterprise 版把兩個驗證器以 ValidatePDFUA2Structure 與 ValidatePDFA4 公開,用問題陣列裝下每一條保留的診斷,而不是停在第一個錯誤。對應該印出完整清單的 CI 步驟,那才是更好的入口
PDF 2.0 角色與 H6 以上的標題
強型別的 THPDFStandardStructureType 列舉新增 sstDocumentFragment、sstAside、sstTitle、sstFENote、sstSub、sstEm、sstStrong 與 sstArtifact。其中三個改變普通商業文件的標記方式:Aside 給側邊欄與引述一個不是被誤用之 Sect 的家;FENote 把腳註與尾註標成 notes,讓螢幕閱讀器可以應要求提供,而不是在句子中間朗讀出來;Em 與 Strong 把強調作為結構攜帶——PDF 1.7 樹只能用 Span 包著它
PDF 2.0 裡標題沒有上限。列舉停在 sstH6,但 AddStructureElement 的字串多載接受 H7、H8 乃至更深,因為 PDF 2.0 集合為任意 n≥1 定義了 Hn。PDF 1.7 停在 H6,迫使深度技術文件壓平大綱或重複使用層級。對標準文件、法規與零件目錄來說,光是這一點就足以把輸出搬到 PDF 2.0
更深的標題伴隨更嚴的規則。EndDoc 裡的 PDF/UA-2 那輪要求標題層級連續:第一個標題必須是 H1,從 H2 跳到 H4 會以 ua2.heading.skipped raise。層級只有在一個 DocumentFragment 裡才可以從更低起步——那個角色是給從更大文件摘錄的內容用的。把 Markdown 或 HTML 大綱直接映射到 Hn 角色的產生器,會繼承作者為了視覺大小而跳過的每一層,所以標記前先把大綱正規化
切換正式輸出前要先檢查什麼
PDF 2.0 從標頭變更開始,而標頭是舊消費端最先讀的東西。只接受到 PDF 1.7 的匯入服務、印刷流程或業務線檢視器,會直接卡在 %PDF-2.0,檔案其他部分再乾淨也沒用。選任何 PDF/A-4 設定檔或 PDF/UA 第 2 部分,就等於選了 PDF 2.0,不管你有沒有開口;所以切換之前,先逐一確認每個消費端系統,而不是切完之後
風險最低的鋪開方式:送往你無法掌控的讀者的文件繼續用 PDF/A-3,匯入端由你掌控的內部封存用 PDF/A-4f,可存取性政策點名之處採用 PDF/UA-2。過渡期間 StrictVersionLock 可以兼任護欄:在必須停在 1.7 的程式碼路徑裡把 Version 設為 pdf17 並上鎖,不小心選到 PDF/A-4 時會在 EndDoc 帶著訊息失敗,而不會流落到打不開該檔案的系統。PDF/A、PDF/X 與 PDF/UA 驗證與PDF/A-3 上的 ZUGFeRD 與 Factur-X 混合發票指南涵蓋版本號之前先要決定的設定檔選擇,自動化預檢報告筆記示範如何把判定結果變成建置步驟
這一切都在 Delphi 與 C++Builder 元件裡以原生 VCL 程式碼執行,PDF/A-4 與 PDF/UA-2 輸出隨你的執行檔出貨、不需要外部引擎。支援的設定檔與 RAD Studio 版本列在 HotPDF Delphi 元件頁