PDF Library for Delphi 用 AddPageLabels 寫入頁面標籤範圍,而從 v3.539.10 起,這個呼叫也能用在已載入、且 /PageLabels 數字樹被拆成 /Kids 節點的檔案上:新的範圍塞進去之前,根節點會先被攤平成單一 /Nums 葉,於是標籤真的會顯示在檢視器裡,而不是被默默無視。典型的受害者是排版工具產出的書籍式 PDF,前言用羅馬數字、正文用阿拉伯數字、附錄標成 A-1、A-2,而您只想重標附錄,結果什麼都沒變
PDF 頁面標籤是什麼,又是怎麼儲存的?
頁面標籤是檢視器在頁面框裡顯示的字串,用來取代實體頁索引,ISO 32000-1 §12.4.2 把它們存在 catalog 鍵 /PageLabels 底下的數字樹裡。每個鍵是一個以 0 起算的頁索引,標記一個標籤範圍的起點;每個值是一個頁面標籤字典,最多三個條目:/S 是編號樣式(D、R、r、A 或 a),/P 是前綴字串,/St 是範圍內第一頁的數值,預設 1。一個範圍延伸到下一個鍵為止,規格要求樹裡必須有頁索引 0 的值,所以每個頁面都被某個範圍覆蓋
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
Exit;
// 第 1-4 頁:i、ii、iii、iv(小寫羅馬數字)
Lib.AddPageLabels(1, 3, 1, '');
// 第 5-120 頁:1、2、3 ...(十進位)
Lib.AddPageLabels(5, 1, 1, '');
// 第 121 頁起:A-1、A-2 ...(十進位加前綴)
Lib.AddPageLabels(121, 1, 1, 'A-');
WriteLn(Lib.GetPageLabel(5)); // 1
WriteLn(Lib.GetPageLabel(122)); // A-2
Lib.SaveToFile('handbook-labeled.pdf');
finally
Lib.Free;
end;
end;
TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) 的參數怎麼映射到那個字典,知道三條規則之後就毫無意外。Start 跟函式庫裡所有頁面參數一樣從 1 起算,寫進樹裡時是 Start - 1。Style 範圍 0 到 5,0 代表只有前綴,1 到 5 對應 /S 值 D、R、r、A、a;範圍之外一律回傳 0、什麼都不動。Offset 只有大於零時才寫成 /St,傳 0 就直接省略這個鍵,檢視器退回預設值 1。因為頁面標籤是 PDF 1.3 才有的,這個呼叫還會跑 EnsureMinVersion('1.3', '/PageLabels'),把較舊檔案的輸出版本拉上來,除非您已經明確鎖定了儲存版本
樹裡有 /Kids 時,新的頁面標籤為什麼會消失?
因為 ISO 32000-1 §7.9.7(表 37)規定數字樹的根只能帶 /Kids 或 /Nums 其中之一,不能兩者都有,而早期的 NumTreeSet 輔助函式只會找 /Nums。產出長文件的製造器常把樹拆成中間節點,各帶一對 /Limits,再掛到一個只有 /Kids 的根上。舊程式碼在那個根上找不到 /Nums,就在既有 /Kids 旁邊新建一個,把新範圍插進去。結果是一個帶著兩個互斥入口的根。檢視器沿 /Kids 下行、永遠不看那個多餘的陣列,函式庫自己的 EnumNumTree 也是先查 /Kids,而 NumTreeLookup 會拒絕 HasKids xor HasNums 不成立的節點。AddPageLabels 照樣回傳 1,存出的檔案照樣正常打開,這是最糟糕的一種失敗:沒有任何抱怨,標籤就是不動
NumTreeSet 裡的修法在插入任何東西之前,先把根轉成葉。根帶 /Kids 時,EnumNumTree 按順序走過每個葉、收集每一對鍵與值,用這份清單建出新的扁平 /Nums 陣列,然後在掛上扁平陣列之前,把 /Kids、/Limits 與任何過期的 /Nums 從根上清掉。丟掉 /Limits 不是做表面工夫,因為表 37 只允許中間節點與葉節點帶這個條目,根永遠不行。從那之後,插入就是對單一陣列的普通有序插入,既有範圍連同它們原本的標籤字典活了下來。這個取捨是刻意的:事後不把樹重建回平衡的 /Kids 節點。對頁面標籤來說這沒有代價,因為再大的參考手冊也很少超過幾十個範圍,而且多數製造器本來就寫單一葉
// 替 /PageLabels 根用 /Kids 的檔案重標附錄
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
WriteLn('Before: ', Lib.GetPageLabel(121)); // e.g. A-1
// 取代從第 121 頁開始的範圍:App-a、App-b ...
if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
Lib.SaveToFile('vendor-manual-relabeled.pdf');
// 既有的羅馬與十進位範圍仍留在攤平後的葉裡
WriteLn('After: ', Lib.GetPageLabel(121)); // App-a
WriteLn('Front: ', Lib.GetPageLabel(2)); // ii,沒變
end;
/Nums 陣列怎麼會被誤讀成鍵?
當程式碼一次走一個元素地讀 /Nums 陣列,就會誤讀,因為這個陣列是扁平的交錯對 [key0 value0 key1 value1 ...],只有偶數位置才是鍵。舊的 NumTreeSet 迴圈對每個元素測數值型別,所以碰巧是數字的值會被當成鍵來比較;一次小於命中可能把插入點設到奇數索引,把新的一對丟進既有某對的中間,讓之後每一對都錯位。EnumNumTree 有一樣的單步走訪。兩者現在都以步距 2 成對迭代,在 X * 2 讀鍵、在 X * 2 + 1 讀值,鍵完全相等就取代該值並以 Break 退出。平心而論,頁面標籤的值是字典,所以第二個 bug 在 /PageLabels 本身上很少觸發,但一個步距讀錯的數字樹輔助函式,在任何值是數字的瞬間就是損壞,而它也在同一輪裡被修了
讀回標籤與來回轉換
TPDFlib.GetPageLabel(Page) 回傳 1 基頁碼的標籤,有兩個值得知道的後備行為。完全沒有 /PageLabels 條目時,它回傳十進位頁碼,呼叫端可以無條件使用。有樹、但沒有範圍蓋到該頁時,它回傳空字串,這正是檔案漏掉強制的索引 0 條目時會發生的事;參考文件說要有一個從頁 1 開始的範圍,標籤才能正確顯示,程式碼把這個要求變成了看得見的行為。字母樣式跟規格走、而不是跟試算表的欄位走:Z 之後是 AA,再來 BB,重複字母而不進位
var
P: Integer;
Data: WideString;
begin
// 快速稽核檢視器頁面框裡會顯示什麼
for P := 1 to Lib.PageCount do
WriteLn(P, ' -> ', Lib.GetPageLabel(P));
// 選項值 4 只匯出標籤範圍,成為 PageLabelBegin 記錄
Data := Lib.ExportDocumentData(4);
// 匯入時透過 ClearPageLabels + AddPageLabels 重放
Lib.ImportDocumentData(Data, 0);
end;
批次編輯時,ExportDocumentData 用選項值 4 把每個範圍寫成帶 PageLabelNewIndex、PageLabelStart、PageLabelPrefix 與 PageLabelNumStyle 行的 PageLabelBegin 區塊,而 ImportDocumentData 把看到的第一條標籤記錄當成完整取代:先呼叫一次 ClearPageLabels,再把每條記錄餵給 AddPageLabels。這讓文字的來回轉換是確定性的,即使原始檔案用的是 /Kids 樹,因為清除移除的是整個 catalog 條目,重建的樹從一開始就是單一葉
這個修法仍然不保證什麼?
攤平是單向的,而且信任它找到的順序。EnumNumTree 按檔案順序收集成對資料,GetPageLabel 套用鍵小於等於頁索引的最後一個範圍,所以一棵葉子順序錯亂的外來樹,§7.9.7 禁止這種樹,但市面上確實在流傳,仍可能給出錯的標籤,直到您用 ClearPageLabels 加全新的 AddPageLabels 重建範圍。標籤也綁在頁索引上、不是頁物件上,所以任何改變頁數或頁序的操作都會讓範圍留在原地。保留物件編號的頁面替換這類原位交換維持了頁數,標籤因此仍然對齊;而交錯雙面掃描的合併排序這類合併產生新的頁面序列,值得配一套重新寫過的範圍
這裡描述的頁面標籤呼叫、數字樹處理與文件資料匯出匯入,都隨 PDF Library for Delphi 出貨,支援 Delphi、C++Builder 與 Lazarus,AddPageLabels 的參考條目記載了樣式值與回傳碼