你在一家廠商手冊裡加了一個新的標籤範圍,讓附錄顯示 App-a、App-b 而不是 A-1、A-2,存檔、重新開啟。頁面框還是顯示 A-1。用文字編輯器打開 catalog,/PageLabels 字典透露了原因:它帶著一個產生器留下的 /Kids 陣列,旁邊還有一個裝著你新範圍的全新 /Nums 陣列。ISO 32000-1 規定數字樹的根只能有這兩個鍵之一,讀取器在根上找到 /Kids,就往下走、不再他顧
PDF 如何儲存頁面標籤範圍
頁面標籤是檢視器在頁面框裡顯示、用來取代實體頁索引的字串,ISO 32000-1 §12.4.2 把它們存在 catalog 鍵 /PageLabels 底下的數字樹裡。每個鍵是一個以 0 起算的頁索引,標示某個標籤範圍的起點;每個值是一個最多三個條目的標籤字典:/S 是編號樣式(D、R、r、A 或 a),/P 是前綴字串,/St 是範圍首頁的號碼,預設 1。一個範圍延伸到下一個鍵為止。規範還要求頁索引 0 必須有條目,所以文件裡每一頁都落在某個範圍內
在 PDF Library for Delphi 裡,寫入端是 TPDFlib.AddPageLabels(Start, Style, Offset, Prefix),它以三條值得背下來的規則映射到那個字典。Start 與函式庫裡所有頁面參數一樣是 1 起算,進樹時變成 Start - 1。Style 從 0 到 5:0 完全不寫 /S,標籤只有前綴(適合單獨一頁標成 Cover),1 到 5 分別成為 D、R、r、A 與 a。樣式超出 0..5 時呼叫回傳 0,文件分毫不動。Offset 只在大於零時才成為 /St;傳 0 就省略該鍵,所有讀取器都把它解讀為 1
排版工具產生的典型書本需要三個範圍:前言用小寫羅馬數字、正文用阿拉伯數字、附錄加前綴
uses
PDFlibrary;
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 ...(十進位,從 1 重新起算)
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;
頁面標籤是 PDF 1.3 才有的功能,所以每次呼叫也會請求把輸出版本提高到至少 1.3。如果你用 LockSaveVersion 釘住了版本,這個請求會被忽略、鎖定繼續生效,因為函式庫不會推翻你有意選擇的版本。真的要鎖,就在任何會寫標籤的作業裡鎖在 1.3 或以上
根上有 /Kids 時,新範圍為什麼會消失?
範圍消失是因為 ISO 32000-1 §7.9.7(表 37)規定數字樹的根只能帶 /Kids 或 /Nums,絕不兩者皆有,而讀取器見到 /Kids 就選它。產生長文件的產生器常把樹拆成多個中間節點,各帶一對 /Limits,掛在一個只有 /Kids 的根下。只認得 /Nums 的插入常式看著那個根,找不到陣列,就在既有 /Kids 旁邊建一個,把新範圍放進去。寫入成功了,檔案仍然可剖析。然後每個檢視器都沿著 /Kids 走,看到產生器原本的三個範圍,永遠不會碰那個遊離陣列——這正是開頭那段廠商手冊的症狀
出路是別再把根當成一個可能長出第二個入口的容器。AddPageLabels 在根上發現 /Kids 時,先從左到右走過既有樹的每個葉,收集每一個鍵與它的標籤字典,再用這份清單組出一個扁平的 /Nums 陣列。/Kids、/Limits 與任何殘留的 /Nums 都先從根上移除,扁平陣列掛上去之後,新範圍才以普通的排序插入進駐。移除 /Limits 同樣重要:表 37 只允許它出現在中間節點與葉上,留著它的根即使改帶 /Nums,仍是格式錯誤的
有一個副作用很容易漏看。如果到你手上的檔案本來就壞了——某個更早的工具做過這種天真插入,根上同時有兩個鍵——葉走訪會像檢視器一樣沿著 /Kids 走,所以遊離陣列裡的範圍不會被帶過來。檢視器從來也沒顯示過那些範圍,所以編輯後的標籤與讀取器先前所見一致
攤平後的樹不會再重建為平衡的 /Kids 節點,我們認為對這棵特定的樹而言,這是正確判斷。平衡化對有成千上萬目的地或內嵌檔案的 name tree 有價值。頁面標籤樹每個標籤範圍一筆條目,再大的參考手冊也很少超過幾十筆;不為拆而拆的產生器寫出來的也多是單一葉。為這麼小的樹上平衡化,等於在每次存檔時加一段可能出錯的程式碼,換一個誰都量不出來的查找收益
// 在 /PageLabels 根使用 /Kids 的檔案裡,重新貼上附錄的標籤
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
WriteLn('Before: ', Lib.GetPageLabel(121)); // 例如 A-1
// 與既有範圍同一個起始頁,因此其值被取代
if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
begin
WriteLn('After: ', Lib.GetPageLabel(121)); // App-a
WriteLn('Front: ', Lib.GetPageLabel(2)); // ii,沒變
Lib.SaveToFile('vendor-manual-relabeled.pdf');
end;
end;
因為新範圍的起始頁與既有鍵相同,插入會取代那個鍵的字典,而不是為頁索引 120 加第二筆條目。來自產生器那些葉的羅馬與十進位範圍,帶著原字典原封不動地傳承下來,包括任何 /St 值
葉走訪還得在格式錯誤的樹上活下來。指回自己某個祖先的 /Kids 條目,或兩個分支共用的葉,在受損管線產出的檔案裡都出現過。走訪會記住已造訪過的節點,循環走一次就被丟在腦後,共用的葉也只貢獻一次它的範圍。name tree 的同類問題,見處理 name tree 循環、壞 /Limits 與大型葉一文
成對走訪 /Nums 陣列
/Nums 陣列是一段扁平的交錯條目 [key0 value0 key1 value1 ...],只有偶數位置是鍵。這種版面招來一個經典錯誤。一次走一個元素、逢元素就問「這是數字嗎」的迴圈,只要某個值恰好是數值,就會把值當鍵來比較。這個比較一旦在小於測試中勝出,插入點就落在奇數索引上,新的一對塞進既有那一對的中間,後面所有的鍵與值全部錯位
頁面標籤的值是字典,所以這個 bug 藏在 /PageLabels 本身身上。等到同一個輔助函式被拿去用在值為純數字的數字樹上,問題才會浮現,而陣列的語法裡沒有任何東西提醒你手上是哪一種。PDF Library for Delphi 在葉走訪與插入裡都按 X * 2 讀鍵、按 X * 2 + 1 讀值,鍵完全相符時取代該值並停止掃描。你要是對著原始物件自己寫數字樹程式碼,第一件事就是把這個步幅抄過去
用 GetPageLabel 讀回標籤
TPDFlib.GetPageLabel(Page) 回傳 1 起算頁碼的標籤,它的兩個後備行為是刻意不同的。完全沒有 /PageLabels 條目的文件回傳十進位頁碼,呼叫端可以無條件拿來顯示。有樹、但沒有範圍覆蓋所請求頁面的文件回傳空字串。第二種情況正是檔案漏掉頁索引 0 的必要條目時的產出:頭幾頁在規範意義上沒有標籤,空字串比一個編造的號碼誠實。標籤集永遠從第 1 頁開始寫
字母樣式遵循 PDF 規範,而不是試算表的欄位排法。Z 之後是 AA,然後 BB、CC;字母重複而不是進位,所以 A 範圍的第 53 頁是 AAA。羅馬數字在 R 產生大寫、在 r 產生小寫,前綴在兩種情況下都原樣加在前面
var
P: Integer;
Data: WideString;
begin
// 快速稽核檢視器頁面框裡會顯示什麼
for P := 1 to Lib.PageCount do
WriteLn(P, ' -> ', Lib.GetPageLabel(P));
// 選項值 4 只匯出標籤範圍,每個範圍一個
// PageLabelBegin 區塊
Data := Lib.ExportDocumentData(4);
// ... 編輯這段文字,或帶到另一個檔案 ...
// 第一筆標籤記錄會清掉既有範圍,然後每一筆記錄
// 都經由 AddPageLabels 重放;回傳套用的記錄數
WriteLn(Lib.ImportDocumentData(Data, 4), ' label ranges applied');
end;
批次編輯時,ExportDocumentData 以選項值 4 把每個範圍寫成一個 PageLabelBegin 區塊,後面跟著 PageLabelNewIndex(1 起算起始頁)、PageLabelStart、選填的 PageLabelPrefix 與 PageLabelNumStyle 各行,名稱形如 LowercaseRomanNumerals 與 DecimalArabicNumerals。ImportDocumentData 把遇到的第一筆標籤記錄視為完整取代:先呼叫一次 ClearPageLabels,把整個 /PageLabels 條目從 catalog 移除,再把每一筆記錄餵給 AddPageLabels。重建的樹從一開始就是單一葉,所以原本呈 /Kids 形狀的檔案,經過一次文字來回轉換,回來的也是重新貼標路徑產出的那種扁平形狀
重新貼標之後的葉順序與頁面搬移
攤平按發現順序保留各範圍。§7.9.7 要求鍵按遞增排列,但葉順序錯亂的檔案確實在市面上流傳,而 GetPageLabel 套用該順序中鍵小於等於頁索引的最後一個範圍。一份宣告範圍為 [0 roman, 120 A-, 4 decimal]、十進位範圍從 1 起算的檔案,第 122 頁會解析到十進位範圍,讀出 118,而你預期的是 A-2。若上面的逐頁稽核顯示這類標籤,別再多打一通 AddPageLabels 去縫補。呼叫 ClearPageLabels,按遞增順序把整套範圍重寫一遍;四通呼叫,比推理哪個範圍會贏便宜得多
標籤綁的是頁索引,不是頁物件。任何改變頁數或頁序的操作,都不會動到那些範圍,它們繼續指向當下坐在那些索引上的頁面。保留物件編號的頁面替換這類原地交換保持頁數,標籤因此對齊如故。像交錯雙面掃描的整理合併這種合併會產生新的頁序列,其輸出應該在合併後重新寫一整套範圍,從第 1 頁開始
這裡展示的標籤呼叫、/Kids 攤平與文件資料匯出匯入,都是 PDF Library for Delphi 的一部分,該產品支援 Delphi、C++Builder 與 Lazarus;其參考文件裡 AddPageLabels 的條目把樣式值與回傳碼列在同一張表上