Bài viết kỹ thuật

Kiểm toán dung lượng PDF trong Delphi: Phân loại theo byte

Để tìm ra dung lượng một file PDF thực sự nằm ở đâu, losLab PDF Library cung cấp AuditDocumentSpace, hàm này phân loại mọi object gián tiếp vào mười hai nhóm — hình ảnh, chương trình font, dictionary font, content stream, form XObject, object stream, file nhúng, metadata, cây cấu trúc, chú thích, cây trang, khác — và báo cáo số lượng object, số byte lưu trữ và tỷ lệ phần trăm của từng nhóm

Tình huống mà tính năng này được sinh ra để giải quyết khá quen thuộc. Một báo cáo 40 trang xuất ra từ trình sinh PDF của bạn nặng 80 MB, khách hàng hỏi vì sao, và tất cả những gì bạn có thể trả lời chỉ là phỏng đoán. Có thể là ảnh. Có thể là font. Thế là bạn bật downsampling, gửi đi, và file còn lại 74 MB vì phần nặng thực sự nằm ở chỗ hoàn toàn khác. Bài viết đồng hành về subset font và downsample ảnh nói về cách thu nhỏ một PDF; bài này nói về bước cần làm trước tiên, đó là đo lường thứ bạn sắp thu nhỏ

Vì sao phải đo trước khi nén?

Vì ba bước tối ưu hóa tiêu chuẩn cho kết quả rất khác nhau tùy từng file cụ thể, và không có gì trong file cho bạn biết bước nào phù hợp cho đến khi bạn đếm. Subset font trên một tài liệu mà font đã chỉ chiếm 2% dung lượng là một buổi chiều dành để dịch chuyển một sai số làm tròn. Downsample ảnh trên một file mà phần lớn dung lượng là content stream chưa nén cũng cho kết quả thất vọng tương tự. Bộ tối ưu không phải phần khó — thư viện nào cũng có một cái. Biết nên nhắm bộ tối ưu nào vào file này mới là phần khó, và đó là câu hỏi về kế toán, không phải câu hỏi về nén. Một cuộc kiểm toán còn bắt được những trường hợp mà không bộ tối ưu nào là câu trả lời: một file hóa ra 60% là tệp đính kèm nhúng không cần nén tốt hơn, nó cần một cuộc trao đổi xem những tệp đính kèm đó có nên nằm trong tài liệu hay không, và một file 30% là cây cấu trúc đang trả giá cho việc gắn thẻ khả năng truy cập, thường là một chi phí có chủ đích mà bạn không nên âm thầm loại bỏ. Khi các byte đã được quy trách nhiệm, bạn đang đưa ra quyết định sản phẩm dựa trên số liệu thay vì với tay lấy công tắc gần nhất

Báo cáo mười hai nhóm chứa những gì

AuditDocumentSpace trả về một handle danh sách chuỗi thay vì một record, để báo cáo có thể vượt qua nguyên vẹn lớp DLL và COM phẳng. Danh sách chứa một dòng tóm tắt Total,Objects,Bytes,100.0 theo sau đúng mười hai dòng Category,Objects,Bytes,Percent theo một thứ tự cố định là một phần của hợp đồng: Images, Font programs, Font dictionaries, Content streams, Form XObjects, Object streams, Embedded files, Metadata, Structure tree, Annotations, Page tree, Other. Mười ba dòng, luôn luôn như vậy, ngay cả khi một nhóm rỗng

var
  Lib: TPDFlib;
  ListID, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    ListID := Lib.AuditDocumentSpace;   // 0 when no document is selected
    if ListID = 0 then
      Exit;
    try
      // GetStringListItem is 1-based: items run 1..GetStringListCount
      for I := 1 to Lib.GetStringListCount(ListID) do
        Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
    finally
      Lib.ReleaseStringList(ListID);
    end;
  finally
    Lib.Free;
  end;
end;

Một chi tiết Delphi trong vòng lặp đó sẽ cắn bạn đúng một lần. GetStringListItem dùng chỉ số phần tử bắt đầu từ 1, khớp với GetStringListCount, và một chỉ số ngoài phạm vi trả về chuỗi rỗng thay vì raise. Viết vòng lặp for I := 0 to Count - 1 theo thói quen và bạn sẽ có một dòng đầu trống, một dòng cuối bị âm thầm bỏ sót, và không có exception nào ở đâu để báo cho bạn biết chỉ số bị sai. Bản thân báo cáo sẽ trông gần như đúng, đó là kiểu thất bại tệ nhất mà một công cụ chẩn đoán có thể mắc phải

Vì sao kiểm toán dùng độ dài lưu trữ thay vì kích thước đã giải mã?

Vì độ dài lưu trữ vừa là con số bạn muốn vừa là con số dễ lấy được. Mỗi object gián tiếp mang TPDFIndObj.FLength, độ dài byte thô mà object chiếm trong file khi được phân tích. Dùng nó nghĩa là một ảnh DCTDecode 900 KB được báo cáo là 900 KB — số byte nó tốn trên đĩa — thay vì 40 MB mẫu RGB mà nó giải mã ra. Điều đó cũng có nghĩa kiểm toán không bao giờ phải giải mã bất cứ thứ gì: các object tải lười vẫn ở trạng thái lười, các filter không bị chạy, và kiểm toán một file 500 MB là một lượt duyệt qua header object thay vì một chu trình giải nén đầy đủ

Quy tắc thứ hai là một phòng thủ chống đếm trùng. Khi một object nằm bên trong một object stream đã nén, được đánh dấu bởi FObjStrNum khác không, số byte của nó được ghi là bằng không. Dung lượng lưu trữ của nó đã được tính một lần bởi stream chứa, mà ISO 32000-1 §7.5.7 định nghĩa là một stream /Type /ObjStm chứa nhiều object trong một payload nén Flate. Tính riêng cho từng thành viên rồi lại tính cho container sẽ thổi phồng tổng số vượt quá kích thước file thực. Điều này ảnh hưởng trực tiếp đến cách bạn đọc kết quả, được trình bày bên dưới và chi tiết hơn trong bài về object stream và cross-reference stream

Vì sao một chương trình font không thể tự phân loại chính nó?

Vì một file font TrueType được nhúng trong PDF không có dấu hiệu nào nói lên điều đó. ISO 32000-1 §9.8.1 định nghĩa chương trình font được nhúng là giá trị của /FontFile, /FontFile2 hoặc /FontFile3 trong một font descriptor, và dictionary stream ở đầu bên kia tham chiếu đó mang các khóa /Length1 và filter nhưng không có /Type và không có /Subtype nào nhận dạng nó là một font. Nhìn riêng lẻ, nó là một stream nhị phân vô danh. Chỉ descriptor trỏ tới nó mới biết nó là gì. Cùng sự bất đối xứng đó xuất hiện với chú thích: §12.5.2 khiến /Type /Annot trở thành tùy chọn trong một dictionary chú thích, nên tín hiệu đáng tin cậy là việc thuộc về mảng /Annots của trang, không phải bản thân dictionary

Vì vậy việc phân loại chạy hai lượt. Lượt đầu đọc /Type/Subtype của riêng từng object và lấy những phần dễ: /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font/Type /FontDescriptor, /Metadata, /EmbeddedFile/Filespec, /StructTreeRoot/StructElem, /Annot, /Page/Pages. Mọi thứ khác tạm thời rơi vào nhóm Other. Lượt thứ hai sau đó duyệt phía tham chiếu và ghi đè: mỗi dictionary trang gán lại /Contents của nó cho content stream, các mục /Annots cho chú thích, và /Thumb cho hình ảnh, trong khi mỗi dictionary font duyệt qua chuỗi descriptor của riêng nó

// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
  MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
  MarkRef(Descriptor.FindValueByKeyName('FontFile'),  catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
  MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));

Đọc báo cáo và chọn bước tiếp theo

Đọc tỷ lệ phần trăm trước, số lượng object thứ hai, và coi bất kỳ khoảng cách lớn nào giữa chúng là một tín hiệu. Một PDF hiện đại đặt hầu hết dictionary nhỏ của nó vào trong object stream, nên Page tree và Structure tree thường xuyên hiển thị hàng chục object nhưng gần như không có byte nào — chi phí thực của chúng đã được gộp vào dòng Object streams. Nếu bản thân Object streams lớn, file dày đặc cấu trúc kiểu metadata hơn là nội dung, và đòn bẩy cần dùng là cắt bớt object chứ không phải nén chúng. Appearance stream của chú thích cũng hành xử tương tự: chúng mang /Subtype /Form, nên một tài liệu bị đóng dấu dày đặc sẽ thể hiện trọng lượng của nó dưới dòng Form XObjects trong khi dòng Annotations vẫn nhỏ

function CategoryShare(Lib: TPDFlib; ListID: Integer;
  const Category: string): Double;
var
  I: Integer;
  Parts: TArray<string>;
  Inv: TFormatSettings;
begin
  Result := 0;
  Inv := FormatSettings;
  Inv.DecimalSeparator := '.';   // the report is locale-independent
  for I := 2 to Lib.GetStringListCount(ListID) do   // line 1 is Total
  begin
    Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
    if (Length(Parts) = 4) and SameText(Parts[0], Category) then
      Exit(StrToFloatDef(Parts[3], 0, Inv));
  end;
end;

Hai chi tiết định dạng quan trọng nếu bạn tự phân tích phần trăm thay vì chỉ hiển thị chúng. Dấu thập phân luôn là dấu chấm literal bất kể locale của máy, nên phân tích với FormatSettings mặc định trên một máy trạm tiếng Đức hoặc tiếng Pháp sẽ thất bại hoặc, tệ hơn, đọc sai. Và các số 0 ở cuối bị cắt bớt, nên một nhóm chiếm đúng 40% số byte in ra là 40, không phải 40.0 — đừng bao giờ giả định một số chữ số thập phân cố định. Với tỷ lệ trong tay, việc điều hướng trở nên máy móc: một tỷ lệ Images áp đảo trỏ tới DownsampleImages, một tỷ lệ Font programs áp đảo trỏ tới SubsetEmbeddedFonts, và Content streams cồng kềnh trỏ tới CompressContent

Những gì báo cáo cố tình không cho bạn biết

Tổng số là tổng trên các object gián tiếp, và một file PDF hơi lớn hơn tổng các object của nó một chút. Header file, trailer, khoảng trắng giữa các object và một bảng cross-reference kiểu cổ điển không phải là object gián tiếp, nên những byte đó không được quy cho bất cứ thứ gì và tổng của kiểm toán thấp hơn một chút so với dung lượng thực trên đĩa. Một cross-reference stream thì khác — nó là một object thật với /Type /XRef, nên trong một file hiện đại những byte đó có xuất hiện, ở nhóm Other. Không hành vi nào trong hai điều này là lỗi, nhưng nếu bạn đang đối chiếu kiểm toán với số byte từ hệ thống file, đó là nơi khoảng chênh lệch đến từ

Hai ranh giới nữa đáng nói rõ ràng. Thứ nhất, các con số mô tả một file đã được tải, không phải một file đang được tạo: đối với các object được xây trong bộ nhớ chưa có độ dài lưu trữ, kích thước rơi về đầu ra đã serialize với một khoản dự trù danh nghĩa cho dictionary stream, đó là một ước lượng cho lần ghi cuối cùng chứ không phải một phép đo. Hãy kiểm toán sau khi lưu-và-tải-lại nếu bạn muốn số liệu chính xác. Thứ hai, một dòng Other phình to là một phát hiện, không phải một báo cáo lỗi — nó thường có nghĩa là các object mồ côi mà không còn gì tham chiếu tới nữa, đó là việc dành cho thu gom rác kiểu mark-and-sweep chứ không phải cho bất kỳ bước nén nào

Dùng theo cách này, kiểm toán thay đổi hình dạng của cuộc trao đổi. Thay vì đoán mò về báo cáo 80 MB, bạn mở nó ra, chạy một lệnh, và đọc thấy ảnh chiếm 8%, chương trình font chiếm 61%, và tài liệu nhúng chín chương trình font đầy đủ cho một bộ nhận diện thương hiệu chỉ dùng ba kiểu chữ. Đó là một câu trả lời có thể sửa được và có con số đi kèm. AuditDocumentSpace, cùng với các bước tối ưu hóa mà nó chỉ ra cho bạn, đi kèm trong losLab PDF Library cho Delphi và C++Builder, nơi các trang tham chiếu tài liệu hóa đầy đủ danh sách nhóm và API danh sách chuỗi xung quanh nó