Odborný článok

Identity Tm v PDF content streamoch: bezpečná redukcia

PDF Library for Delphi odstraňuje operátor identity text matrix, 1 0 0 1 0 0 Tm, pri optimalizácii content streamov peephole počas ukladania len vtedy, keď sú text matrix aj text line matrix už identické: tesne po BT alebo tesne po predchádzajúcom identickom Tm. Identické cm sa naďalej zahodí vždy, lebo cm násobí CTM, zatiaľ čo Tm obe textové matice rovno nahradí. Od v3.539.28 ostanú v streame všetky ostatné identické Tm

Chyba, ktorú to opravuje, je tichého typu. Generátor reportov vyprodukuje BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET a spolieha sa na identické Tm, aby druhý reťazec vrátil do počiatku textového priestoru skôr, než si aplikuje vlastnú pozicovaciu logiku. Starší optimizer videl šesť čísel hláskujúcich identickú maticu, rozhodol, že operátor nemôže nič zmeniť, a vymazal ho. Nič nezlyhalo, nič nezalogovalo varovanie a uložená strana nakreslila „Total“ hneď za „Invoice“ na tom istom baseline, čo je presne tá trieda defektov, ktorej si nikto nevšimne, kým zákazník nevytlačí PDF

Prečo nie je 1 0 0 1 0 0 Tm vždy no-op?

Identické Tm je no-op len vtedy, keď by nahradilo dve matice, ktoré už identitu držia, a to je vlastnosť operátorov pred ním, nie jeho vlastných operandov. ISO 32000-1 §9.4.1 hovorí, že BT inicializuje text matrix (Tm) aj text line matrix (Tlm) na identitu a §9.4.2 definuje Tm ako nastavenie oboch na dané hodnoty, nie ich napojenie. Porovnajte to s cm (§8.4.4), ktoré násobí aktuálnu transformačnú maticu sprava: násobenie identitou nechá každé CTM nezmenené, takže 1 0 0 1 0 0 cm je bezpečné vymazať hocikde. Vnútri textového objektu je obraz iný. Td, TD, T* a neidentické Tm všetky hýbu s Tlm a každý operátor zobrazujúci text (Tj, TJ, ', ") posunie Tm o šírku nakreslených glyfov. Po ktoromkoľvek z nich je identické Tm skutočný reset do počiatku. Ak ste textové pozície niekedy vysledovali ručne cez state tracker CTM a text matrix v content streame, je to to isté rozlíšenie medzi napojením stavu a jeho nahradením

PDFlibPas zaobchádza s 1 0 0 1 0 0 cm a 1 0 0 1 0 0 Tm odlišne: cm násobí CTM sprava a je no-op kdekoľvek, zatiaľ čo Tm rovno nahradí Tm aj Tlm a každé Tj posunie Tm o nakreslenú šírku, takže identické Tm po zobrazenom texte je skutočný reset
Generátor reportov sa na ten reset spoliehal: vymazanie identického Tm nakreslilo Total hneď za Invoice na tom istom baseline a nič nezlyhalo, nič sa nezalogovalo ani nevarovalo, kým sa to dostalo k tlačiarni zákazníka

Ako backward scan rozhoduje, ktoré identické Tm zahodiť

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices teraz prechádza od každého identického Tm dozadu a zahodí ho, len ak sa sken skôr dospeje k BT alebo k ďalšiemu identickému Tm. Predchádzajúce identické Tm sa počíta, či už je ponechané, alebo bolo samo práve naplánované na vymazanie, lebo oboma spôsobmi nechalo obe matice na identite, presne ako to robí BT. Pravidlo roztriedi každý operátor, ktorý stretne, do jednej z dvoch skupín:

  • Zastav a Tm ponechaj: Td, TD, T*, neidentické Tm, Tj, TJ, ', ", ET, akýkoľvek operátor, ktorý parser nepozná, alebo začiatok streamu
  • Prejdi cez a skenuj ďalej: operátory, ktoré na Tm ani Tlm nesiahnu, ako Tf, Tc, nastavovače farieb, gs, operátory marked-content a cm
RemoveIdentityMatrices v PDFlibPas prechádza od každého identického Tm dozadu: Tf, Tc, nastavovače farieb, gs a cm sa preskočia, zatiaľ čo Td, TD, T*, neidentické Tm, Tj, TJ, neznámy operátor alebo ET sken zastaví a Tm ponechá, a BT ručí za jeho zahodenie
Predchádzajúce identické Tm sken tiež zastaví, lebo ponechané alebo už naplánované na vymazanie nechalo obe matice na identite — a optimizer nikdy nepohne glyfom

Konzervatívne prípady sú zámerné. Neznámy operátor môže byť čokoľvek, takže sken odmieta uvažovať za neho. ET uzatvára textový objekt, takže Tm po ňom nemá BT, ktoré by ručilo za hodnoty matíc. Sken pracuje navyše vždy len s jedným content streamom, čo má význam na stranách, ktorých /Contents je pole: vrstva, ktorá začína v strede textového objektu bez vlastného BT, si nechá svoje identické Tm, aj keď by ju predchádzajúca vrstva urobila redundantnou. Stojí to pár bajtov na zvláštnych súboroch a nikdy nepohne glyfom. Ak upravujete text strany na úrovni inštrukcií, ako v sprievodcovi mapovaním znakov na content bajty, optimizer prepisuje presne ten istý naparsovaný model 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; // poškodený stream: bajty nechaj na pokoji
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // vráti počet odstránených inštrukcií
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // jedna inštrukcia na riadok
  finally
    Prog.Free;
  end;
end;

// Odstránené: Tm tesne po BT, druhé z dvoch identických Tm za sebou
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Ponechané: Tm po Td, po Tj, po neidentickom Tm alebo mimo BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Spustite helper na invoice streame z úvodu a identické Tm prežije, lebo backward scan narazí na Tj skôr, než dôjde k BT. Vložte /F1 12 Tf, 2 Tc a 0 g medzi BT a identické Tm a aj tak zmizne, keďže žiaden z nich na textové matice nesiahne. Sekvencia ako BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm prichádza o presne jeden operátor: prvé identické Tm resetuje maticu, ktorú pohnulo Td, a redundantné je len to druhé

Kedy peephole optimizer naozaj beží?

Optimizer beží len počas kompresného priebehu, vnútri TPDFPageTree.Compress, a len na content streamoch, ktoré už nie sú Flate-komprimované. Predvolené je TPDFlib.SetOptimizeContentStreams(1) a ten istý prepínač je vystavený ako pole OptimizeContentStreams v TPDFlibSaveOptions; rešpektujú ho aj CompressContent, aj CompressPage. Stream, ktorého /Filter je už /FlateDecode, sa preskočí celý, takže načítanie existujúceho komprimovaného PDF a opätovné uloženie neprepíše jeho operátory. Ak sa stream nepodarí naparsovať, komprimujú sa pôvodné dekódované bajty bez zmeny. TPDFlib.NormalizeContentStreams parsuje a emituje obsah nanovo s kanonickými medzerami a číslami, ale optimizer nikdy nezavolá, čo z neho robí užitočný baseline, keď chcete vidieť, koľko na veľkosti pridávajú peephole pravidlá, vedľa väčších úspor popísaných v článku o optimalizácii veľkosti PDF s font subsettingom

PDFlibPas spúšťa peephole optimizer len vnútri kompresného priebehu pri ukladaní: TPDFPageTree.Compress rešpektuje SetOptimizeContentStreams, stream už filtrovaný cez /FlateDecode sa preskočí celý, neparsovateľný stream sa komprimuje s pôvodnými bajtami bez zmeny a NormalizeContentStreams optimizer nezavolá vôbec
Preskočené komprimované streamy sú tá tichá časť: načítajte existujúce PDF, uložte ho znova a jeho operátory vyjdú nedotknuté, lebo optimizer prepisuje len streamy, ktoré najprv sám dekódoval
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Nekomprimované streamy prejdu peephole pravidlami, potom Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // Tá istá voľba cez priložené save options; False sa odhlási
    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;

Čo ten starý regresný test vlastne garantoval?

Starý regresný test garantoval jediný tvar: identické Tm tesne po BT sa odstráni. Peephole_RemovesIdentityTextMatrix podá optimizeru BT 1 0 0 1 0 0 Tm (hello) Tj ET a asertuje, že nezostane žiadne Tm. Skoršie vydanie si už vtedy poznamenalo, že zahodenie identického Tm nie je bezpečné, keď Tlm nie je identita, a správanie aj tak nechalo, lebo ho test „uzamkol“. Pri pozornom čítaní test o identickom Tm po Td alebo po zobrazenom texte nepovie nič; brať pokrytie jednej vzorky ako kontrakt celého pravidla bola tá pravá chyba. Oprava nechá pôvodný prípad prechádzať a pridá šesť prípadov, ktoré priklincujú odstrániteľné tvary aj ponechávané, vrátane Tm mimo akéhokoľvek textového objektu a takého, ktoré nasleduje po ET

Kompromis sa dá ľahko prijať, keď je napísaný. Generátory, ktoré každý textový objekt obalia ako BT 1 0 0 1 0 0 Tm ..., sa toho redundantného operátora aj naďalej zbavia, a práve odtiaľ pochádzali takmer všetky úspory. Optimizer sa teda vzdáva občasného identického Tm uprostred textového objektu, hrstky bajtov na stranu ešte predtým, než ich vôbec uvidí Flate, výmenou za garanciu, ktorú hlavička modulu hovorí nahlas: každá transformácia je výstupne ekvivalentná a viditeľnú stranu nikdy nezmení. Veľkostný optimizer, ktorý hýbe textom, nie je optimizer, je to rendering bug s dobrými kompresnými pomermi

Content-stream parser, peephole optimizer aj kompresné voľby pri ukladaní popísané tu dodávame v PDF Library for Delphi and C++Builder, ktorá navyše vystavuje NormalizeContentStreams, CompressContent a TPDFlibSaveOptions na doladenie toho, ako sa každý dokument zapíše