Технічна стаття

Identity Tm у вмісті PDF: безпечне peephole-видалення

PDF Library for Delphi видаляє оператор identity текстової матриці, 1 0 0 1 0 0 Tm, під час збережувальної peephole-оптимізації потоку вмісту лише тоді, коли текстова матриця і матриця текстового рядка вже є identity: просто після BT чи просто після попереднього identity Tm. Identity cm досі викидається завжди, бо cm домножується на CTM, тоді як Tm обидві текстові матриці замінює начисто. З v3.539.28 кожен інший identity Tm лишається в потоці

Баг, який це виправляє, — тихого сорту. Генератор звітів видає BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET, розраховуючи, що identity Tm поверне другий рядок у початок text space перед тим, як спрацює його власна логіка позиціювання. Старіший оптимізатор бачив шість чисел, що складають identity-матрицю, вирішував, що оператор нізащо не може нічого змінити, і видаляв його. Нічого не падало, нічого не писало попереджень, а збережена сторінка малювала «Total» одразу після «Invoice» на тій самій базовій лінії — саме той клас дефектів, який ніхто не помічає, поки клієнт не роздрукує PDF

Чому 1 0 0 1 0 0 Tm — не завжди no-op?

Identity Tm — no-op лише тоді, коли вона мала б замінити дві матриці, що вже тримають identity, а це властивість операторів перед нею, не її власних операндів. ISO 32000-1 §9.4.1 каже, що BT ініціалізує обидві — текстову матрицю (Tm) і матрицю текстового рядка (Tlm) — до identity, а §9.4.2 визначає Tm як встановлення обох у задані значення, а не домножування на них. Порівняйте з cm (§8.4.4), який право-домножує поточну матрицю трансформації: множення на identity лишає будь-який CTM незмінним, тож 1 0 0 1 0 0 cm безпечно видаляти будь-де. Усередині текстового об'єкта картина інша. Td, TD, T* і не-identity Tm усі рухають Tlm, а кожен оператор показу тексту (Tj, TJ, ', ") просуває Tm на ширину намальованих гліфів. Після будь-якого з них identity Tm — це справжнє скидання в початок. Якщо ви колись трасували позиції тексту вручну з трекером стану CTM і текстової матриці потоку вмісту, це та сама відмінність між домножуванням стану і заміною

PDFlibPas трактує 1 0 0 1 0 0 cm і 1 0 0 1 0 0 Tm по-різному: cm право-домножує CTM і всюди no-op, тоді як Tm замінює Tm і Tlm начисто, а кожен Tj просуває Tm на намальовану ширину, тож identity Tm після показаного тексту — справжнє скидання
Генератор звітів розраховував на те скидання: видалення identity Tm малювало Total одразу після Invoice на тій самій базовій лінії, і нічого не впало, не залогувало і не попередило на шляху до принтера клієнта

Як зворотний скан вирішує, який identity Tm викинути

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices тепер іде назад від кожного identity Tm і викидає його, лише якщо скан першим досягає BT чи іншого identity Tm. Попередній identity Tm зараховується незалежно від того, чи він збережений, чи сам щойно запланований на видалення, бо будь-як він лишив обидві матриці в identity, точнісінько як BT. Правило розкидає кожен оператор, якого може зустріти, на дві групи:

  • Стоп і зберегти Tm: Td, TD, T*, не-identity Tm, Tj, TJ, ', ", ET, будь-який оператор, якого парсер не розпізнає, або початок потоку
  • Переступити і сканувати далі: оператори, що ніколи не торкаються Tm чи Tlm, як-от Tf, Tc, задавачі кольору, gs, оператори marked-content і cm
PDFlibPas RemoveIdentityMatrices іде назад від кожного identity Tm: Tf, Tc, задавачі кольору, gs і cm переступаються, тоді як Td, TD, T*, не-identity Tm, Tj, TJ, невідомий оператор чи ET зупиняє скан і зберігає Tm, а BT поручає за видалення
Попередній identity Tm теж зупиняє скан, бо збережений чи вже запланований на видалення він лишив обидві матриці в identity — будь-як оптимізатор ніколи не рухає гліф

Консервативні випадки — навмисні. Невідомий оператор може бути чим завгодно, тож скан відмовляється міркувати далі за нього. ET закриває текстовий об'єкт, тож Tm після нього не має BT, який поручився б за значення матриць. Скан також працює з одним потоком вмісту за раз, що важливо для сторінок, чий /Contents — масив: шар, що починається посередині текстового об'єкта, без власного BT, зберігає свій identity Tm, навіть коли попередній шар зробив би його надлишковим. Це коштує кількох байтів на дивних файлах і ніколи не рухає гліф. Якщо ви редагуєте текст сторінки на рівні інструкцій, як у розборі відповідності символів і байтів вмісту, той самий розібраний TPDFContentProgram — це модель, яку переписує оптимізатор

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; // пошкоджений потік: байти не чіпаємо
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // повертає кількість видалених інструкцій
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // одна інструкція на рядок
  finally
    Prog.Free;
  end;
end;

// Видалено: Tm просто після BT, другий із двох identity Tm підряд
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Збережено: Tm після Td, після Tj, після не-identity Tm чи поза BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Прогоніть хелпер на інвойсному потоці з початку статті — identity Tm виживе, бо зворотний скан натикається на Tj раніше, ніж дістанеться BT. Покладіть /F1 12 Tf, 2 Tc і 0 g між BT та identity Tm — і вона все одно піде, бо жоден із них не торкається текстових матриць. Послідовність на кшталт BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm втрачає рівно один оператор: перший identity Tm скидає матрицю, яку зсунув Td, і лише друга надлишкова

Коли peephole-оптимізатор насправді запускається?

Оптимізатор запускається лише під час компресійного проходу, всередині TPDFPageTree.Compress, і лише на потоках вмісту, які ще не стиснуті Flate. TPDFlib.SetOptimizeContentStreams(1) — усталено, і той самий перемикач виставлено як поле OptimizeContentStreams у TPDFlibSaveOptions; обидва — CompressContent і CompressPage — його поважають. Потік, чий /Filter уже /FlateDecode, пропускається повністю, тож завантаження наявного стиснутого PDF і повторне збереження не переписує його операторів. Якщо потік не парситься, оригінальні розкодовані байти стискаються без змін. TPDFlib.NormalizeContentStreams розбирає і перевидає вміст із канонічними пробілами та числами, але ніколи не викликає оптимізатор, що робить його корисною базою порівняння, коли хочеться побачити, скільки розміру дають peephole-правила, поруч із більшими виграшами з оптимізації розміру PDF-файлу субсетингом шрифтів

PDFlibPas запускає peephole-оптимізатор лише всередині збережувального компресійного проходу: TPDFPageTree.Compress поважає SetOptimizeContentStreams, потік, уже відфільтрований /FlateDecode, пропускається повністю, непарсований потік стискається з оригінальними байтами без змін, а NormalizeContentStreams ніколи не викликає оптимізатор узагалі
Пропущені стиснуті потоки — тиха частина: завантажте наявний PDF, збережіть знову, і його оператори виходять недоторканими, бо оптимізатор переписує лише потоки, які сам спершу розкодував
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Нестиснуті потоки йдуть через peephole-правила, потім Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // Той самий вибір через комплектні опції збереження; False відмовляється
    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;

Що старий регресний тест насправді гарантував?

Старий регресний тест гарантував лише одну форму: identity Tm просто після BT видаляється. Peephole_RemovesIdentityTextMatrix згодовує оптимізаторові BT 1 0 0 1 0 0 Tm (hello) Tj ET і перевіряє, що жодного Tm не лишилося. Раніший реліз уже відзначав, що викидати identity Tm небезпечно, коли Tlm не identity, але поведінку все одно лишив, бо тест її «зафіксував». Прочитаний уважно, тест не каже нічого про identity Tm після Td чи після показаного тексту; вважати покриття одного зразка контрактом усього правила — ось справжня помилка. Виправлення лишає початковий випадок зеленим і додає шість випадків, які пришпилюють і вилучні форми, і збережені, включно з Tm поза будь-яким текстовим об'єктом і тією, що йде після ET

Компроміс легко прийняти, щойно він записаний. Генератори, що обгортають кожен текстовий об'єкт як BT 1 0 0 1 0 0 Tm ..., досі отримують видалення того надлишкового оператора — звідси йшла майже вся економія. Від чого оптимізатор відмовляється, так це від епізодичної identity Tm посередині текстового об'єкта — кількох байтів на сторінку ще до того, як їх побачить Flate — в обмін на гарантію, яку заголовок модуля формулює просто: кожна трансформація еквівалентна на виході і ніколи не змінює видимої сторінки. Розмірний оптимізатор, який рухає текст, — не оптимізатор, це рендер-баг із хорошими коефіцієнтами стиснення

Парсер потоку вмісту, peephole-оптимізатор і збережувальні опції стиснення, описані тут, входять до PDF Library for Delphi and C++Builder, яка також відкриває NormalizeContentStreams, CompressContent і TPDFlibSaveOptions для тонкого налаштування того, як пишеться кожен документ