Bài viết kỹ thuật

Lỗi thứ tự trang PDF trong HotPDF: Cấu trúc vật lý so với Cấu trúc logic

Triệu chứng xuất hiện trong một tiện ích sao chép trang được xây dựng dựa trên HotPDF Component: việc yêu cầu trang 1 của một tài liệu có ba trang luôn luôn tạo ra trang 2. Việc kiểm tra logic lập chỉ mục không tìm thấy lỗi nào. Lời gọi đã sử dụng một chỉ mục logic dựa trên cơ sở 0 (0-based logical index), số học đã chính xác, các điều kiện biên (boundary conditions) đều ổn. Thế nhưng lần nào cũng cho ra sai trang

Lỗi này hoàn toàn không nằm ở mã sao chép. Nó nằm ở cách HotPDF xây dựng mảng trang nội bộ (internal page array) của nó khi tải tệp

Khái niệm về thứ tự trang PDF: sự khác biệt giữa thứ tự vật lý và thứ tự logic
Thứ tự trang PDF: mảng /Kids trong cây Pages xác định chuỗi logic, độc lập với cách các đối tượng được đánh số hoặc lưu trữ trong tệp

Hai cách sắp xếp, một nguồn gốc của sự nhầm lẫn

Một tệp PDF là một tập hợp các đối tượng gián tiếp (indirect objects), mỗi đối tượng được xác định bằng một số thứ tự đối tượng. Cấu trúc tệp không áp đặt nghĩa vụ nào lên những con số đó để phản ánh thứ tự đọc. Đối tượng 1 có thể chứa trang 2; đối tượng 20 có thể chứa trang 1. Thứ thực sự định nghĩa thứ tự đọc là cây trang (page tree): một hệ thống phân cấp của các từ điển /Pages mà mảng /Kids của chúng liệt kê các tham chiếu trang theo chuỗi mà một trình xem nên hiển thị chúng (ISO 32000-1 §7.7.3)

Tài liệu gây ra lỗi có cấu trúc cây trang như sau:

{ 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

Tệp này tình cờ liệt kê đối tượng 1 và đối tượng 4 trước đối tượng 20 trong luồng byte. Bất kỳ trình phân tích cú pháp (parser) nào lặp qua các đối tượng gián tiếp theo thứ tự tệp và đóng dấu chúng vào một PageArr ngay khi nó tìm thấy các từ điển loại-trang (page-type dictionaries) sẽ kết thúc với đối tượng 1 ở chỉ mục 0, đối tượng 4 ở chỉ mục 1, và đối tượng 20 ở chỉ mục 2. Trang logic 1 nằm tại PageArr[2]. Việc yêu cầu chỉ mục trang 0 lại tìm nạp (fetch) trang logic 2

Đó chính xác là những gì mà cả hai đường dẫn phân tích cú pháp nội bộ của HotPDF đã làm. Đường dẫn truyền thống, được sử dụng cho các tệp PDF 1.3/1.4, và đường dẫn hiện đại, được sử dụng cho các tài liệu luồng-đối tượng (PDF 1.5+), mỗi đường dẫn đều xây dựng PageArr bằng cách bước qua các đối tượng gián tiếp theo thứ tự tệp vật lý thay vì theo chuỗi /Kids

Xác nhận giả thuyết

Trước khi chạm vào bất kỳ bản sửa lỗi nào, sự không khớp (mismatch) này cần phải được chứng minh thay vì giả định. Công cụ dòng lệnh qpdf làm cho điều này trở nên đơn giản:

{ 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 }

Việc trích xuất từng trang riêng lẻ và kiểm tra kích thước tệp đã xác nhận sự ánh xạ (mapping): những gì PageArr[0] tạo ra là nội dung thuộc về trang logic 2, và PageArr[2] chứa trang logic 1. Sự dịch chuyển vòng tròn này là bằng chứng rõ ràng (smoking gun). Điều này cũng giải thích tại sao sự cố lại xuất hiện trên nhiều tài liệu nguồn khác nhau: bất kỳ tệp PDF nào nơi các đối tượng trang tình cờ có số lượng đối tượng thấp hơn so với một trang logic sớm hơn sẽ kích hoạt nó

Có một lý do đơn giản giải thích vì sao các tệp PDF lại rơi vào trạng thái này. Tính năng lưu tăng dần (incremental saves) sẽ nối (append) các đối tượng được cập nhật với các số thứ tự đối tượng mới, để lại các khe cũ trong bảng tham chiếu chéo không trỏ tới đâu cả. Các trình soạn thảo khi thêm một trang bìa sẽ chèn nó vào bằng một số thứ tự đối tượng cao bất kể vị trí của nó trong mảng Kids. Một số trình tạo (generators) chỉ đơn giản là viết các trang theo một thứ tự thuận tiện cho việc phát luồng nội dung chứ không phải theo trình tự trang logic. Định dạng PDF không yêu cầu họ phải làm khác đi

Cách khắc phục: bám theo mảng Kids

Cách tiếp cận đúng đắn là xây dựng PageArr bằng cách bước theo chuỗi /Kids từ gốc catalog, chứ không phải bằng cách quét các đối tượng gián tiếp. Sau khi cả hai đường dẫn phân tích cú pháp hoàn thành lượt đầu tiên, một bước hậu kỳ (post-processing) sẽ giải quyết thứ tự logic:

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;

Lời gọi này đi vào ở phần cuối của mỗi đường dẫn phân tích cú pháp, sau khi tất cả các đối tượng đã được đưa vào danh mục (catalogued) nhưng trước khi bất kỳ thao tác trang nào được phục vụ:

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

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

Bước sắp xếp lại có độ phức tạp là O(n * m) trong đó n là số lượng Kids và m là độ dài PageArr hiện tại, nhưng đối với bất kỳ tài liệu nào có một cây trang phẳng (tất cả các lá đều ở độ sâu 1, bao gồm đại đa số các tệp PDF trong thế giới thực) thì cả hai đều có cùng giá trị và chi phí là không đáng kể. Cây trang lồng ghép sâu (deeply nested page trees) đòi hỏi một bước đi đệ quy (recursive walk) thay vì cách tiếp cận đơn cấp (single-level) như được hiển thị ở đây; việc triển khai sản xuất xử lý trường hợp đó một cách riêng biệt

Sử dụng CopyPageFromDocument sau khi đã sửa lỗi

Với việc ReorderPageArrByPagesTree đã được đưa vào vị trí, các chỉ số trang logic sẽ hoạt động như mong đợi. Hàm CopyPageFromDocument ở cấp cao hơn sẽ nhận một chỉ mục logic trên cơ sở 0 (0-based logical index) và sao chép đúng trang vào tài liệu đích:

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 thực hiện truy vấn nội bộ đối với thứ tự cây trang thay vì dựa vào chỉ mục PageArr thô, nhờ vậy nó hoạt động chính xác ngay cả đối với các tài liệu nơi mà thứ tự vật lý và logic phân kỳ. Đối với các hoạt động hàng loạt (batch operations), InsertPagesFromDocument chấp nhận một mảng các chỉ số logic và sao chép chúng trong một lần xử lý (one pass)

Điều này tiết lộ điều gì về việc phân tích cú pháp PDF

Thông số kỹ thuật của PDF nói rất rõ ràng: thứ tự trang logic được xác định bởi mảng /Kids của cây trang, chứ không phải bởi các số thứ tự đối tượng hoặc độ lệch byte (ISO 32000-1 §7.7.3.2). Bất kỳ trình phân tích cú pháp nào sử dụng cách sắp xếp khác đi như một đường tắt sẽ tạo ra kết quả chính xác trên phần lớn các tài liệu mà nó thấy, bởi vì hầu hết các trình tạo đều viết các trang theo thứ tự tự nhiên và gán các số đối tượng một cách tuần tự. Lỗi này sẽ ẩn mình cho đến khi ai đó tải một tệp PDF được chỉnh sửa tăng dần (incrementally edited), được tổ chức lại bởi một công cụ khác, hoặc được tạo ra bởi một phần mềm chọn bố cục khác

Việc chỉ kiểm thử trên các tệp PDF tự-tạo ra (self-generated) sẽ bỏ sót hoàn toàn nhóm vấn đề này. Do đó, việc khắc phục cho sự hồi quy thứ tự trang (page ordering regression) cần một kho tài liệu từ nhiều nguồn khác nhau: các bản lưu tăng dần (incremental saves), các tài liệu quét với các trang bìa được chèn vào, các tệp PDF được tạo ra bởi các công cụ tuyến tính hóa hoặc tối ưu hóa đồ thị đối tượng một cách khác nhau. Một tài liệu đã kích hoạt lỗi ban đầu cần được giữ lại trong bộ kiểm thử hồi quy một cách vĩnh viễn

Trang HotPDF Component bao gồm toàn bộ API cho các thao tác trên trang, bao gồm CopyPageFromDocument, InsertPagesFromDocument, và MovePage