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 и текстовой матрицы потока содержимого, это то же различие между конкатенацией состояния и его заменой
Как обратный скан решает, какой identity Tm удалить
TPDFContentPeepholeOptimizer.RemoveIdentityMatrices теперь идёт назад от каждого identity-Tm и выбрасывает его, только если скан первым достигает BT или другого identity-Tm. Более ранний identity-Tm засчитывается, оставлен он или сам только что назначен на удаление, потому что в обоих случаях он оставил обе матрицы в identity, ровно как BT. Правило сортирует каждый встречный оператор в одну из двух групп:
- Стоп и оставить Tm:
Td,TD,T*, не-identityTm,Tj,TJ,',",ET, любой оператор, которого парсер не знает, или начало потока - Шагнуть и сканировать дальше: операторы, никогда не трогающие Tm и Tlm, — например
Tf,Tc, сеттеры цвета,gs, операторы marked-content иcm
Консервативные случаи намеренные. Неизвестный оператор может оказаться чем угодно, поэтому скан отказывается рассуждать за его спиной. 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 с субсеттингом шрифтов
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 для настройки записи каждого документа