Bài viết kỹ thuật

Chỉ mục object PDF thưa và lazy trong Delphi với PDFiumPas

Bạn chỉ cần một dictionary từ một PDF 2 GB mà công cụ trước tiên lại bung toàn bộ bảng cross-reference thành một mảng cỡ theo trailer /Size. PDFiumPas thay bước đó bằng một chỉ mục object thưa, lazy: nó chỉ giữ các descriptor section xref, phân giải một số object theo yêu cầu qua các cửa sổ có chặn, và chỉ cache những entry bạn thực sự chạm tới

Dáng cũ của đoạn code này trong FPdfCompress là trung thực nhưng đắt. ApplyDefaultOpenAction đọc trọn tệp vào một TBytes, rồi cấp phát một mảng TPdfActiveXrefEntries dày đặc với một slot cho mỗi số object lên tới /Size. Hai thứ hỏng ở quy mô lớn. Chi phí đọc tăng tuyến tính theo kích thước tài liệu ngay cả khi caller chỉ cần bốn dictionary, và mảng dày đặc va vào ngân sách của parser: TPdfParserResourceBudget.Default đặt MaxObjects thành 4.000.000, nên một tệp hoàn toàn hợp lệ mà số object cao nhất nằm trên trần đó bị từ chối vì lý do bộ nhớ chứ không phải vì lý do đúng sai

Chỉ mục object PDF thưa lazy của PDFiumPas trong Delphi so với mảng cross-reference dày đặc: đường dày đặc đọc cả tệp và cấp phát một slot cho mỗi số object tới cỡ trailer, còn đường thưa chỉ giữ descriptor section
Chỉ descriptor nằm trong bộ nhớ, entry nằm lại trong tệp, và mọi lần đọc đi qua một cửa sổ một mebibyte có chặn

Vì sao API công khai của PDFium không trả lời câu hỏi này?

Vì thông tin nằm bên trong PDFium nhưng không bao giờ vượt qua biên C. CPDF_Parser giữ bảng cross-reference, tư cách thành viên object stream và thứ tự ưu tiên revision nội bộ, vậy mà các header công bố không lộ entry point nào nhận một số object rồi trả về offset thô, generation, revision nào thắng, hay nó sống trong ObjStm nào. Phía lưu cũng khép kín tương tự: FPDF_SaveAsCopyFPDF_SaveWithVersion chỉ đưa cho bạn một callback ghi tuần tự. Vì thế mọi vá cấp byte lên một catalogue sau khi save thuần PDFium phải dựng trong tầng Pascal, và đó là lý do PDFiumPas tự parse các cấu trúc này thay vì dùng lại DLL

Chỉ mục thưa thực sự giữ gì trong bộ nhớ?

Descriptor, chứ không phải entry. Với bảng cổ điển (ISO 32000-1 §7.5.4), một TPdfSparseXrefSubsection lưu số object đầu, số lượng object, byte offset nơi các hàng entry bắt đầu và độ rộng entry đo được. Bản thân entry nằm lại trong tệp. Độ rộng được đo từ hàng đầu tiên chứ không giả định là 20 byte, vì các nhà sản xuất không thống nhất về ký tự xuống dòng; PDFiumPas nhận 18 tới 64 và từ chối mọi thứ ngoài dải đó, cùng với bất kỳ subsection nào có count khai báo vượt quá cuối stream. Với cross-reference stream (§7.5.8), section giữ ba độ rộng trường /W, mỗi cái giới hạn từ 0 tới 8, các cặp /Index đã làm phẳng, và các byte entry đã giải mã, với chiều dài kỳ vọng được tính từ /W/Index trước khi một byte nào được inflate

Toàn bộ chỉ mục được dựng bởi Initialize từ một cửa sổ đuôi tối đa 1 MiB — chỗ tìm thấy startxref — và mọi lần đọc object sau đó dùng một cửa sổ object 1 MiB. Trần raw stream là 64 MiB và một dòng xref không được vượt quá 1024 byte. Nếu bạn đã đọc ghi chú của chúng tôi về xác thực object stream và cross-reference stream với PDFiumPas, kỷ luật độ rộng trường y hệt áp dụng ở đây, chỉ khác là giờ nó dùng để định địa chỉ một entry thay vì kiểm toán cả một bảng

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { chỉ đi qua startxref, chuỗi /Prev và catalogue }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

Một phép tra cứu chạm tới một object thế nào?

Bằng số học, ở cả hai bố cục. Subsection cổ điển có hàng độ rộng cố định, nên địa chỉ của một entry là điểm bắt đầu subsection cộng offset của object nhân với độ rộng đo được; PDFiumPas rồi đọc đúng dòng đó, parse offset mười chữ số và generation năm chữ số, kiểm tra generation với trần 65535 từ §7.5.4, và xếp loại keyword đuôi là axkDirect hay axkFree. Cross-reference stream cần thêm một bước vì các subsection /Index được nối liền trong chuỗi byte đã giải mã, nên chỉ mục cộng dồn count của các subsection đứng trước rồi mới nhân với tổng độ rộng /W. Type 1 cho một offset, type 2 cho một số object stream và một chỉ số thành viên, còn mọi thứ khác thành axkUnknown thay vì phỏng đoán

{ bảng cổ điển, ISO 32000-1 section 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ cross-reference stream, ISO 32000-1 section 7.5.8 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Không gì ở cả hai đường là tỉ lệ theo /Size. Đó là toàn bộ ý nghĩa của lần viết lại: giá trị size của trailer được đưa tiếp như metadata và dùng khi ghi revision tăng dần, nhưng nó không bao giờ dẫn dắt một phép cấp phát. Bộ regression ghim điều này bằng một fixture mà page tree sống ở object 1.000.000.000 và 1.000.000.001 dưới một trailer khai báo /Size 1000000002. Bản dày đặc cũ từ chối tệp đó; chỉ mục thưa phân giải cả hai tham chiếu và giữ nguyên size khai báo trong trailer đầu ra

PDFiumPas phân giải một số object trong Delphi thế nào: bảng cross-reference cổ điển nhân với độ rộng hàng đo được, còn cross-reference stream cộng dồn count của các subsection đứng trước rồi mới nhân với tổng độ rộng trường từ mảng /W
Cả hai phép tra cứu đều là số học thuần, nên không cái nào tỉ lệ theo số object khai báo trong trailer

Revision hybrid, chuỗi /Prev và các bộ chặn quanh chúng

Thứ tự ưu tiên revision là chỗ một chỉ mục lazy ngây thơ vấp. PDFiumPas đi theo chuỗi từ startxref theo thứ tự mới trước và dừng tra cứu ở section đầu tiên trả lời, tái tạo quy tắc ưu tiên mà không vật chất hóa một bảng hợp nhất. Tệp hybrid-reference (§7.5.8.4) được xử lý bên trong nhánh cổ điển: khi trailer mang /XRefStm, section stream bổ sung được đăng ký trước section cổ điển đã nhắc tới nó, nên các object nén mà bảng thuần không nhìn thấy vẫn được tìm thấy trong khi các entry cổ điển giữ vị thế của mình. Các revision cũ hơn sau đó được đi tiếp qua /Prev

Hai bộ chặn giới hạn cuộc đi đó, và cả hai đều có ý nghĩa trên tệp hỏng. Mọi offset đã ghé đều được ghi nhận, nên một /Prev trỏ ngược vào chính chuỗi sẽ kết thúc thay vì quay vòng, và độ sâu duyệt bị chặn bởi MaxRecursionDepth, mặc định là 1024. Cờ mã hóa được cộng dồn trên toàn bộ chuỗi chứ không chỉ đọc từ trailer mới nhất, vì một tài liệu mà trailer mới nhất bỏ qua /Encrypt vẫn có thể được mã hóa ở phía sau; các caller nối thêm revision dựa vào cờ đó để từ chối ghi object plaintext vào một tệp đã mã hóa

PDFiumPas đi qua chuỗi revision PDF hybrid trong Delphi thế nào: các section được đăng ký mới trước từ startxref, một section XRefStm bổ sung đứng trước bảng cổ điển đã nhắc tới nó, và cuộc đi /Prev bị chặn bởi các offset đã ghé và một trần độ sâu
Một tra cứu dừng ở section đầu tiên trả lời, tái tạo thứ tự ưu tiên revision mà không bao giờ vật chất hóa bảng hợp nhất

Entry type-2: vì sao object stream chờ đợi

Một entry type-2 chỉ tên một object stream, và PDFiumPas không đụng vào stream đó cho tới khi một caller xin một thành viên của nó. Khi điều đó cuối cùng xảy ra, /Type /ObjStm được xác thực, /N được kiểm tra với ngân sách object và /First với trần byte đã giải mã, và /N được kiểm tra hợp lý với /First vì mỗi cặp header cần ít nhất bốn byte. Chỉ khi đó stream mới được inflate, và lần quét header dừng ở thành viên được yêu cầu và người kế nhiệm của nó thay vì dựng một bảng thành viên đầy đủ. Mỗi lần chỉ giữ một object stream đã giải mã — một đánh đổi đúng khi một nhánh page tree cụm lại trong một ObjStm duy nhất; bài viết của chúng tôi về giải mã object stream và predictor trong Delphi nói chi tiết chuyện gì xảy ra trong bước inflate đó (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { một chỉ mục giữ lại, nhiều lần đọc biết generation }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source vẫn thuộc về bạn }
  end;
end;

Chỗ cache ngừng hứa hẹn

Chỉ mục là một ảnh chụp, và đáng nói thẳng điều đó. Các section được parse một lần trong Initialize; nếu stream bên dưới bị sửa sau đó, mọi entry đã cache đều lỗi thời và class sẽ không nhận ra. TPdfSparseDictionaryReader giữ chỉ mục trong suốt vòng đời thuộc sở hữu caller của nguồn — đúng thứ một lần duyệt đệ quy trên page tree cần, và đúng thứ bạn không được làm khi qua một lần viết lại. Cache entry là một mảng phẳng được tìm tuyến tính và nó lưu cả kết quả phủ định, nên vài trăm tra cứu thì rẻ còn vài trăm nghìn thì không. ReadDictionary đòi khớp generation chính xác còn ReadLatestDictionary phân giải generation đang hoạt động, và sự khác biệt là chủ đích: phân giải tham chiếu cần cái trước, rà soát catalogue cần cái sau. Ở những chỗ không thể tôn trọng các trần này, các unit xung quanh quay về parser legacy đọc cả tệp thay vì thu hẹp tập tệp vẫn chạy được — một pattern chúng tôi cũng dùng cho streaming PDF lớn theo yêu cầu

Các regression đa trình biên dịch phủ cùng hành vi trên cả ba toolchain, kể cả một assertion rằng một nguồn 2 MiB không bao giờ thấy một lần đọc lớn hơn 1 MiB. Nếu bạn bảo trì code Delphi, C++Builder hay Lazarus đụng trực tiếp vào cấu trúc PDF và ngán trả chi phí parse cả tệp chỉ cho bốn dictionary, chỉ mục thưa và đường hầm công khai quanh nó có trong PDFiumPas Delphi PDFium component