PDFlibPas(PDF Library for Delphi)在寫檔之前,會把每個物件對照一張 PDF 版本規則表檢查,而不久之前,這套 PDF 版本預檢還把普通的 CAD 量測字典誤認成地理空間字典。一份單頁 CAD 圖載入正常,SaveToFile 卻回傳 0、LastErrorCode 是 602,還要求 1.7 ExtensionLevel 3。修正後的規則把直線(rectilinear)/Measure 字典(/Subtype /RL)當成普通的 PDF 1.6,擴充閘門只留給真正的地理空間標記
檔案是從語料收錄進來的:一頁、一個 optional-content 群組、兩個直線量測 viewport,是建築 CAD 套件寫出的那種輸出,好讓檢視器能從平面圖上讀出距離。它毫無奇異之處,而這正是這個拒絕要命的原因。擋下有效檔案的預檢,比慢的預檢更糟,因為呼叫端拿到的是一份看起來權威的診斷,指著文件裡根本沒有的功能。修法分兩部分:一條規則背後的規格解讀,以及一個認知,在它原本看的層級,規則根本分不出兩種字典
PDFlibPas 的存檔版本預檢是怎麼運作的?
存檔閘門 PrepareAndCheckSaveVersion 把每個間接物件對照 PDFFeatureRules 比對,第一條既命中、又需要超過目標所允許的規則就判失敗。目標是文件版本(或 LockSaveVersion 釘住的版本),加上 /Extensions /ADBE 底下宣告的 Adobe 擴充等級。每筆 TPDFFeatureRule 記錄帶一個 MinVersion、一個 MinExtensionLevel、一個像 fmkDictKey 或 fmkDictSubtype 的 MatchKind、一個 Match 字串、一個人類可讀的 Feature 名稱,以及可選的回呼。AddRule 註冊普通版本規則;AddExtensionRule 一律把 MinVersion 釘在 17、再往上加擴充等級,所以擴充規則只有 PDF 1.7 加上正確的 /Extensions 條目才可能滿足。閘門絆倒時,所需的版本與功能名稱會留給呼叫端,GetInformation 的鍵 311、312 與 313 把它們暴露出來
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('floor-plan.pdf', '') <> 1 then
raise Exception.Create('load failed');
if Pdf.SaveToFile('floor-plan-out.pdf') <> 1 then
if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
// 311:所需版本,312:觸發它的功能,
// 313:存檔目標鎖定的版本(未鎖時為 '')
Writeln('Needs ', Pdf.GetInformation(311),
' for ', Pdf.GetInformation(312),
', locked at [', Pdf.GetInformation(313), ']');
finally
Pdf.Free;
end;
end;
一份普通的 CAD 圖為什麼會以錯誤 602 失敗?
規則表裡有一條 AddExtensionRule(3, fmkDictKey, 'Measure', '/Measure geospatial dictionary', Nil),任何字典只要帶 /Measure 鍵就命中,而每個量測 viewport 都有一個。頁面的 /VP 陣列裝著 viewport 字典,每個 viewport 透過 /Measure 指向它的量測字典,而那個鍵存在與否的比對就停在這裡,不看量測字典實際上是什麼。載入時的功能掃描於是把文件版本號拉到 1.7,但它從不替輸入檔案代寫 /Extensions 宣告,所以存檔閘門看到的是擴充等級 0 的 PDF 1.7,於是回報 1.7 ExtensionLevel 3。拒絕憑空發明擴充宣告是刻意的:函式庫不會悄悄把輸入檔案升級,去掩蓋一條錯的規則
規格對直線這個情況說得一清二楚。量測字典是 PDF 1.6 引進的,ISO 32000-1 §12.9 給 /Subtype 的預設值是 RL,一種由自己那組條目描述的直角座標系:比例尺、X 與 Y 的數字格式、距離與面積。地理空間量測是 Adobe Extension Level 3 疊在 PDF 1.7 之後的添加,以 /Subtype /GEO 識別,帶著地理點陣列、座標系字典與顯示單位,也就是在 Delphi 中讀取 GeoPDF viewport、GPTS 與 LPTS 陣列走過的那些結構。兩種字典都掛在同一個 /Measure 鍵下,所以任何停在鍵上的規則都不可能對兩者都對。區分的資訊在下一層,在量測字典本身裡
修正後的規則集仍然強制什麼?
修法刪掉了無條件的鍵規則,留下描述真實版本需求的閘門。帶 /VP 或 /UserUnit 的頁面仍然需要 PDF 1.6,走 CB_PagePDF16Entries;/PtData 鍵仍然需要擴充等級 3;而 CB_GeospatialDictionary 按內容、而不是按抵達它的那個鍵,判定一個量測字典是不是地理空間
// 已移除:任何帶 /Measure 鍵的字典都被當成地理空間
// AddExtensionRule(3, fmkDictKey, 'Measure', '/Measure geospatial dictionary', Nil);
AddRule(16, fmkCustom, '', 'Page PDF 1.6 entry /UserUnit /VP', CB_PagePDF16Entries);
AddExtensionRule(3, fmkDictKey, 'PtData', '/PtData geospatial dictionary', Nil);
AddExtensionRule(3, fmkCustom, '', 'geospatial measure dictionary', CB_GeospatialDictionary);
function CB_GeospatialDictionary(Obj: TPDFObject; const Ctx: TPDFRuleContext): Boolean;
var
Dict: TPDFDictionary;
begin
Result := False;
if not (Obj is TPDFDictionary) then
Exit;
Dict := TPDFDictionary(Obj);
Result := (Dict.StringValue('Subtype') = 'GEO') or
(Dict.FindIndexByKeyName('GCS') >= 0) or (Dict.FindIndexByKeyName('DCS') >= 0) or
(Dict.FindIndexByKeyName('GPTS') >= 0) or (Dict.FindIndexByKeyName('LPTS') >= 0) or
(Dict.FindIndexByKeyName('PDU') >= 0);
end;
Delphi 與 FPC 共用的迴歸從兩側釘住這個邊界。量測字典省略 /Subtype 的 viewport、與明寫 /RL 的 viewport,都在 PDF 1.6 通過;同一頁在 PDF 1.5 仍然被拒;功能偵測也不再為它回報擴充。加進一個 /GPTS 陣列會把判定翻回 1.7 ExtensionLevel 3,宣告擴充等級之後就能通過;而光禿禿的 /Subtype /GEO 字典沒有它就遭拒。回呼的設計偏保守:一個直線字典若還帶著游離的 /GCS 或 /PDU 鍵,會被當成地理空間,因為這些鍵在 RL 模型裡沒有意義
LockSaveVersion 是這個改動對呼叫端可見的地方。TPDFlib.LockSaveVersion 接受 '1.0' 到 '1.7',其他字串回傳 0,釘住文件版本、擋下寫入端悄悄拉高的呼叫,但存檔閘門仍然對著鎖定的值跑。規則修正之後,鎖在 1.6 的 CAD 檔案乾淨地存出。真正的 GeoPDF 鎖在 1.6 照樣拿到 602,這是正確的答案;而 SetMeasureDictCoordinateSystem 這類地理空間寫入呼叫,當您透過 API 建構那類內容時會自己宣告擴充等級 3
if Pdf.LockSaveVersion('1.6') <> 1 then
raise Exception.Create('unsupported version string');
if Pdf.SaveToFile('floor-plan-16.pdf') <> 1 then
begin
if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
// 真正超出 1.6 的內容,例如 GEO 量測字典
raise Exception.CreateFmt('Locked at 1.6 but %s needs %s',
[string(Pdf.GetInformation(312)), string(Pdf.GetInformation(311))]);
end;
Pdf.UnlockSaveVersion;
版本規則掃描為什麼比必要的還慢?
掃描在測試之前把每筆 TPDFFeatureRule 拷貝進一個局部記錄,而這個記錄有兩個 AnsiString 欄位,每份拷貝都要調整兩個參照計數、釋放前一組值。預檢走訪每個物件樹的每個節點,純量也算,所以這筆開銷是物件數乘上規則數,而且不適用於目標版本的規則也是先拷貝、後跳過。既然 PDFFeatureRules 在單元初始化時填一次、之後當成唯讀,v3.539.17 就把表條目直接傳給 MatchSingleRule 與 RuleExceedsTarget,它們的 const Rule 參數拿的是參照,不碰字串
// 之前:每條規則、每個走訪物件,一次受管記錄拷貝
Rule := PDFFeatureRules[X];
if not RuleExceedsTarget(Rule, TargetVersion, TargetExtensionLevel) then
Continue;
// 之後:const 參數原位讀取不可變的表條目
if not RuleExceedsTarget(PDFFeatureRules[X], TargetVersion, TargetExtensionLevel) then
Continue;
if MatchSingleRule(Obj, Ctx, PDFFeatureRules[X]) then
begin
RequiredVersion := RequiredVersionString(PDFFeatureRules[X]);
FeatureName := PDFFeatureRules[X].Feature;
Result := False;
Exit;
end;
量到的效果很窄,引用時也該這麼說。基準測試把 20,000 個數值物件組成的陣列對 PDF 1.4 目標檢查,每輪十次;用 FPC Win64 在 -O2 下建置,五輪的中位數從 0.711 秒降到 0.203 秒,兩個建置反序再跑一次得到 0.459 秒對 0.150 秒。這大致是規則比對路徑上 3 倍的收益。真實的存檔還要付延遲功能偵測、物件解碼與序列化的錢,所以比值不會原樣帶到總存檔時間上。規則順序、回呼、版本門檻與首個失敗的診斷都不變,也沒有為了這個結果快取任何規則或跳過任何檢查
載入的 PDF 沒過版本預檢時,該檢查什麼?
先讀鍵 311 與 312,再去動版本。如果功能名稱指向地理空間字典、而檔案只畫直線量測,那就是這個誤報,現在的建置會原樣存出檔案。如果功能是真的,要嘛宣告擴充,要嘛鎖到一個誠實涵蓋內容的版本;只為了讓閘門閉嘴而拉高版本,是在迴避一個問題:下游的消費者讀不讀得了您出貨的東西。工程文件的 PDF/E-1 作者模式預檢貫徹同一個原則:有邊界、有證據的檢查,在那裡 CAD 圖面對的是符合性標準,而不是一個版本號
版本合規檢查、量測與地理空間字典,以及存檔版本鎖定,都是 PDF Library for Delphi——面向 Delphi、C++Builder 與 Lazarus 開發者的 PDFlibPas 工具組的一部分