技術文章

HotPDF 中的 PDF 頁面順序錯誤:物理結構與邏輯結構

此症狀出現在基於 HotPDF Component 建置的頁面複製公用程式中:請求三頁文件中的第 1 頁,卻總是產生第 2 頁;檢查索引邏輯沒有發現任何錯誤,該呼叫使用的是以 0 為基底的邏輯索引,計算正確,邊界條件也很好;然而,每次輸出的都是錯誤的頁面

錯誤完全不在複製程式碼中,而是在載入檔案時 HotPDF 如何建置其內部頁面陣列

PDF 頁面順序概念:物理順序與邏輯順序的差異
PDF 頁面順序:Pages 樹中的 /Kids 陣列定義了邏輯順序,與物件在檔案中的編號或儲存方式無關

兩種排序,一個混淆的來源

PDF 檔案是間接物件的集合,每個物件都由一個物件編號來識別;檔案結構並未強制要求這些編號必須反映閱讀順序;物件 1 可以容納第 2 頁,物件 20 可以容納第 1 頁;實際定義閱讀順序的是頁面樹:這是一個 /Pages 字典的階層結構,其 /Kids 陣列列出了檢視器應顯示的頁面參考順序 (ISO 32000-1 §7.7.3)

觸發此錯誤的文件具有以下頁面樹結構:

{ Pages tree root, object 16 }
16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R   { logical page 1 }
         1 0 R    { logical page 2 }
         4 0 R]   { logical page 3 }
>>
endobj

該檔案在位元組串流中恰好將物件 1 和物件 4 列在物件 20 之前;任何以檔案順序反覆運算間接物件並在發現頁面類型字典時將其標記到 PageArr 中的解析器,最終會得到索引 0 為物件 1、索引 1 為物件 4,以及索引 2 為物件 20;邏輯上的第 1 頁位於 PageArr[2],請求頁面索引 0 反而會擷取邏輯上的第 2 頁

這正是 HotPDF 的兩條內部解析路徑所做的事情;用於 PDF 1.3/1.4 檔案的傳統路徑,以及用於物件資料流文件 (PDF 1.5+) 的現代路徑,各自透過在實體檔案順序中遍歷間接物件,而不是遵循 /Kids 鏈結來建置 PageArr

證實假設

在進行任何修正之前,需要先證實此不符,而非憑空假設;qpdf 命令列工具使這項工作變得簡單直觀:

{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }

qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }

單獨擷取每一頁並檢查檔案大小證實了該對應:PageArr[0] 產生的內容屬於邏輯上的第 2 頁,而 PageArr[2] 則持有邏輯上的第 1 頁;循環偏移就是有力證據;這也解釋了為什麼該問題會出現在多個不同的來源文件中:任何頁面物件的物件編號裝好低於較早之邏輯頁面的 PDF 都會觸發此問題

PDF 最終處於此狀態有一個直接的原因;增量儲存會附加具有新物件編號的已更新物件,使交叉引用表中的舊插槽指向無處;添加封面頁的編輯器會以高物件編號插入封面頁,而不管其在 Kids 陣列中的位置如何;某些產生器只是以方便內容串流的順序寫入頁面,而不是以邏輯頁面順序寫入;PDF 格式並不要求它們非得這樣做

修正方法:遵循 Kids 陣列

正確的方法是透過遍歷型錄根部的 /Kids 鏈結來建置 PageArr,而不是透過掃描間接物件;在兩條解析路徑都完成其初始行程之後,一個後處理步驟會解析出邏輯順序:

procedure THotPDF.ReorderPageArrByPagesTree;
var
  PagesObj  : THPDFDictionaryObject;
  KidsArray : THPDFArrayObject;
  NewPageArr: array of THPDFDictArrItem;
  I, J, PageIndex, KidsIndex: Integer;
  RefObj    : THPDFLink;
  PageObjNum: Integer;
  Found     : Boolean;
begin
  { Locate root /Pages dictionary via FRootIndex }
  PagesObj := FindPagesRootFromCatalog;
  if PagesObj = nil then Exit;

  KidsIndex := PagesObj.FindValue('Kids');
  if KidsIndex < 0 then Exit;
  KidsArray := THPDFArrayObject(PagesObj.GetIndexedItem(KidsIndex));

  SetLength(NewPageArr, KidsArray.Items.Count);
  PageIndex := 0;

  for I := 0 to KidsArray.Items.Count - 1 do
  begin
    RefObj     := THPDFLink(KidsArray.GetIndexedItem(I));
    PageObjNum := RefObj.Value.ObjectNumber;

    Found := False;
    for J := 0 to Length(PageArr) - 1 do
    begin
      if PageArr[J].PageLink.ObjectNumber = PageObjNum then
      begin
        NewPageArr[PageIndex] := PageArr[J];
        Inc(PageIndex);
        Found := True;
        Break;
      end;
    end;
    { Non-page Kids (intermediate /Pages nodes) produce no match; skip }
  end;

  if PageIndex > 0 then
  begin
    SetLength(PageArr, PageIndex);
    for I := 0 to PageIndex - 1 do
      PageArr[I] := NewPageArr[I];
  end;
end;

此呼叫會加入每條解析路徑的末尾,也就是在所有物件都已被編目但在任何頁面操作獲得處理之前:

{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;

{ Modern path (object streams) }
if TryParseModernPDF then
begin
  Result := ModernPageCount;
  ReorderPageArrByPagesTree;
  Exit;
end;

重新排序步驟是 O(n * m),其中 n 是 Kids 數量,m 是目前的 PageArr 長度,但對於任何具有扁平頁面樹的文件(所有葉子都在深度 1,這涵蓋了絕大多數實際的 PDF),兩者具有相同的值,開銷可以忽略不計;深層嵌套的頁面樹需要遞迴遍歷,而不是此處顯示的單層方法,生產實作單獨處理該情況

修正後使用 CopyPageFromDocument

在 ReorderPageArrByPagesTree 就緒後,邏輯頁面索引將如預期般運作;高階的 CopyPageFromDocument 接受以 0 為基底的邏輯索引,並將正確的頁面複製到目標文件中:

var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    Source.LoadFromFile('source.pdf');

    Dest.FileName := 'extracted.pdf';
    Dest.BeginDoc;

    { Copy logical page 0 (first page the user sees) }
    Dest.CopyPageFromDocument(Source, 0, 0);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

CopyPageFromDocument 在內部查詢頁面樹順序,而不是依賴原始的 PageArr 索引,因此即使在物理和邏輯順序分歧的文件中,它也能正常運作;對於批次操作,InsertPagesFromDocument 接受邏輯索引陣列並一次性複製它們

這揭示了關於 PDF 解析的什麼內容

PDF 規範非常明確:邏輯頁面順序由頁面樹的 /Kids 陣列定義,而不是由物件編號或位元組偏移量定義 (ISO 32000-1 §7.7.3.2);任何使用不同排序作為捷徑的解析器,都會在其看到的大多數文件上產生正確的結果,因為大多數產生器以自然順序寫入頁面並分配順序物件編號;該錯誤會一直隱藏,直到有人載入了被增量編輯、被另一個工具重新組織或由選擇了不同版面配置的軟體所產生的 PDF

僅針對自行產生的 PDF 進行測試會完全遺漏此類問題;因此,對頁面順序迴歸的修正需要來自不同來源的文件語料庫:增量儲存、插入了封面頁的掃描文件,以及由以不同方式線性化或最佳化物件圖的工具所產生的 PDF;觸發原始錯誤的文件應該永久保留在迴歸測試集中

The HotPDF Component page covers the full API for page operations, including CopyPageFromDocument, InsertPagesFromDocument, and MovePage