Bài viết kỹ thuật

Object Stream và Xref Stream PDF trong Delphi

Object stream của PDF 1.5 đóng gói nhiều đối tượng gián tiếp nhỏ vào một container nén Flate duy nhất, và losLab PDF Library phát ra chúng khi lưu đầy đủ thông qua cờ PackObjectStreams. Lợi ích này là có thật: hàng trăm dictionary trang, font và annotation, mỗi cái tốn hàng chục byte không nén, gộp lại thành một vài blob đã nén. Cái giá phải trả là mỗi đối tượng đã đóng gói giờ cần một cross-reference stream để mô tả nó

Nửa sau đó chính là chỗ khiến các trình ghi bị hỏng. Xây dựng một container /ObjStm chỉ là số học; dạy cho cơ chế cross-reference biết cách trỏ vào đó mới là một cuộc thiết kế lại. Một trình ghi tạo ra một container hợp lệ hoàn hảo rồi mô tả các thành viên của nó bằng các offset kiểu type-1 thông thường sẽ tạo ra một tệp mà Acrobat mở lên đủ lâu để tuyên bố nó bị hỏng. Hai tính năng này thực chất là một, và bài viết này bao quát phía ghi của cả hai, như được định nghĩa trong ISO 32000-1 §7.5.7 và §7.5.8

Một container ObjStm thực sự chứa gì

Một object stream là một stream mà các byte đã giải mã của nó gồm hai vùng nối tiếp nhau, và ISO 32000-1 §7.5.7 cho dictionary đúng ba khóa quan trọng đối với việc xây dựng. /Type /ObjStm nhận dạng nó, /N cho số lượng thành viên, và /First cho độ dài byte của vùng header — tương đương với offset nơi phần thân bắt đầu. Header là các cặp số đối tượng và offset cách nhau bằng khoảng trắng; phần thân là các thành viên được tuần tự hóa nối tiếp nhau, với mỗi offset được đo từ đầu phần thân chứ không phải từ đầu payload đã giải mã. Đọc một container đã được giải mã đầy đủ sẽ thấy rõ điều này: bên dưới, /First là 14 vì ba dòng header chiếm mười bốn byte, và đối tượng 7 nằm ở byte thứ 55 trong phần thân vì đối tượng 4 khi tuần tự hóa chiếm 54 ký tự cộng một dấu phân cách

// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
//                        /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]

Hai quy tắc thành viên là tuyệt đối và cả hai đều xuất phát trực tiếp từ §7.5.7. Một đối tượng stream không bao giờ có thể là thành viên, vì một stream mang dữ liệu byte thô mà sẽ phải lồng bên trong một stream khác. Và một thành viên phải là một giá trị đối tượng hoàn chỉnh, không bao giờ được là một tham chiếu gián tiếp trần trụi — một đối tượng nén chỉ đơn thuần là 5 0 R tạo ra một tầng gián tiếp mà trình đọc không thể giải quyết nếu chưa biết trước nó trỏ tới đâu. losLab PDF Library lọc bỏ cả hai trường hợp này trong quá trình thu thập ứng viên, cùng với dictionary mã hóa và đối tượng 0, rồi đóng gói những gì còn lại thành các nhóm 200 đối tượng mỗi container. Giới hạn đó là một quyết định về truy cập ngẫu nhiên chứ không phải giới hạn của đặc tả: một trình đọc chỉ muốn một thành viên vẫn phải giải nén toàn bộ container, nên container quá lớn khiến các tra cứu nhỏ trở nên tốn kém

Vì sao các thành viên ObjStm phải dùng mục cross-reference type-2?

Vì một đối tượng đã đóng gói không có offset tệp nào để ghi lại. ISO 32000-1 §7.5.8 trả lời điều này bằng ba loại mục trong một cross-reference stream nhị phân: type 0 cho các đối tượng trống, type 1 cho các đối tượng đang dùng thông thường được lưu ở một offset byte, và type 2 cho các đối tượng nén, mà hai trường dữ liệu của nó chứa số đối tượng container và chỉ số thành viên bên trong đó. Không có cách nào để biểu diễn một đối tượng đã đóng gói trong bảng xref văn bản thuần cổ điển, và đó chính xác là lý do PDF 1.5 giới thiệu cả hai tính năng cùng lúc

Thứ tự đi kèm sau đó khiến hầu như mọi lần cài đặt đầu tiên vấp ngã, kể cả của chúng tôi. Các đối tượng thông thường nhận mục type-1. Bản thân các container /ObjStm cũng nhận mục type-1, vì một container là một đối tượng stream gián tiếp hoàn toàn bình thường được ghi tại một offset thật. Chỉ các thành viên mới nhận mục type-2. Và bản thân cross-reference stream cũng là một đối tượng gián tiếp trong tệp, nên nó cần mục type-1 của riêng mình trỏ tới offset nơi nó vừa được ghi — cùng offset mà startxref ghi lại. Một phiên bản sớm của trình ghi của chúng tôi đã loại trừ số đối tượng của container khỏi vòng lặp ghi thay vì loại trừ các thành viên, và kết quả là một tệp có cross-reference stream nhưng không có object stream nào cả: mạch lạc về cấu trúc, rỗng về ngữ nghĩa, bị từ chối ở phía sau. Giá trị /Size che giấu một lỗi lệch-một tương ứng, vì nó là số đối tượng cao nhất cộng một, và cross-reference stream được cấp phát như số đối tượng cao nhất, nên nó cũng phải được tính vào

Định cỡ mảng /W: vì sao bốn byte là chưa đủ

Mảng /W khai báo độ rộng byte của mỗi trong ba trường, và losLab PDF Library ghi nó dưới dạng /W [1 Field2 Field3] với trường 1 cố định một byte cho mã type và trường 3 cố định hai byte, đủ bao quát cả số thế hệ lên tới 65535 lẫn chỉ số thành viên. Trường 2 là trường không thể là hằng số, vì nó mang hai đại lượng không liên quan: trong một mục type-1 nó là một offset byte chỉ bị giới hạn bởi dung lượng tệp, trong khi ở mục type-2 nó là số đối tượng container, còn ở mục type-0 nó là đối tượng trống tiếp theo trong chuỗi. Một trường 2 cố định bốn byte hoạt động tốt cho tới khi tệp vượt quá 4 GB, lúc đó mọi offset vượt ngưỡng sẽ bị cắt cụt âm thầm và toàn bộ bảng trở thành rác. Vì vậy trình ghi quét bảng đã lắp ráp để tìm giá trị lớn nhất mà bất kỳ ô trường-2 nào sẽ từng chứa, kể cả offset của chính cross-reference stream, và mở rộng trường này lên tới tám byte

// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
  if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
    Field2Value := XRefTable[X].ObjStrNum   // type-2: container number
  else
    Field2Value := XRefTable[X].ObjPos;     // type-1 offset / type-0 next-free
  if Field2Value > MaxField2Value then
    MaxField2Value := Field2Value;
end;

Field2 := 4;
while (Field2 < 8) and
      (MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
  Inc(Field2);
Field3 := 2;   // generation numbers and member indices both fit

Một khi biết được các độ rộng, kích thước payload cũng được biết chính xác, nên trình ghi cấp phát trước toàn bộ buffer và lấp đầy theo chỉ số; việc nối thêm từng mục theo từng byte vào một AnsiString biến việc xây bảng thành bậc hai, điều mà không ai để ý trên một hóa đơn mười trang nhưng ai cũng nhận ra trên một tài liệu hai trăm nghìn đối tượng. Hai chi tiết nữa giúp các trình đọc nghiêm ngặt hài lòng. /Index khai báo những dải số đối tượng nào bảng này bao phủ, và với một lần viết lại toàn bộ thì đơn giản là [0 N] không có khoảng trống. Và mọi ô mà trình ghi không thực sự phát ra phải mặc định là trống thay vì đang dùng: đối tượng 0 đứng đầu chuỗi trống, mỗi ô trống liên kết tới ô tiếp theo, và một ô từng chứa một đối tượng đã xóa giữ số thế hệ của nó tăng thêm một. Bài viết liên quan về an toàn bộ nhớ khi phân tích PDF không đáng tin cậy đưa ra cùng lập luận về giới hạn này từ phía đọc

Vì sao cross-reference stream không bao giờ được phép mã hóa?

Vì một trình đọc phải phân tích nó trước khi có thể biết cách giải mã bất cứ thứ gì. Cross-reference stream là thứ cho trình đọc biết dictionary /Encrypt nằm ở đâu; nếu chính các byte của nó bị mã hóa, trình đọc sẽ cần khóa tệp để tìm ra đối tượng mô tả khóa tệp đó. losLab PDF Library thực thi điều này trong một vị từ (predicate) duy nhất: ShouldCryptStreamData trả về False bất cứ khi nào dictionary của stream mang /Type /XRef, nên ngoại lệ này luôn đúng bất kể đường đi nào tới bộ tuần tự hóa

Container /ObjStm lại nhận cách xử lý ngược lại, và sự bất đối xứng này là có chủ đích. Một container được mã hóa nguyên khối, dùng khóa dựa trên số đối tượng của chính nó, hệt như bất kỳ stream nào khác. Các thành viên của nó không được mã hóa riêng lẻ — chúng được đóng gói ở dạng văn bản thuần đã giải mã, và một lượt mã hóa duy nhất trên container đã lắp ráp bao phủ luôn chúng, kể cả các chuỗi (string). Mã hóa hai lần các thành viên sẽ tạo ra một tệp mà khi giải mã lại ra ciphertext, và vì tầng ngoài giải mã thành công, lỗi sẽ xuất hiện dưới dạng lỗi phân tích cú pháp sâu bên trong đồ thị đối tượng chứ không phải lỗi xác thực. Có một đối tượng luôn đứng ngoài toàn bộ cơ chế này: trong một tài liệu đã mã hóa, Catalog được giữ nguyên là một đối tượng type-1 trực tiếp và không bao giờ bị đóng gói, vì đóng gói nó sẽ buộc bộ tải phải giải nén và giải mã một object stream để tới được document root, trước khi ngữ cảnh giải mã mà chính root giúp thiết lập được xây dựng đầy đủ

Bật tính năng đóng gói từ Delphi

Công tắc công khai là PackObjectStreams, được đưa ra dưới dạng một trường trên TPDFlibSaveOptions, dưới dạng hàm thiết lập độc lập SetPackObjectStreams, và dưới dạng một property trên đối tượng tài liệu. Nó mặc định được bật và được canh chặn tự động theo phiên bản: trình ghi chỉ đóng gói khi tài liệu đã là PDF 1.5 trở lên, và nó gọi cơ chế canh giữ phiên bản tối thiểu nội bộ để một tài liệu đã đóng gói được nâng lên 1.5 thay vì bị gắn nhãn sai. Sau khi lưu, GetLastSaveUsedObjectStreams báo cho biết cổng canh giữ đó có thực sự mở hay không, đây chính là điều bạn nên assert trong một bài test hồi quy thay vì so sánh kích thước byte

var
  Doc: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('report.pdf', '') <= 0 then
      Exit;

    Doc.SetInformation(0, '1.5');        // packing is gated on PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // drop orphans before packing
    Options.PackObjectStreams  := True;

    if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
      if Doc.GetLastSaveUsedObjectStreams = 1 then
        Writeln('Saved with ObjStm containers and an xref stream');
  finally
    Doc.Free;
  end;
end;

Thứ tự giữa đóng gói và thu gom rác rất quan trọng. Phân tích khả đạt (reachability) phải chạy trước, vì một thành viên sống sót vào một container sẽ kéo theo cả container đó — nếu một đối tượng còn sống bị đóng gói, số đối tượng container của nó theo định nghĩa là khả đạt, và quét bỏ container sẽ khiến thành viên đó bị mắc kẹt không có cách nào định vị. Chạy bộ thu gom trước cũng có nghĩa là các đối tượng đã chết không bao giờ đi vào một container nào cả, đó chính là nguồn gốc của khoản tiết kiệm dung lượng cộng dồn. Đóng gói bổ trợ cho các đòn bẩy giảm dung lượng khác chứ không thay thế chúng; bài hướng dẫn về tối ưu dung lượng tệp PDF và subset hóa font bao quát các đòn bẩy tác động lên payload của stream, trong khi object stream tác động lên cấu trúc

Những ranh giới cần biết trước khi bật tính năng này

Các lần lưu tăng dần (incremental) không bao giờ đóng gói. Một incremental update nối thêm các đối tượng mới và một phần cross-reference mới trong khi để nguyên vẹn về mặt vật lý các phiên bản trước đó, nên việc đóng gói lại các đối tượng hiện có vào các container mới sẽ làm mồ côi các mục type-1 mà phiên bản trước vẫn đang tham chiếu tới; losLab PDF Library tắt tính năng đóng gói bất cứ khi nào chế độ append đang hoạt động, và bài viết về incremental update và streaming ở chế độ append bao quát đầy đủ đường đi đó. Các tài liệu dưới PDF 1.5 luôn giữ bảng cross-reference dạng văn bản thuần vô điều kiện: một trình đọc 1.4 hoàn toàn không biết /ObjStm nghĩa là gì, và việc âm thầm nâng cấp một tài liệu chỉ vì trình ghi muốn tệp nhỏ hơn sẽ là một đánh đổi sai lầm thay mặt bên gọi. Một khóa tùy chọn mà chúng tôi cố tình không phát ra là /Extends, thứ mà ISO 32000-1 §7.5.7 định nghĩa để một container có thể nêu tên một container tiền nhiệm, giúp trình đọc coi một chuỗi container là một nhóm logic. Nó thực sự là tùy chọn, mọi container chúng tôi ghi ra đều tự thân đầy đủ và có thể giải mã độc lập, và việc bỏ qua nó loại bỏ hẳn một lớp lỗi vòng lặp và tham chiếu treo khỏi trình ghi — dù tất nhiên các trình đọc vẫn phải tôn trọng /Extends khi gặp nó trong các tệp từ những trình tạo khác

Tính năng đóng gói object stream và xuất cross-reference stream là một phần của losLab PDF Library cho Delphi và C++Builder, cùng với bộ thu gom rác và bộ tối ưu content-stream mà chúng kết hợp cùng; trang sản phẩm có đầy đủ tài liệu tham khảo về các tùy chọn lưu tệp