Artykuł techniczny

Identity Tm w PDF: bezpieczne usuwanie w peephole

PDF Library for Delphi usuwa operator jednostkowej macierzy tekstu, 1 0 0 1 0 0 Tm, podczas zapisowej optymalizacji peephole strumieni treści tylko wtedy, gdy macierz tekstu i macierz linii tekstu są już jednostkowe: tuż po BT albo tuż po wcześniejszym jednostkowym Tm. Jednostkowy cm jest nadal zawsze wyrzucany, bo cm mnoży CTM, podczas gdy Tm zastępuje obie macierze tekstu w całości. Od v3.539.28 każde inne jednostkowe Tm zostaje w strumieniu

Błąd, który to naprawia, jest cichego gatunku. Generator raportów emituje BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET i polega na jednostkowym Tm, żeby odesłać drugi łańcuch do początku przestrzeni tekstu, zanim zastosuje własną logikę pozycjonowania. Starszy optymalizator widział sześć liczb układających się w macierz jednostkową, uznał, że operator nie może niczego zmienić, i go skasował. Nic nie zawiodło, nic nie zapisało ostrzeżenia, a zapisana strona rysowała „Total" natychmiast po „Invoice" na tej samej linii bazowej — czyli dokładnie tę klasę defektu, której nikt nie zauważa, dopóki klient nie wydrukuje PDF-a

Dlaczego 1 0 0 1 0 0 Tm nie zawsze jest no-op?

Jednostkowe Tm jest no-opem tylko wtedy, gdy miałoby zastąpić dwie macierze, które już trzymają jednostkową, a to własność operatorów przed nim, nie jego własnych argumentów. ISO 32000-1 §9.4.1 mówi, że BT inicjalizuje zarówno macierz tekstu (Tm), jak i macierz linii tekstu (Tlm) na jednostkową, a §9.4.2 definiuje Tm jako ustawienie obu na podane wartości, nie doklejanie do nich. Porównaj z cm (§8.4.4), które mnoży aktualną macierz transformacji z prawej: mnożenie przez jednostkową nie zmienia żadnego CTM, więc 1 0 0 1 0 0 cm można bezpiecznie usuwać wszędzie. Wewnątrz obiektu tekstu obraz jest inny. Td, TD, T* i niejednostkowe Tm przesuwają Tlm, a każdy operator pokazujący tekst (Tj, TJ, ', ") posuwa Tm o szerokość namalowanych glifów. Po każdym z nich jednostkowe Tm jest prawdziwym resetem do początku. Jeśli kiedykolwiek śledziłeś pozycje tekstu ręcznie za pomocą trackera stanu CTM i macierzy tekstu strumienia treści, to dokładnie to samo rozróżnienie między doklejaniem stanu a jego zastępowaniem

PDFlibPas traktuje 1 0 0 1 0 0 cm i 1 0 0 1 0 0 Tm różnie: cm mnoży CTM z prawej i jest no-opem wszędzie, podczas gdy Tm zastępuje Tm i Tlm w całości, a każde Tj posuwa Tm o namalowaną szerokość, więc jednostkowe Tm po pokazanym tekście to prawdziwy reset
Generator raportów liczył na ten reset: usunięcie jednostkowego Tm namalowało Total natychmiast po Invoice na tej samej linii bazowej, a nic nie zawiodło, nie zalogowało i nie ostrzegło po drodze do drukarki klienta

Jak skan wstecz decyduje, które jednostkowe Tm wyrzucić

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices idzie teraz wstecz od każdego jednostkowego Tm i wyrzuca je tylko, jeśli skan najpierw dojdzie do BT albo innego jednostkowego Tm. Wcześniejsze jednostkowe Tm liczy się niezależnie od tego, czy jest zachowywane, czy samo właśnie trafiło do kolejki usunięcia, bo i tak zostawiło obie macierze jednostkowe, dokładnie jak robi to BT. Reguła sortuje każdego operatora, jakiego może spotkać, do jednej z dwóch grup:

  • Stop i zachowaj Tm: Td, TD, T*, niejednostkowe Tm, Tj, TJ, ', ", ET, każdy operator nierozpoznany przez parser albo początek strumienia
  • Przejdź obok i skanuj dalej: operatory, które nigdy nie dotykają Tm ani Tlm, jak Tf, Tc, ustawiacze koloru, gs, operatory marked-content i cm
RemoveIdentityMatrices w PDFlibPas idzie wstecz od każdego jednostkowego Tm: Tf, Tc, ustawiacze koloru, gs i cm są omijane, podczas gdy Td, TD, T*, niejednostkowe Tm, Tj, TJ, nieznany operator albo ET stopuje skan i zachowuje Tm, a BT rękuje za jego wyrzucenie
Wcześniejsze jednostkowe Tm też stopuje skan, bo zachowane albo już zaplanowane do usunięcia zostawiło obie macierze jednostkowe — i tak optymalizator nigdy nie rusza glifa

Konserwatywne przypadki są zamierzone. Nieznany operator może być czegokolwiek, więc skan odmawia wnioskowania poza nim. ET zamyka obiekt tekstu, więc Tm po nim nie ma BT, które rękuje za wartości macierzy. Skan pracuje też na jednym strumieniu treści naraz, co ma znaczenie dla stron, których /Contents jest tablicą: warstwa startująca w środku obiektu tekstu, bez własnego BT, zachowuje swoje jednostkowe Tm, nawet gdy poprzednia warstwa uczyniłaby je zbędnym. To kosztuje kilka bajtów na dziwacznych plikach i nigdy nie rusza glifa. Jeśli edytujesz tekst strony na poziomie instrukcji, jak w przewodniku po mapowaniu znak-bajt treści, ten sam sparsowany model TPDFContentProgram jest tym, co przepisuje optymalizator

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; // uszkodzony strumień: bajty zostają jak są
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // zwraca liczbę usuniętych instrukcji
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // jedna instrukcja na linię
  finally
    Prog.Free;
  end;
end;

// Usuwane: Tm bezpośrednio po BT, drugie z dwóch identity Tm pod rząd
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Zachowywane: Tm po Td, po Tj, po nie-jednostkowym Tm albo poza BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Uruchom helper na strumieniu faktury z otwarcia, a jednostkowe Tm przeżyje, bo skan wstecz wpada na Tj, zanim dojdzie do BT. Wstaw /F1 12 Tf, 2 Tc i 0 g między BT a jednostkowym Tm, a ono nadal poleci, bo żadne z nich nie dotyka macierzy tekstu. Sekwencja taka jak BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm traci dokładnie jeden operator: pierwsze jednostkowe Tm resetuje macierz, którą przesunęło Td, a zbędne jest tylko drugie

Kiedy optymalizator peephole faktycznie się uruchamia?

Optymalizator rusza tylko podczas przebiegu kompresji, wewnątrz TPDFPageTree.Compress, i tylko na strumieniach treści, które nie są już skompresowane Flate'em. TPDFlib.SetOptimizeContentStreams(1) jest domyślne, a ten sam przełącznik występuje jako pole OptimizeContentStreams w TPDFlibSaveOptions; honorują go zarówno CompressContent, jak i CompressPage. Strumień, którego /Filter to już /FlateDecode, jest pomijany w całości, więc wczytanie istniejącego skompresowanego PDF-a i ponowny zapis nie przepisuje jego operatorów. Gdy strumień nie da się sparsować, oryginalne zdekodowane bajty są kompresowane bez zmian. TPDFlib.NormalizeContentStreams parsuje i wypuszcza treść z kanonicznymi odstępami i liczbami, ale nigdy nie woła optymalizatora, co czyni go użytecznym punktem odniesienia, gdy chcesz zobaczyć, ile wielkości dokłada sama reguła peephole, obok większych zysków opisanych w artykule o optymalizacji rozmiaru plików PDF z subsetowaniem fontów

PDFlibPas uruchamia optymalizator peephole tylko wewnątrz zapisowego przebiegu kompresji: TPDFPageTree.Compress honoruje SetOptimizeContentStreams, strumień już przefiltrowany /FlateDecode jest pomijany w całości, strumień nieparsowalny jest kompresowany z oryginalnymi bajtami bez zmian, a NormalizeContentStreams w ogóle nie woła optymalizatora
Pomijane skompresowane strumienie to cicha część: wczytaj istniejący PDF, zapisz go ponownie, a jego operatory wychodzą nietknięte, bo optymalizator przepisuje tylko strumienie, które najpierw sam zdekodował
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Nieskompresowane strumienie przechodzą reguły peephole, potem Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // Ten sam wybór przez dołączone opcje zapisu; False rezygnuje
    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 stary test regresji naprawdę gwarantował?

Stary test regresji gwarantował jeden kształt: jednostkowe Tm bezpośrednio po BT jest usuwane. Peephole_RemovesIdentityTextMatrix podaje BT 1 0 0 1 0 0 Tm (hello) Tj ET optymalizatorowi i asertuje, że nie zostaje żadne Tm. Wcześniejsze wydanie odnotowało już, że wyrzucanie jednostkowego Tm jest niebezpieczne, gdy Tlm nie jest jednostkowe, a mimo to zostawiło zachowanie, bo test je „zablokował". Czytany uważnie test nie mówi nic o jednostkowym Tm po Td ani po pokazanym tekście; potraktowanie pokrycia jednej próbki jako kontraktu całej reguły było właściwym błędem. Poprawka utrzymuje oryginalny przypadek na zielono i dodaje sześć przypadków przypinających zarówno kształty usuwalne, jak i zachowywane, łącznie z Tm poza jakimkolwiek obiektem tekstu i takim po ET

Kompromis jest łatwy do zaakceptowania, gdy spisze się go na papierze. Generatory owijające każdy obiekt tekstu w BT 1 0 0 1 0 0 Tm ... nadal dostają usunięty ten zbędny operator, i stąd pochodziła niemal cała oszczędność. Czego optymalizator się wyrzeka, to sporadyczne jednostkowe Tm w środku obiektu tekstu, garść bajtów na stronę, zanim Flate w ogóle je zobaczy, w zamian za gwarancję, którą nagłówek modułu stawia czarno na białym: każda transformacja jest równoważna wyjściowo i nigdy nie zmienia widocznej strony. Optymalizator rozmiaru, który rusza tekst, to nie optymalizator, tylko błąd renderowania z dobrymi współczynnikami kompresji

Parser strumieni treści, optymalizator peephole i zapisowe opcje kompresji opisane tutaj są częścią PDF Library for Delphi and C++Builder, która wystawia też NormalizeContentStreams, CompressContent i TPDFlibSaveOptions do strojenia tego, jak każdy dokument jest zapisywany