Technický článek

Identity Tm v PDF content streamu: bezpečná peephole redukce

PDF Library for Delphi odstraňuje operátor identity text matice, 1 0 0 1 0 0 Tm, při save-time peephole optimalizaci content streamu jen tehdy, když jsou text matrix i text line matrix už teď identity: hned po BT nebo hned po dřívější identity Tm. Identity cm se pořád maže vždycky, protože cm násobí CTM, zatímco Tm obě text matice rovnou nahrazuje. Od v3.539.28 zůstává v streamu každý jiný identity Tm

Bug, který tahle oprava řeší, je ten tichý druh. Generátor reportů vyšle BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET a spoléhá na identity Tm, která pošle druhý řetězec zpátky na počátek textového prostoru, než si aplikuje vlastní poziční logiku. Starší optimizer viděl šest čísel, které hlásají identitní matici, usoudil, že operátor nemůže změnit vůbec nic, a smazal ji. Nic neselhalo, nic nezalogovalo varování a uložená stránka nakreslila „Total“ hned za „Invoice“ na stejné baseline — přesně ta třída vad, které si nikdo nevšimne, dokud zákazník PDF nevytiskne

Proč není 1 0 0 1 0 0 Tm vždy no-op?

Identity Tm je no-op jen tehdy, když nahrazuje dvě matice, které už identitu drží, a to je vlastnost operátorů před ní, ne jejích vlastních operandů. ISO 32000-1 §9.4.1 říká, že BT inicializuje text matrix (Tm) i text line matrix (Tlm) na identitu, a §9.4.2 definuje Tm jako nastavení obou na zadané hodnoty, ne jejich násobení. Srovnejte s cm (§8.4.4), které aktuální transformační matici násobí zprava: násobení identitou jakoukoli CTM nechá beze změny, takže 1 0 0 1 0 0 cm se smí smazat kdekoliv. Uvnitř text objektu je obraz jiný. Td, TD, T* a ne-identity Tm všechny hýbají Tlm a každý text zobrazující operátor (Tj, TJ, ', ") posouvá Tm o šířku namalovaných glyfů. Po kterýchkoli z nich je identity Tm doopravdy reset na počátek. Pokud jste někdy ručně trasovali pozice textu s trackerem stavu CTM a text matrix v content streamu, je to tentýž rozdíl mezi skládáním stavu a jeho nahrazováním

PDFlibPas zachází s 1 0 0 1 0 0 cm a 1 0 0 1 0 0 Tm jinak: cm CTM násobí zprava a je no-op kdekoliv, zatímco Tm Tm i Tlm rovnou nahrazuje a každé Tj posune Tm o namalovanou šířku, takže identity Tm po zobrazeném textu je doopravdy reset
Generátor reportů se na ten reset spoléhal: smazání identity Tm namalovalo Total hned za Invoice na stejné baseline a nic neselhalo, nezalogovalo ani nevarovalo, dokud výstup nedorazil k tiskárně zákazníka

Jak zpětný scan rozhoduje, který identity Tm smaže

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices teď od každé identity Tm jde zpátky a maže ji, jen když scan dřív narazí na BT nebo jinou identity Tm. Dřívější identity Tm počítá, ať je ponechána, nebo je sama právě přihlášena ke smazání, protože oběma cestami nechala obě matice v identitě, úplně jako BT. Pravidlo zařazuje každý operátor, který může potkat, do jedné ze dvou skupin:

  • Zastavit a Tm ponechat: Td, TD, T*, ne-identity Tm, Tj, TJ, ', ", ET, jakýkoli operátor, který parser nezná, nebo začátek streamu
  • Přeskočit a scanovat dál: operátory, které se Tm ani Tlm nedotýkají, jako Tf, Tc, nastavovače barev, gs, marked-content operátory a cm
RemoveIdentityMatrices v PDFlibPas jde zpátky od každé identity Tm: Tf, Tc, nastavovače barev, gs a cm přeskočí, zatímco Td, TD, T*, ne-identity Tm, Tj, TJ, neznámý operátor nebo ET scan zastaví a Tm ponechá a BT za smazáním ručí
Dřívější identity Tm scan taky zastaví, protože ponechaná i už přihlášená ke smazání nechala obě matice v identitě — a tak jako tak optimizer nepohne ani glyfem

Konzervativní případy jsou záměr. Neznámý operátor může být cokoliv, takže scan odmítá uvažovat za ním. ET zavírá text objekt, takže Tm za ním nemá žádné BT, které by za hodnoty matic ručilo. Scan navíc pracuje vždy s jedním content streamem, což je důležité u stránek, jejichž /Contents je pole: vrstva, která startuje uprostřed text objektu a žádné vlastní BT nemá, si identity Tm ponechá, i kdyby ji předchozí vrstva učinila redundantní. To stojí pár bajtů na podivných souborech a nikdy nepohne glyfem. Pokud editujete text stránky na úrovni instrukcí, jako ve průvodci mapováním znaků na content bajty, tenhle parsovaný model TPDFContentProgram je přesně to, co optimizer přepisuje

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; // poškozený stream: bajty necháme na pokoji
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // vrátí počet odebraných instrukcí
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // jedna instrukce na řádek
  finally
    Prog.Free;
  end;
end;

// Odstraněno: Tm hned po BT, druhá ze dvou identity Tm za sebou
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Ponecháno: Tm po Td, po Tj, po ne-identity Tm nebo mimo BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Pusťte helper na invoice stream z úvodu a identity Tm přežije, protože zpětný scan narazí na Tj dřív než na BT. Vložte /F1 12 Tf, 2 Tc a 0 g mezi BT a identity Tm a pořád zmizí, protože se žádného z nich text matice nedotýká. Sekvence jako BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm ztratí přesně jeden operátor: první identity Tm resetuje matici, kterou hyla Td, a redundantní je až ta druhá

Kdy peephole optimizer doopravdy běží?

Optimizer běží jen během kompresní fáze, uvnitř TPDFPageTree.Compress, a jen na content streamy, které ještě nejsou Flate komprimované. TPDFlib.SetOptimizeContentStreams(1) je default a stejný přepínač je vystavený jako pole OptimizeContentStreams v TPDFlibSaveOptions; ctí ho i CompressContent a CompressPage. Stream, jehož /Filter je už /FlateDecode, se přeskočí úplně, takže načtení existujícího komprimovaného PDF a jeho nové uložení operátory nepřepíše. Když se stream nepodaří parsovat, původní dekódované bajty se zkomprimují beze změny. TPDFlib.NormalizeContentStreams parsuje a znovu vydává obsah s kanonickými mezerami a čísly, ale optimizer nikdy nevolá, takže je to užitečný baseline, když chcete vidět, jak velký podíl na úspoře mají peephole pravidla, vedle větších zisků popsaných v optimalizaci velikosti PDF souboru se subsettingem fontů

PDFlibPas spouští peephole optimizer jen uvnitř save-time kompresní fáze: TPDFPageTree.Compress ctí SetOptimizeContentStreams, stream už filtrovaný přes /FlateDecode se přeskočí úplně, neparseovatelný stream se zkomprimuje s původními bajty beze změny a NormalizeContentStreams optimizer nevolá vůbec
Přeskočené komprimované streamy jsou ta tichá část: načtěte existující PDF, uložte ho znovu a jeho operátory vylezou nedotčené, protože optimizer přepisuje jen streamy, které si nejdřív sám dekódoval
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Nekomprimované streamy projdou peephole pravidly, pak Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // Tatáž volba přes přibalené save options; False se odhlásí
    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;

Co ten starý regresní test doopravdy garantoval?

Starý regresní test garantoval jediný tvar: identity Tm hned po BT se odstraní. Peephole_RemovesIdentityTextMatrix podstrčí optimizeru BT 1 0 0 1 0 0 Tm (hello) Tj ET a asertuje, že žádné Tm nezbylo. Některé dřívější vydání si už poznamenalo, že mazání identity Tm je nebezpečné, když Tlm identitou není, a chování si přesto nechalo, protože ho test „zamkl“. Čtené pozorně, test o identity Tm po Td nebo po zobrazeném textu neříká vůbec nic; vzít pokrytí jednoho vzorku za kontrakt celého pravidla byla ta skutečná chyba. Oprava nechává tenhle původní případ procházet a přidává šest případů, které přibíjí jak odebratelné tvary, tak ponechané, včetně Tm mimo jakýkoli text objekt a jedné, která následuje po ET

Kompromis se dá přijmout hned, jakmile je sepsaný. Generátory, které každý text objekt balí jako BT 1 0 0 1 0 0 Tm ..., si tu redundantní operaci nechají odebrat i dál, a právě odtud přišla téměř veškerá úspora. Optimizer se vzdává občasné identity Tm uprostřed text objektu, hrstky bajtů na stránku, než je vůbec uvidí Flate, výměnou za garanci, kterou hlavička modulu říká na rovinu: každá transformace je výstupně ekvivalentní a viditelnou stránku nikdy nezmění. Size optimizer, který hýbe textem, není optimizer, je to rendering bug s dobrým kompresním poměrem

Content-stream parser, peephole optimizer i save-time kompresní volby popsané tady sjíždějí v PDF Library for Delphi and C++Builder, která navíc vystavuje NormalizeContentStreams, CompressContent a TPDFlibSaveOptions pro dolaďování toho, jak se každý dokument zapíše