拿一份資訊字典寫著 /Title (Annual Maintenance Report)、XMP 封包卻完全沒有 dc:title 的檔案。檢視器照樣無怨無悔地顯示標題,PDF/A 驗證器卻拒收,因為兩份中繼資料彼此矛盾。另一份檔案看起來一樣健康,XMP 裡卻沒有 pdfaid:part 項目,驗證器的回應是幾十條錯誤:它把檔案當普通 PDF 評判,把所宣稱部分的每一條規則都回報為不符——全部源於缺了這一行
這兩個問題需要的工作量天差地遠。讓中繼資料一致是任何檔案都能跑的小修。把任意 PDF 變成封存文件,意味著剝除目標部分禁止的東西、補上它要求的東西、聲明宣稱,然後驗證實際產出的那些位元組。我們刻意把轉換建構在最後這一步上。隨 Enterprise 版(TPDFlibEnterprise)出貨的 ConvertToPDFA 連同 GetPDFAConversionReport,把轉換後的文件存進記憶體、對那些位元組執行 PDF/A 檢查,只在檢查通過時回傳 1
為什麼一份開得好好的 PDF 會過不了 PDF/A 檢查?
兩種失敗都來自中繼資料規則。PDF 把標題、作者、產生器與日期記在文件資訊字典,PDF/A 還要求同樣的事實出現在 XMP 中繼資料封包裡;ISO 19005-1 §6.7.3 要求兩者等價,PDF/A-2 與 PDF/A-3 保留這條規則。在這條規則上失敗的檔案,常見模式是產生器填了資訊字典、卻從沒寫 XMP 那一半,驗證器於是在一處找到標題、在另一處什麼都沒有
缺識別的情況更安靜。pdfaid:part 與 pdfaid:conformance 項目是把檔案標記為 PDF/A 的東西,沒有它們檔案照樣可以完全合法。最常見到的情形,是應用程式提供自己的 XMP 封包、換掉 PDF 寫入器產生的那份;自訂封包帶著應用程式自己的欄位、沒有 pdfaid 項目,錯誤清單於是離真正的病因十萬八千里
RepairDocumentMetadata 兩種情況都處理,回傳它改動的項目數。每個版本都有它,SetPDFAMode 與 SanitizeDocument 也一樣。修復走過 Title、Author、Subject、Keywords、Creator、Producer、CreationDate 與 ModDate。只有 XMP 帶值時,資訊字典由它填入,已記錄的東西不會被扔掉。兩邊都帶值且不同時,資訊字典贏。我們這樣裁決,是因為資訊字典是產生器最常設定的一半,更可能是作者意圖的可信紀錄
如果你的 XMP 才是權威複本(例如在文件管理系統裡策展過),先把想要的值用 SetInformation 推進資訊字典,然後才跑修復。反過來做,策展過的 XMP 標題會被原始產生器寫的東西覆蓋
識別修復只要求你一件事:文件必須知道它瞄準哪個部分。剛載入的檔案沒有設 PDF/A 模式,所以修復前先呼叫 SetPDFAMode。模式就位後,RepairDocumentMetadata 寫入對應的 pdfaid 項目、為部分 4 的模式加上年份 2020 的 pdfaid:rev,並把 dc:format 設為 application/pdf——驗證器連這個也查
uses
SysUtils, PDFlibrary;
procedure RepairIncoming(const InName, OutName: string);
var
Lib: TPDFlib;
Fixed: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile(InName, '') <> 1 then
raise Exception.CreateFmt('Cannot open %s', [InName]);
// 策展過的標題先寫進資訊字典,
// 因為兩半不一致時,這份複本贏
Lib.SetInformation(2, 'Annual Maintenance Report 2025');
Lib.SetPDFAMode(3); // 3 = PDF/A-2b,見下表
Fixed := Lib.RepairDocumentMetadata;
Writeln(Format('%d metadata entries brought into agreement', [Fixed]));
Lib.SaveToFile(OutName);
finally
Lib.Free;
end;
end;
選定目標部分與它的模式編號
先按文件必須承載什麼來挑部分,之後才查編號。SetPDFAMode 與 ConvertToPDFA 共用一套編號,而它不按部分名稱暗示的順序走
| 模式 | 部分與等級 | 說明 |
|---|---|---|
| 1 | PDF/A-1a | PDF 1.4,帶標籤 |
| 2 | PDF/A-1b | PDF 1.4 |
| 3 | PDF/A-2b | PDF 1.7 |
| 4 | PDF/A-2a | PDF 1.7,帶標籤 |
| 5, 6, 7 | PDF/A-3b, 3a, 3u | 允許嵌入任何類型的檔案 |
| 8 | PDF/A-2u | PDF 1.7,要求 Unicode 文字 |
| 9, 10, 11 | PDF/A-4, 4e, 4f | PDF 2.0;4e 加入 3D 與豐富媒體,4f 允許嵌入檔案 |
模式 3 是 PDF/A-2b,模式 4 是 PDF/A-2a。這個倒置專抓從 1a 往上數、以為 a 級永遠領先的人。PDF/A-2u 排在 8,落在部分 3 的模式之後
真正有後果的選擇是附件。部分 1 與 2 禁止嵌入檔案,所以模式 1 到 4 與模式 8 時,ConvertToPDFA 會移除它們。把一份 Factur-X 或 ZUGFeRD 發票轉到模式 3,結構化的 XML 發票就從輸出裡消失,連同這份檔案存在的理由。任何帶附件的東西,請瞄準模式 5(PDF/A-3b)——那些發票格式定義所在的部分——需要 PDF 2.0 就用模式 11。部分 3 或部分 4 檔案裡的每個附件,都必須透過 /AFRelationship 聲明它與文件的關係,而 PDF Library for Delphi 在這些模式下會為它存出的每個嵌入檔案寫上這個項目
部分 4 的自我識別方式不同。純 PDF/A-4 沒有一致性字母,兩個擴充用 E 或 F,並要求帶著其部分發布年份的 pdfaid:rev。模式 9 到 11 時,ConvertToPDFA 把檔案抬到 PDF 2.0,因為 ISO 19005-4 §6.1.2 把這個部分建在 PDF 2.0 上,檢查也改按 PDF 2.0 而非 1.7 評判這類檔案
ConvertToPDFA 對檔案改了什麼
ConvertToPDFA 執行一串固定的修復、把每一步記進純文字報告、把結果存進記憶體並驗證那些位元組。檢查失敗回傳 0;模式落在 1 到 11 之外也回傳 0,並在寫出任何報告之前就返回。這串順序值得認識,因為每一步都是輸出會失去或獲得的東西:
- 密碼未知的加密文件會以一行
blocked:立即中止轉換;裡面的東西一個都讀不了,更別說修復 - 文件層級 JavaScript 與執行它的開啟動作會被移除,連同每一個 additional-actions 項目與任何部分都不允許的動作類型,包括 Launch、Sound、Movie、ResetForm、ImportData、Hide、SetOCGState、Rendition 與 Trans。GoTo 與 URI 連結保留,導覽照常運作
- 對禁止嵌入檔案的部分,嵌入檔案會被移除,如前述
- 隱藏或設定為永不顯示的註記會被刪除。PDF/A 要求註記可見、可列印,而把它們顯示出來會改變頁面外觀,所以我們選擇移除
- 表單欄位外觀會在
/NeedAppearances旗標清除之前重新產生,已填的值保持可見 - 有使用但未嵌入的字型,會從本機安裝的字型嵌入
- 不再被任何東西引用的物件會被丟棄,檔案若沒有 sRGB 輸出意圖則補上一個
- 最後,部分與等級寫進 XMP,位元組接受檢查
在伺服器上,字型這一步最容易讓轉換卡住。文件用到轉換主機上哪裡都不存在的字型時,無法從檔案內部修復,報告會這麼說。把你的文件實際用到的字型裝上轉換機器;為既有 PDF 內嵌缺少字型的逐步解說 單獨講這一步
批次工作該怎麼處理一次失敗的轉換?
把回傳值當閘門、報告當工單。回傳 1 的檔案進封存,其餘的連同報告轉給人處理。別只因為失敗轉換的輸出看起來比輸入乾淨,就把它存進封存。那份檔案此刻帶著一個檢查拒絕確認的 PDF/A 宣稱,而驗證步驟的存在正是為了防住這種情況
GetPDFAConversionReport 的報告一行一個動作,每行以動詞開頭:removed:、regenerated:、repaired:、embedded:、added:、stated:,加上擋住執行時的 blocked:,以及收尾的 result: 行。判定背後的檢查在第一個失敗處停住,所以檔案失敗時,轉換會把檢查完整再跑一遍,報告一次列出所有問題。按前綴分流只要幾行程式
uses
Classes, SysUtils, PDFlibrary, PDFlibEnterprise;
function ArchiveOne(const InName, ArchiveDir, ReviewDir: string): Boolean;
var
Lib: TPDFlibEnterprise;
Report: TStringList;
I: Integer;
Blocked: Boolean;
begin
Result := False;
Lib := TPDFlibEnterprise.Create;
Report := TStringList.Create;
try
if Lib.LoadFromFile(InName, '') <> 1 then
Exit;
Result := Lib.ConvertToPDFA(5) = 1; // 5 = PDF/A-3b,保留附件
Report.Text := Lib.GetPDFAConversionReport;
if Result then
Lib.SaveToFile(IncludeTrailingPathDelimiter(ArchiveDir) + ExtractFileName(InName))
else
begin
Blocked := False;
for I := 0 to Report.Count - 1 do
if Pos('blocked:', Report[I]) = 1 then
Blocked := True;
// 原件保持原樣;跟著它走的只有報告
Report.SaveToFile(IncludeTrailingPathDelimiter(ReviewDir) +
ChangeFileExt(ExtractFileName(InName), '.pdfa-report.txt'));
if Blocked then
Writeln(InName, ': needs a password or a rebuilt source file');
end;
finally
Report.Free;
Lib.Free;
end;
end;
blocked: 行通常意味著解法在 PDF 之外:一個密碼、一份不同的來源檔、或重跑產生文件的東西。完整轉換之後剩下的普通驗證問題,多半是承載意義、不能一刪了之的內容,那些才是需要人做決定的情況
讀取檔案已經帶著的 PDF/A 標記
轉換之前,先查文件對自己的宣稱。掃描器、辦公套件與其他 PDF 工具送來的檔案常常已經帶著 PDF/A 標記;把一份有效的 PDF/A-3 重新轉成部分 2,會白白讓它失去附件
陷阱在於 XMP 允許同一個屬性用兩種寫法。有些產生器把部分寫成子元素 <pdfaid:part>2</pdfaid:part>;有些寫成 rdf:Description 上的屬性 pdfaid:part="2"。兩種都是普通、合法的 XMP。只搜尋其中一種形式的偵測器,會判定另一族產生器的檔案沒帶標記;這樣建的檢查器最後會拿一份有效的 PDF/A-2 去對部分 1 評判、回報它未帶標記且 PDF 版本過高——與事實完全相反。PDF Library for Delphi 的驗證器兩種形式都讀。如果你要自己嗅探標記,也請這麼做:
function DeclaredPDFAPart(Lib: TPDFlib): string;
const
ElemTag = '<pdfaid:part>';
AttrDQ = 'pdfaid:part="';
AttrSQ = 'pdfaid:part=''';
var
Packet: string;
P: Integer;
begin
Result := '';
Packet := Lib.GetDocumentMetadata;
P := Pos(ElemTag, Packet); // 元素形式
if P > 0 then
Exit(Copy(Packet, P + Length(ElemTag), 1));
P := Pos(AttrDQ, Packet); // 屬性形式,雙引號
if P = 0 then
P := Pos(AttrSQ, Packet); // 屬性形式,單引號
if P > 0 then
Result := Copy(Packet, P + Length(AttrDQ), 1);
end;
空結果代表兩種形式都沒有標記。一個數字告訴你產生器想的是哪個部分,決定要不要轉換之前,先拿它與目標比對
檔案進封存之前先淨化指令碼
封存轉換與安全淨化通常一起跑,因為安全政策想除掉的內容,與 PDF/A 禁止的內容高度重疊。SanitizeDocument 接受 SANITIZE_* 旗標的位元遮罩,回報它執行了多少次移除。ConvertToPDFA 自己的第一步就是帶著 SANITIZE_JAVASCRIPT or SANITIZE_OPEN_ACTION 呼叫它
有一個細節對驗證器要緊。文件層級指令碼住在目錄 /Names 字典底下的 /JavaScript 名稱樹裡,最後一個指令碼被移除時,空掉的名稱樹會跟著被移除。空的樹照樣宣告這份文件有指令碼,有些檢查器對那個鍵本身起反應、根本不去數底下有什麼
無論淨化出自哪個工具,請用重新載入的方式驗證存出的檔案,別相信記憶體裡的狀態。重開一次很便宜:
var
Lib, Check: TPDFlib;
begin
Lib := TPDFlib.Create;
Check := TPDFlib.Create;
try
Lib.LoadFromFile('upload.pdf', '');
Lib.SanitizeDocument(SANITIZE_JAVASCRIPT or SANITIZE_OPEN_ACTION);
Lib.SaveToFile('upload-clean.pdf');
Check.LoadFromFile('upload-clean.pdf', '');
if Check.GlobalJavaScriptCount > 0 then
raise Exception.Create('Document-level scripts survived the save');
finally
Check.Free;
Lib.Free;
end;
end;
封存管線裡的鄰近步驟,請見 PDF/A 與 PDF/UA 預檢、真正的密文標註與內容移除,以及 Factur-X 的 PDF/A-3 XMP 延伸綱要——封存文件同時帶結構化發票資料時,中繼資料那一側由它接手
這裡展示的轉換、中繼資料修復與驗證,全部都在你自己的 Delphi、C++Builder 或 Lazarus 行程內執行,鏈中沒有外部轉換器或命令列驗證器。PDF Library for Delphi 產品頁 列出支援的 PDF/A 部分、版本與目標平台