Bài viết kỹ thuật

Identity Tm trong content stream PDF: xóa peephole an toàn

PDF Library for Delphi gỡ bỏ một toán tử text matrix identity, 1 0 0 1 0 0 Tm, trong bước tối ưu content-stream kiểu peephole lúc lưu, chỉ khi text matrix và text line matrix đã là identity: ngay sau BT, hoặc ngay sau một Tm identity đứng trước đó. Một cm identity thì vẫn luôn bị vứt, vì cm nhân vào CTM trong khi Tm thay thẳng cả hai text matrix. Kể từ v3.539.28, mọi identity Tm khác đều được giữ nguyên trong stream

Bug mà bản sửa này xử lý thuộc kiểu thầm lặng. Một trình sinh báo cáo phát BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET, dựa vào identity Tm để đưa chuỗi thứ hai về gốc tọa độ text-space trước khi áp logic định vị của chính nó. Bộ tối ưu cũ nhìn thấy sáu con số vẽ nên matrix identity, kết luận toán tử này chẳng thể thay đổi điều gì, và xóa nó. Chẳng gì hỏng, chẳng gì log cảnh báo, và trang lưu ra vẽ "Total" ngay sau "Invoice" trên cùng một baseline, đúng cái loại khuyết tật chẳng ai để ý cho tới khi khách hàng đưa PDF đi in

Vì sao 1 0 0 1 0 0 Tm không phải lúc nào cũng là no-op?

Một Tm identity chỉ là no-op khi nó sắp thay thế hai matrix đang là identity, và đó là tính chất của các toán tử đứng trước nó, không phải của chính toán hạng của nó. ISO 32000-1 §9.4.1 nói BT khởi tạo cả text matrix (Tm) lẫn text line matrix (Tlm) về identity, còn §9.4.2 định nghĩa Tm là gán cả hai về giá trị cho trước, chứ không nối vào chúng. So với cm (§8.4.4), thứ nhân-phải vào current transformation matrix: nhân với identity để nguyên mọi CTM, nên 1 0 0 1 0 0 cm xóa ở đâu cũng an toàn. Bên trong một text object thì câu chuyện khác. Td, TD, T* và một Tm không identity đều dời Tlm, còn mọi toán tử hiển thị văn bản (Tj, TJ, ', ") đều đẩy Tm tiến theo bề rộng các glyph nó vừa vẽ. Sau bất kỳ toán tử nào trong số đó, một identity Tm là một phép reset thật sự về gốc. Nếu bạn đã từng lần tay theo vị trí văn bản với bộ theo dõi trạng thái CTM và text matrix của content stream, thì đây chính là sự phân biệt giữa nối trạng thái và thay trạng thái

PDFlibPas đối xử với 1 0 0 1 0 0 cm và 1 0 0 1 0 0 Tm khác nhau: cm nhân-phải vào CTM và là no-op ở mọi nơi, trong khi Tm thay thẳng Tm và Tlm, và mỗi Tj đẩy Tm tiến theo bề rộng nó vừa vẽ, nên một identity Tm sau văn bản đã hiển thị là một phép reset thật sự
Trình sinh báo cáo đã trông cậy vào phép reset đó: xóa identity Tm khiến Total được vẽ ngay sau Invoice trên cùng baseline, và chẳng gì hỏng, log hay cảnh báo trên đường tới máy in của khách hàng

Phép quét ngược quyết định identity Tm nào bị xóa ra sao?

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices giờ đi ngược từ mỗi identity Tm và chỉ xóa nó nếu phép quét chạm BT hay một identity Tm khác trước. Identity Tm đứng trước được tính cả khi nó được giữ lẫn khi nó vừa được đưa vào danh sách xóa, vì rốt cuộc cách nào nó cũng để cả hai matrix ở trạng thái identity, y như BT. Luật này xếp mọi toán tử nó có thể gặp vào một trong hai nhóm:

  • Dừng và giữ Tm: Td, TD, T*, một Tm không identity, Tj, TJ, ', ", ET, bất kỳ toán tử nào parser không nhận ra, hoặc điểm mở đầu của stream
  • Bước qua và quét tiếp: những toán tử không bao giờ đụng tới Tm hay Tlm, như Tf, Tc, các bộ đặt màu, gs, các toán tử marked-content và cm
PDFlibPas RemoveIdentityMatrices đi ngược từ mỗi identity Tm: Tf, Tc, bộ đặt màu, gs và cm được bước qua, còn Td, TD, T*, một Tm không identity, Tj, TJ, một toán tử lạ hay ET chặn phép quét và giữ lại Tm, còn BT bảo chứng cho việc xóa nó
Một identity Tm đứng trước cũng chặn phép quét, vì được giữ hay đã vào danh sách xóa thì nó cũng đã để cả hai matrix ở identity — cách nào optimizer cũng chẳng dời một glyph nào

Nữa các ca bảo thủ là có chủ đích. Một toán tử lạ có thể là bất cứ gì, nên phép quét từ chối suy luận vượt qua nó. ET đóng text object, nên một Tm đứng sau nó chẳng có BT nào bảo chứng cho giá trị matrix. Phép quét cũng chỉ làm việc trên một content stream mỗi lần, điều quan trọng với các trang mà /Contents là một mảng: một layer mở đầu giữa chừng một text object, không có BT riêng, sẽ giữ identity Tm của nó kể cả khi layer trước đó khiến nó dư thừa. Cái giá là vài byte trên những tệp lạ và chẳng bao giờ dời một glyph. Nếu bạn sửa văn bản trang ở mức instruction, như trong hướng dẫn ánh xạ ký tự sang content byte, thì chính mô hình TPDFContentProgram đã parse này là thứ optimizer viết lại

uses
  PDFlibContentModel, PDFlibContentOptimize;

function OptimizeSnippet(const Source: AnsiString): AnsiString;
var
  Prog: TPDFContentProgram;
  Optimizer: TPDFContentPeepholeOptimizer;
begin
  Result := Source;
  Prog := TPDFContentProgram.Create;
  try
    if not Prog.Parse(Source) then
      Exit; // stream hỏng: cứ để nguyên các byte
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // trả về số instruction đã bị gỡ
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // mỗi dòng một instruction
  finally
    Prog.Free;
  end;
end;

// Đã gỡ: Tm đứng ngay sau BT, cái thứ hai trong hai identity Tm liền nhau
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Giữ lại: Tm sau Td, sau Tj, sau một Tm không identity, hay ngoài BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Chạy helper lên stream hóa đơn ở phần mở đầu thì identity Tm sống sót, vì phép quét ngược đụng Tj trước khi tới BT. Đặt /F1 12 Tf, 2 Tc và 0 g vào giữa BT và identity Tm thì nó vẫn bị dọn, vì chẳng toán tử nào trong số đó đụng tới các text matrix. Một chuỗi kiểu BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm mất đúng một toán tử: identity Tm đầu tiên reset lại matrix mà Td vừa dời, và chỉ cái thứ hai mới là dư thừa

Peephole optimizer thực sự chạy khi nào?

Optimizer chỉ chạy trong bước nén, bên trong TPDFPageTree.Compress, và chỉ trên các content stream chưa được nén Flate sẵn. TPDFlib.SetOptimizeContentStreams(1) là mặc định, và cùng công tắc đó được lộ ra dưới dạng trường OptimizeContentStreams của TPDFlibSaveOptions; cả CompressContent lẫn CompressPage đều tôn trọng nó. Một stream mà /Filter đã là /FlateDecode bị bỏ qua trọn vẹn, nên nạp một PDF đã nén có sẵn rồi lưu lại không viết lại các toán tử của nó. Nếu stream parse hỏng, các byte gốc đã giải mã được nén nguyên trạng. TPDFlib.NormalizeContentStreams parse và phát lại content với khoảng cách cùng con số chuẩn hóa nhưng không bao giờ gọi optimizer, khiến nó thành một baseline hữu ích khi bạn muốn thấy phần chênh lệch kích thước mà các luật peephole đóng góp là bao nhiêu, bên cạnh các khoản thắng lớn hơn được nói trong tối ưu kích thước tệp PDF với font subsetting

PDFlibPas chỉ chạy peephole optimizer bên trong bước nén lúc lưu: TPDFPageTree.Compress tôn trọng SetOptimizeContentStreams, stream đã lọc bằng /FlateDecode bị bỏ qua trọn vẹn, stream không parse được được nén với các byte gốc nguyên trạng, còn NormalizeContentStreams chẳng bao giờ gọi optimizer
Các stream nén sẵn bị bỏ qua mới là phần thầm lặng: nạp một PDF có sẵn, lưu lại, và các toán tử của nó ra ngoài nguyên vẹn vì optimizer chỉ viết lại những stream nó tự giải mã trước
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Stream chưa nén đi qua các luật peephole, rồi tới Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // Cùng lựa chọn đó qua bộ save options đi kèm; False là từ chối
    Options.CompressContent := True;
    Options.CompressFonts := True;
    Options.CompressImages := True;
    Options.Linearize := False;
    Options.KeepModDate := False;
    Options.OptimizeContentStreams := False;
    Options.GarbageCollect := False;
    Options.PackObjectStreams := True;
    Lib.SaveToFileOptions('report-plain.pdf', Options);
  finally
    Lib.Free;
  end;
end;

Test hồi quy cũ thực sự bảo đảm điều gì?

Test hồi quy cũ chỉ bảo đảm đúng một hình dạng: một identity Tm đứng ngay sau BT sẽ bị xóa. Peephole_RemovesIdentityTextMatrix nạp BT 1 0 0 1 0 0 Tm (hello) Tj ET cho optimizer và assert rằng chẳng còn Tm nào sót lại. Một bản phát hành trước đã ghi nhận việc xóa identity Tm là không an toàn khi Tlm không phải identity, rồi vẫn giữ nguyên hành vi vì test đã "khóa" nó. Đọc lại cho kỹ, test chẳng nói gì về một identity Tm sau Td hay sau văn bản đã hiển thị; việc coi phạm vi phủ của một mẫu đơn lẻ là hợp đồng của cả luật mới là lỗi thật sự. Bản sửa giữ ca gốc vẫn pass và thêm sáu ca ghim chặt cả các hình dạng được xóa lẫn các hình dạng được giữ, kể cả một Tm nằm ngoài mọi text object và một cái đứng sau ET

Sự đánh đổi dễ chấp nhận ngay khi được viết ra. Những generator gói mọi text object thành BT 1 0 0 1 0 0 Tm ... vẫn thấy toán tử dư thừa đó bị dọn, và đó là nơi gần như toàn bộ khoản tiết kiệm đến từ. Cái optimizer từ bỏ là những identity Tm thỉnh thoảng nằm giữa chừng text object, vài byte mỗi trang trước khi Flate kịp nhìn chúng, đổi lấy một cam kết mà header của module nói thẳng: mọi phép biến đổi là output-equivalent và không bao giờ thay đổi trang hiển thị. Một bộ tối ưu kích thước mà dời chữ không phải là optimizer, đó là một bug render với tỉ lệ nén đẹp

Parser content-stream, peephole optimizer và các tùy chọn nén lúc lưu được mô tả trong bài đều đi kèm trong PDF Library for Delphi và C++Builder, thứ cũng lộ ra NormalizeContentStreams, CompressContent và TPDFlibSaveOptions để chỉnh cách mỗi tài liệu được ghi