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

Identity Tm в content stream: безопасно peephole триене

PDF Library for Delphi премахва identity text matrix оператора 1 0 0 1 0 0 Tm при save-time peephole оптимизацията на content stream-и само когато text matrix и text line matrix вече са identity: веднага след BT или веднага след по-ранен identity Tm. Identity cm и нататък се маха винаги, защото cm умножава CTM, докато Tm заменя и двете текстови матрици изцяло. От v3.539.28 всеки друг identity Tm остава в stream-а

Бъгът, който това поправя, е от тихите. Генератор на отчети излъчва 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 е истинско зануляване към началото. Ако някога сте трасирали текстови позиции на ръка с state tracker-а за content stream CTM и text matrix, това е същата разлика между конкатениране на състоянието и замяната му

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, всеки оператор, който парсерът не разпознава, или началото на stream-а
  • Прескочи и продължи сканирането: оператори, които изобщо не пипат Tm или Tlm, като Tf, Tc, color setter-и, gs, marked-content оператори и cm
PDFlibPas RemoveIdentityMatrices върви назад от всеки identity Tm: Tf, Tc, color setter-и, gs и cm се прескачат, докато Td, TD, T*, не-identity Tm, Tj, TJ, непознат оператор или ET спира сканирането и запазва Tm, а BT гарантира триенето му
По-ранен identity Tm също спира сканирането, защото запазен или вече планиран за триене той е оставил двете матрици на identity — и в двата случая оптимизаторът никога не мести глиф

Консервативните случаи са нарочни. Непознат оператор може да е каквото и да е, така че сканирането отказва да разсъждава отвъд него. ET затваря текстовия обект, така че Tm след него няма BT, което да гарантира стойностите на матрицата. Сканирането работи и по един content stream наведнъж, което има значение за страници, чиито /Contents е масив: слой, започващ в средата на текстов обект, без собствен BT, си пази identity Tm, дори предишният слой да би го направил излишен. Струва няколко байта на странни файлове и никога не мести глиф. Ако редактирате текст на страница на ниво инструкция, както в упражнението за character-to-content-byte съпоставяне, същият парснат 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; // повреден stream: остави байтовете на мира
    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')

Пуснете helper-а върху stream-а с фактура от началото и 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, и само по content stream-и, които не са вече Flate-компресирани. TPDFlib.SetOptimizeContentStreams(1) е по подразбиране, а същият ключ е изложен и като OptimizeContentStreams поле на TPDFlibSaveOptions; и CompressContent, и CompressPage го уважават. Stream с /Filter вече /FlateDecode се прескача изцяло, така че зареждане на компресиран PDF и повторно записване не пренаписват операторите му. Ако stream-ът се провали при парсване, оригиналните декодирани байтове се компресират непроменени. TPDFlib.NormalizeContentStreams парсва и преизлъчва съдържание с канонични интервали и числа, но никога не вика оптимизатора, което го прави полезен базис, когато искате да видите колко от разликата в размера дават peephole правилата — редом с по-големите спестявания, покрити в оптимизацията на размера на PDF файл с font subsetting

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

    // Същият избор през вградените save опции; 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 на оптимизатора и assert-ва, че не е останал Tm. По-ранна версия вече беше отбелязала, че триенето на identity Tm е небезопасно, когато Tlm не е identity, после пак запази поведението, защото тестът го „заключваше". Прочетен внимателно, тестът не казва нищо за identity Tm след Td или след показан текст; отнасянето към покритието на една извадка като към договора на цялото правило беше истинската грешка. Поправката запазва оригиналния случай да минава и добавя шест случая, които заковат както махаемите форми, така и запазените, включително Tm извън всеки текстов обект и такъв след ET

Компромисът е лесен за приемане, щом е записан. Генератори, които увиват всеки текстов обект като BT 1 0 0 1 0 0 Tm ..., и нататък си изкарват излишния оператор премахнат — откъдето идваха почти всички спестявания. Това, което оптимизаторът отстъпва, е повременния identity Tm в средата на текстов обект, шепа байта на страница, преди Flate изобщо да ги види, в замяна на гаранция, която модулният header казва направо: всяка трансформация е output-еквивалентна и никога не променя видимата страница. Size оптимизатор, който мести текст, не е оптимизатор, а rendering бъг с добри коефициенти на компресия

Парсерът на content stream-и, peephole оптимизаторът и save-time опциите за компресия, описани тук, идват всичките с PDF Library for Delphi и C++Builder, която излага и NormalizeContentStreams, CompressContent и TPDFlibSaveOptions за настройка как всеки документ се записва