Техническая статья

Identity Tm в PDF-потоках: безопасное удаление peephole

PDF Library for Delphi удаляет оператор identity-текстовой матрицы 1 0 0 1 0 0 Tm при сохранении, в peephole-оптимизации потока содержимого, только когда text matrix и text line matrix уже 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 инициализирует и text matrix (Tm), и text line matrix (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
RemoveIdentityMatrices в PDFlibPas идёт назад от каждого 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 и C++Builder, где также выставлены наружу NormalizeContentStreams, CompressContent и TPDFlibSaveOptions для настройки записи каждого документа