您的偵測程式碼回報別家產品的 PDF/UA 檔案毫無宣告,而一份 PDF/R,在編輯器給它附加一枚便籤的瞬間就不再是 PDF/R。HotPDF 透過 IsLoadedPDFUA、GetLoadedPDFR1Version 與 GetLoadedPDFDeclarations 這類存取函式,從已載入的文件讀出這些宣告,每一個函式都會到自己所屬標準存放識別資訊的位置去找
各 PDF 標準把識別資訊寫在哪裡
各附屬標準從來沒有為宣告統一過位置。大多數用 catalog /Metadata 串流裡的 XMP 屬性,少數較舊的仍依賴文件資訊字典裡的鍵,PDF/R 把一行註解放到檔案 trailer 旁邊,PDF 2.0 世代又添了兩種機制:catalog 裡的開發者擴充字典與 XMP 裡的 PDF Declarations。識別資訊是檔案對自身的陳述;內容是否名副其實,交給驗證器判斷,產出符合 PDF/A、PDF/X 與 PDF/UA 的輸出則是另一個主題。就分流而言(PDF/X 進列印佇列、PDF/A 進封存、WTPDF 進無障礙管線),您需要的就是這份宣告,從正確的位置讀出來:
| 標準 | 宣告放在哪裡 | HotPDF 存取函式 |
|---|---|---|
| PDF/A(ISO 19005) | XMP 的 pdfaid:part 與 pdfaid:conformance,PDF/A-4 另有 pdfaid:rev | IsLoadedPDFA、GetLoadedPDFAVersion、GetLoadedPDFARevision |
| PDF/X(ISO 15930) | PDF/X-1a 與 PDF/X-3 放 Info 字典的 GTS_PDFXVersion,較新的部分放 XMP | IsLoadedPDFX、GetLoadedPDFXVersion |
| PDF/UA(ISO 14289) | XMP 的 pdfuaid:part | IsLoadedPDFUA、GetLoadedPDFUAVersion |
| PDF/VT(ISO 16612-2) | XMP 的 pdfvtid:GTS_PDFVTVersion | IsLoadedPDFVT、GetLoadedPDFVTVersion |
| PDF/E(ISO 24517-1) | ISO_PDFEVersion,從 Info 字典、catalog 或 XMP 讀取 | IsLoadedPDFE、GetLoadedPDFEVersion |
| PDF/R-1(ISO 23504-1) | %PDF-raster-x.y 註解,緊鄰最後一個 startxref 上方 | IsLoadedPDFR1、GetLoadedPDFR1Version |
| PDF/VCR-1(ISO 16613-1) | catalog 或 XMP 裡的 GTS_PDFVCRVersion | IsLoadedPDFVCR、GetLoadedPDFVCRVersion |
| WTPDF 1.0、ISO/TS 32005 | XMP 裡的 pdfd:declarations 項目 | IsLoadedWTPDF、GetLoadedWTPDFVersion、IsLoadedISO32005 |
| PDF 2.0 開發者擴充 | catalog 裡的 /Extensions /ISO_ | HasLoadedISODeveloperExtension、GetLoadedISODeveloperExtensionLevels |
uses
System.SysUtils, HPDFDoc;
procedure ReportClaims(const FileName: string; const Password: AnsiString);
var
Pdf: THotPDF;
PDFX, PDFXLevel: AnsiString;
begin
Pdf := THotPDF.Create(nil); // 專用的實體,只做檢查用
try
// 密碼多載會解密串流,XMP 封包也在內;
// 免密碼開啟的檔案就傳入空字串
if Pdf.LoadFromFile(FileName, Password) <= 0 then
raise Exception.CreateFmt('Cannot open %s', [FileName]);
if Pdf.IsLoadedPDFA then
Writeln('PDF/A: ', Pdf.GetLoadedPDFAVersion); // 例如 PDF/A-2b
PDFX := Pdf.GetLoadedPDFXVersion; // 先 XMP,後 Info
if PDFX <> '' then
begin
PDFXLevel := Pdf.GetLoadedInfoKeyValue('GTS_PDFXConformance');
if PDFXLevel <> '' then
Writeln('PDF/X: ', PDFX, ', conformance ', PDFXLevel)
else
Writeln('PDF/X: ', PDFX);
end;
if Pdf.IsLoadedPDFUA then
Writeln('PDF/UA: ', Pdf.GetLoadedPDFUAVersion); // 例如 PDF/UA-1
if Pdf.IsLoadedPDFVT then
Writeln('PDF/VT: ', Pdf.GetLoadedPDFVTVersion);
if Pdf.IsLoadedPDFE then
Writeln('PDF/E: ', Pdf.GetLoadedPDFEVersion);
if Pdf.IsLoadedPDFR1 then
Writeln('PDF/R: ', Pdf.GetLoadedPDFR1Version); // 例如 PDF/R-1.0
if Pdf.IsLoadedWTPDF then
Writeln('WTPDF: ', Pdf.GetLoadedWTPDFVersion);
finally
Pdf.Free;
end;
end;
照範例的做法,檢查時用一個獨立的 THotPDF 實體。IsLoaded… 系列函式回答的是元件此刻持有的文件,包括它正在建置中的那份:PDFUACompliance 為輸出開啟之後,IsLoadedPDFUA 也會是 True,而 IsLoadedPDFX 與非空的 PDFXCompliance 同進退。建置途中這樣很方便,但同一個元件既當產出器又當檢查器用時,它會把您的輸出設定混進對別家檔案的判決裡
PDF/X 是唯一一個識別資訊在不同部分之間搬過家的家族。PDF/X-1a 與 PDF/X-3 是 PDF 1.3 設定檔,把 GTS_PDFXVersion 放進文件資訊字典,完全不需要 XMP;PDF/X-4 及之後的部分則要求 XMP 屬性 pdfx:GTS_PDFXVersion。GetLoadedPDFXVersion 先查 XMP(pdfx 或 pdfxid 前綴皆可),再查 Info 字典,然後查一些老產品寫的 catalog 鍵,並原樣傳回宣告值。PDF/X-1a 有一個值得寫進程式碼的轉折:它的版本值是 PDF/X-1:2001,而 PDF/X-1a:2001 符合等級住在自己的鍵 /GTS_PDFXConformance 裡,範例需要區分 X-1a 與純 X-1 時,就用 GetLoadedInfoKeyValue 讀它
有效的 XMP 宣告長什麼樣子?
XMP 是序列化成 XML 的 RDF,而 RDF/XML 允許簡單屬性在 rdf:Description 內以兩種寫法出現:當作子元素,<pdfuaid:part>1</pdfuaid:part>,或當作 description 本身的屬性,pdfuaid:part="1",單引號雙引號皆可。ISO 16684-1 兩種都接受,一個封包也可以把屬性拆進數個 rdf:Description 元素。這種拆分不是紙上談兵。HotPDF 為 PDF/UA、PDF/VT、PDF/E 與 PDF/X 寫出子元素形式,而 veraPDF 測試語料與許多外部產品寫的是簡寫形式,像 pdfuaid:part="1" 或 pdfvtid:GTS_PDFVTVersion="PDF/VT-1" 這樣的 description 把整份宣告裝在屬性裡。繞著 Pos('<pdfuaid:part>', Xmp) 打造、又只在自家輸出上跑過的偵測器,本地檢查全數過關,遇到第一個 veraPDF 樣本就說它沒有宣告。HotPDF 的 XMP 讀取端對表中每個存取函式都同時處理兩種形式。GetLoadedPDFAVersion 把 pdfaid:part 與 pdfaid:conformance 組合成 PDF/A-1b、PDF/A-2u、PDF/A-4 或 PDF/A-4f 這類識別資訊,GetLoadedPDFARevision 則傳回 pdfaid:rev——PDF/A-4 要求的版本年(2020),第 1 至 3 部並未定義:
procedure ReportPDFALevel(Pdf: THotPDF);
var
Level, Revision: AnsiString;
begin
Level := Pdf.GetLoadedPDFAVersion; // 例如 'PDF/A-1b'、'PDF/A-2u'、'PDF/A-4f'
if Level = '' then
Exit;
Revision := Pdf.GetLoadedPDFARevision; // PDF/A-4 為 '2020',第 1-3 部為空
if Revision <> '' then
Writeln(Level, ', edition ', Revision)
else
Writeln(Level);
// 預期結果:不論產品寫的是
// <pdfaid:part>2</pdfaid:part> 或 pdfaid:part="2",都得到 "PDF/A-2b"
end;
前綴是另一個會動的部分。在 XML 裡前綴只是本地別名;真正識別 PDF/A schema 的是命名空間 URI http://www.aiim.org/pdfa/ns/id/,已核可的 ISO 19005-4 勘誤也接受它的 https:// 拼法,而產品愛把它綁到哪個前綴都行。GetLoadedPDFAVersion 在讀 part 與等級之前,會先查出封包為這個 URI 宣告的前綴,所以寫成 pdfa:part 而非 pdfaid:part 的封包照樣識別得出來。您自己從 GetLoadedXMPMetadata 傳回的解碼封包裡讀取任何其他 XMP 屬性時,也該拿出同樣的細心:兩種序列化形式、從 xmlns 宣告解析前綴,以及等號兩側允許空白
加密檔案把宣告藏在位元組搜尋之外
直接在原始檔案裡掃 pdfaid 或 ISO_PDFEVersion 省掉了解析,卻在兩個平凡情況下失效。多數 XMP 串流經過 Flate 壓縮,屬性名稱根本不會以文字形式出現在磁碟上。而在加密文件裡,metadata 串流是密文:ISO 32000-2 §7.6.4.2 定義的 EncryptMetadata 預設為 true,除非產品明確退出,封包會與其他串流一起加密。對加密過的 PDF/E-1 檔案做位元組搜尋,於是回報 ISO_PDFEVersion 宣告缺失,儘管它明明就在那裡。可靠的順序是載入、解密、解碼,然後再搜尋。用密碼多載 LoadFromFile(FileName, Password) 開檔,只帶擁有者密碼、在任何檢視器都能免提示開啟的檔案則傳入空字串。單參數的 LoadFromFile(FileName) 載入文件結構但不解密串流,XMP 封包因此讀不出來;過了密碼多載之後,GetLoadedXMPMetadata 與上述每個存取函式都能在解密、解壓縮後的封包上工作
PDF/R 檔案為何編輯過就失去身分?
PDF/R-1 自我識別的方式獨一無二,其他附屬標準都不是這樣。ISO 23504-1 第 5 章要求在持有 startxref 的最後一行的前一行,放一行讀作 %PDF-raster-1.0(或實際適用的版本)的註解。PDF 解析器讀交叉引用資料時會把註解丟掉,物件模型因此無話可報;證據只存在檔案末端的原始位元組裡。GetLoadedPDFR1Version 會回到文件載入時的來源,至多讀最後 64 KiB,找出最後一個 startxref,檢查它上面的那一行。接著看增量更新。加註解的編輯器會在原始 %%EOF 之後附加新物件、新的交叉引用區、新的 trailer 與新的 startxref。%PDF-raster-1.0 那行還在檔案裡,但它現在落在倒數第二個 startxref 上方,而標準對哪一個才算數毫不含糊。預期結果:編輯之後 IsLoadedPDFR1 傳回 False,除非編輯工具在自己的更新裡重新寫出該註解。掃整個檔案找 %PDF-raster- 的偵測器會說有,按第 5 章的規則,那個答案是錯的。必須讓檔案保持 PDF/R 身分的擷取工作流程,把註解放進附屬檔案(sidecar),或交給會把整份檔案重寫為 PDF/R 的工具
開發者擴充:整數層級、名稱與陣列
PDF 2.0 文件透過 catalog 的 /Extensions 字典宣告它所依賴的 ISO 技術規格(ISO/TS 32001 的 SHA-3 雜湊、ISO/TS 32002 的 ECC 簽章、ISO/TS 32007 的 glTF 資產),該字典定義於 ISO 32000-2 §7.12。在註冊前綴 /ISO_ 之下,表 48 允許單一開發者擴充字典,或 PDF 2.0 新增的字典陣列,表 49 加上 §7.12.5 則把 /ExtensionLevel 定為整數。只處理一種容器形狀的讀取端,會漏掉所有以另一種寫成的檔案。HasLoadedISODeveloperExtension 與 GetLoadedISODeveloperExtensionLevels 兩種都收,途中還會解析間接參照,儘管 §7.12.1 說這些字典必須是直接物件:
procedure ReportISOExtensions(Pdf: THotPDF);
var
Levels: THPDFIntegerArray;
Revision, URL: AnsiString;
I: Integer;
begin
Levels := Pdf.GetLoadedISODeveloperExtensionLevels;
for I := 0 to High(Levels) do
Writeln('ISO_ extension level ', Levels[I]);
if Pdf.HasLoadedISODeveloperExtension(32001, Revision, URL) then
Writeln('ISO/TS 32001 revision ', Revision, ', documented at ', URL);
// 對以 EnsureISO32001Extensions 寫出的檔案,預期結果:
// ISO/TS 32001 revision :2022, documented at https://www.iso.org/standard/45874.html
if Pdf.HasLoadedISODeveloperExtension(32007) then
Writeln('glTF 3D assets per ISO/TS 32007');
end;
ISO/TS 32007:2024 第一版在 §4.8 有一個把 /ExtensionLevel /32007 寫成名稱物件的範例;PDF 勘誤 Issue #835 已把它修正為整數 32007,但照抄原始範例的產品寫的仍是名稱。所以當整數查找落空時,HotPDF 的讀取端退而解析名稱或字串值,帶不帶前導斜線都收,HasLoadedISODeveloperExtension(32007) 因此兩種拼法都找得到。另一條勘誤關於 /URL:ISO 32000-2:2020 的表 49 把 /URL 列為選用,Issue #732 把它升為必填。寫入端 HotPDF 在每個 EnsureISO…Extensions 呼叫都發出整數形式與一個 /URL;讀取端對按 2020 文本寫成的檔案,URL out 參數為空屬正常,把它當參考資訊,絕不當成拒絕該擴充的理由
產品寫下的是哪個 WTPDF URI?
WTPDF 1.0 與 ISO/TS 32005 的宣告屬於 PDF Declarations:XMP 裡 pdfd:declarations bag 中的項目,隸屬 PDF Association 的 PDF Declarations 技術說明所定義的命名空間 http://pdfa.org/declarations/。每個項目帶一個 conformsTo URI 與選用的 claimBy、claimDate、claimCredentials、claimReport 欄位,GetLoadedPDFDeclarations 以 THPDFDeclarationEntry record 陣列傳回它們;寫入端 AddPDFDeclaration 把同樣五個欄位放進 XMP 封包,緊挨著PDF 2.0、PDF/A-4 與 PDF/UA-2 識別命名空間。用純文字搜尋把 bag 切成項目有自己的坑:在每個 conformsTo 出現處切分,子元素形式的結束標籤 </pdfd:conformsTo> 也會被當成新項目的起點,第一份宣告於是未到自身結尾就被截斷。HotPDF 的讀取端會跨過結束標籤,兩種序列化形式都收
URI 本身是更難纏的部分。同一份宣告可能以 http:// 或 https:// 抵達,而 WTPDF 1.0 Issue #395 改動了 # 之前要不要放一個 /,勘誤前後寫成的檔案就差在那個字元;純字串比較會把它們當成兩個不同的標準。HasLoadedPDFDeclaration 不分大小寫比較 URI,把 https:// 視同 http://,並在兩側都丟掉緊鄰 # 之前的 /,所以下面的查詢不論產品選了哪種拼法都匹配得上。片段保持原樣:#accessibility1.0 與 #accessibility1.0-validated 是兩份不同的宣告,後者宣稱已有驗證器檢查過檔案,您該索求工作流程在乎的那一份。GetLoadedWTPDFVersion 為分流把兩者收斂成 'WTPDF 1.0 Accessibility' 或 'WTPDF 1.0 Reuse'
procedure ReportDeclarations(Pdf: THotPDF);
var
Decls: THPDFDeclarationEntryArray;
I: Integer;
begin
Decls := Pdf.GetLoadedPDFDeclarations;
for I := 0 to High(Decls) do
Writeln(Decls[I].ConformsTo, ' claimed by ', Decls[I].ClaimBy,
' on ', Decls[I].ClaimDate);
// http 或 https 都匹配,'#' 前有無斜線皆可
if Pdf.HasLoadedPDFDeclaration(
'https://pdfa.org/declarations/wtpdf#accessibility1.0') then
Writeln('WTPDF 1.0 Accessibility (unvalidated claim)');
// 涵蓋 Reuse 與 Accessibility 的普通版與 -validated 版
Writeln('WTPDF profile: ', Pdf.GetLoadedWTPDFVersion);
end;
對擷取或列印路由器而言,操作順序比任何單一存取函式都重要:在檔案剛抵達、還沒被加註解或蓋章步驟附加更新之前,先測 IsLoadedPDFR1;PDF/X 分流交給 GetLoadedPDFXVersion,它同時涵蓋 PDF/X-1a、PDF/X-3 檔案的 Info 字典與 PDF/X-4 檔案的 XMP,只在必須區分 X-1a 與純 X-1 時才讀 /GTS_PDFXConformance。這兩項檢查對載入HotPDF Delphi PDF component 的任何文件都能執行,包括以密碼開啟的加密檔案