Τεχνικό Άρθρο

Identity Tm σε PDF streams: ασφαλής peephole αφαίρεση

Το PDF Library for Delphi αφαιρεί έναν τελεστή identity text matrix, 1 0 0 1 0 0 Tm, κατά τη βελτιστοποίηση peephole του content stream την ώρα της αποθήκευσης μόνο όταν η text matrix και η text line matrix είναι ήδη identity: αμέσως μετά BT, ή αμέσως μετά ένα προγενέστερο identity Tm. Ένα identity cm πετιέται ακόμα πάντα, επειδή το cm πολλαπλασιάζει τον CTM ενώ το Tm αντικαθιστά και τις δύο text matrices ολοκληρωτικά. Από το v3.539.28 κάθε άλλο identity Tm μένει στο stream

Το bug που αυτό διορθώνει είναι το ήσυχο είδος. Μια γεννήτρια αναφορών εκπέμπει BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET, βασιζόμενη στο identity Tm να στείλει τη δεύτερη συμβολοσειρά πίσω στην αρχή του text space πριν εφαρμόσει τη δική της λογική τοποθέτησης. Ο παλιότερος optimizer είδε έξι αριθμούς που γράφουν τον identity matrix, αποφάσισε πως ο τελεστής δεν μπορεί να αλλάξει τίποτα, και τον σβήσε. Τίποτα δεν απέτυχε, τίποτα δεν κατέγραψε προειδοποίηση, και η αποθηκευμένη σελίδα σχεδίασε «Total» αμέσως μετά το «Invoice» στην ίδια baseline, που είναι ακριβώς η κατηγορία ελαττώματος που κανείς δεν προσέχει μέχρι ένας πελάτης να τυπώσει το PDF

Γιατί το 1 0 0 1 0 0 Tm δεν είναι πάντα no-op;

Ένα identity Tm είναι no-op μόνο όταν θα αντικαθιστούσε δύο matrices που κρατούν ήδη την ταυτότητα, και αυτό είναι ιδιότητα των τελεστών πριν από αυτό, όχι των δικών του τελεστών. Το ISO 32000-1 §9.4.1 λέει ότι το BT αρχικοποιεί και την text matrix (Tm) και την text line matrix (Tlm) στην ταυτότητα, και το §9.4.2 ορίζει το Tm ως το να θέτει και τις δύο στις δοσμένες τιμές, όχι να κάνει concatenation πάνω τους. Συγκρίνετε αυτό με το cm (§8.4.4), που δεξιοπολλαπλασιάζει τον current transformation matrix: ο πολλαπλασιασμός με την ταυτότητα αφήνει κάθε CTM αμετάβλητο, οπότε το 1 0 0 1 0 0 cm είναι ασφαλές να σβηστεί οπουδήποτε. Μέσα σε ένα text object η εικόνα είναι διαφορετική. Τα Td, TD, T* και ένα μη identity Tm μετακινούν όλα το Tlm, και κάθε text-showing τελεστής (Tj, TJ, ', ") προωθεί το Tm κατά το πλάτος των glyphs που ζωγράφισε. Μετά από οποιονδήποτε από αυτούς, ένα identity Tm είναι μια γνήσια επαναφορά στην αρχή. Αν έχειτε ποτέ ιχνηλατήσει θέσεις κειμένου στο χέρι με τον state tracker CTM και text matrix του content stream, αυτή είναι η ίδια διάκριση μεταξύ concatenation κατάστασης και αντικατάστασής της

Το 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 στην ίδια baseline, και τίποτα δεν απέτυχε, δεν κατεγράφη ή προειδοποίησε στον δρόμο προς τον εκτυπωτή του πελάτη

Πώς η αντίστροφη σάρωση αποφασίζει ποιο identity Tm σβήνει

Το TPDFContentPeepholeOptimizer.RemoveIdentityMatrices τώρα περπατά προς τα πίσω από κάθε identity Tm και το σβήνει μόνο αν η σάρωση φτάσει πρώτα BT ή άλλο identity Tm. Το προγενέστερο identity Tm μετράει είτε κρατείται είτε μόλις προγραμματίστηκε το ίδιο για διαγραφή, επειδή και στις δύο περιπτώσεις άφησε και τις δύο matrices στην ταυτότητα, ακριβώς όπως κάνει το BT. Ο κανόνας ταξινομεί κάθε τελεστή που μπορεί να συναντήσει σε μία από δύο ομάδες:

  • Σταματά και κρατά το Tm: Td, TD, T*, ένα μη identity Tm, Tj, TJ, ', ", ET, κάθε τελεστή που ο parser δεν αναγνωρίζει, ή την αρχή του stream
  • Προσπερνά και συνεχίζει τη σάρωση: τελεστές που δεν αγγίζουν ποτέ Tm ή Tlm, όπως Tf, Tc, color setters, gs, marked-content τελεστές και cm
Το RemoveIdentityMatrices του PDFlibPas περπατά προς τα πίσω από κάθε identity Tm: Tf, Tc, color setters, gs και cm προσπερνώνται, ενώ Td, TD, T*, ένα μη identity Tm, Tj, TJ, άγνωστος τελεστής ή ET σταματά τη σάρωση και κρατά το Tm, και το BT εγγυάται το σβήσιμο
Ένα προγενέστερο identity Tm σταματά επίσης τη σάρωση, επειδή κρατημένο ή ήδη προγραμματισμένο για διαγραφή άφησε και τις δύο matrices στην ταυτότητα — με τον ένα ή τον άλλο τρόπο ο optimizer δεν μετακινεί ποτέ glyph

Οι συντηρητικές περιπτώσεις είναι επίτηδες. Ένας άγνωστος τελεστής θα μπορούσε να είναι οτιδήποτε, οπότε η σάρωση αρνείται να συλλογιστεί πέρα από αυτόν. Το ET κλείνει το text object, οπότε ένα Tm μετά από αυτό δεν έχει BT να εγγυηθεί τις τιμές των matrices. Η σάρωση δουλεύει επίσης ένα content stream τη φορά, που έχει σημασία για σελίδες των οποίων το /Contents είναι πίνακας: ένα layer που ξεκινά στη μέση ενός text object, χωρίς δικό του BT, κρατά το identity Tm του ακόμα κι όταν το προηγούμενο layer θα το είχε κάνει περιττό. Κοστίζει μερικά bytes σε περίεργα αρχεία και δεν μετακινεί ποτέ glyph. Αν επεξεργάζεστε κείμενο σελίδας σε επίπεδο οδηγίας, όπως στο walkthrough αντιστοίχισης χαρακτήρα σε byte του content, το ίδιο parsed μοντέλο TPDFContentProgram είναι αυτό που ξαναγράφει ο optimizer

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: άφησε τα bytes στην ησυχία τους
    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 στο invoice stream από την αρχή του άρθρου και το identity Tm επιζεί, επειδή η αντίστροφη σάρωση πέφτει σε Tj πριν φτάσει το BT. Βάλτε /F1 12 Tf, 2 Tc και 0 g ανάμεσα στο BT και το identity Tm και πάλι φεύγει, αφού κανένας από αυτούς δεν αγγίζει τις text matrices. Μια αλληλουχία όπως BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm χάνει ακριβώς έναν τελεστή: το πρώτο identity Tm επαναφέρει τη matrix που μετακίνησε το Td, και μόνο το δεύτερο είναι περιττό

Πότε τρέχει πραγματικά ο peephole optimizer;

Ο optimizer τρέχει μόνο κατά το πέρασμα συμπίεσης, μέσα στο TPDFPageTree.Compress, και μόνο σε content streams που δεν είναι ήδη Flate-συμπιεσμένα. Το TPDFlib.SetOptimizeContentStreams(1) είναι το default, και ο ίδιος διακόπτης εκτίθεται ως το πεδίο OptimizeContentStreams του TPDFlibSaveOptions· τόσο το CompressContent όσο και το CompressPage τον σέβονται. Ένα stream του οποίου το /Filter είναι ήδη /FlateDecode παραλείπεται ολότελα, οπότε το άνοιγμα ενός υπάρχοντος συμπιεσμένου PDF και η ξανααποθήκευσή του δεν ξαναγράφει τους τελεστές του. Αν το stream αποτύχει να γίνει parse, τα πρωτότυπα αποκωδικοποιημένα bytes συμπιέζονται αμετάβλητα. Το TPDFlib.NormalizeContentStreams κάνει parse και ξαναεκπέμπει content με κανονικοποιημένα διαστήματα και αριθμούς αλλά δεν καλεί ποτέ τον optimizer, που το κάνει χρήσιμο baseline όταν θέλετε να δείτε πόση διαφορά μεγέθους συνεισφέρουν οι peephole κανόνες, δίπλα στα μεγαλύτερα κέρδη που καλύπτονται στο βελτιστοποίηση μεγέθους PDF αρχείων με font subsetting

Το PDFlibPas τρέχει τον peephole optimizer μόνο μέσα στο πέρασμα συμπίεσης της αποθήκευσης: το TPDFPageTree.Compress σέβεται το SetOptimizeContentStreams, ένα stream ήδη φιλτραρισμένο με /FlateDecode παραλείπεται ολότελα, ένα μη αναλύσιμο stream συμπιέζεται με τα πρωτότυπα bytes του αμετάβλητα, και το NormalizeContentStreams δεν καλεί ποτέ τον optimizer
Τα παραλειπόμενα συμπιεσμένα streams είναι το ήσυχο κομμάτι: ανοίξτε ένα υπάρχον PDF, αποθηκεύστε το ξανά, και οι τελεστές του βγαίνουν ανέγγιχτοι επειδή ο optimizer ξαναγράφει μόνο streams που πρώτα αποκωδικοποίησε
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Τα ασυμπίεστα streams περνούν από τους peephole κανόνες, μετά Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // Η ίδια επιλογή μέσω των bundled save options· το 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;

Τι εγγυόταν πραγματικά το παλιό regression test;

Το παλιό regression test εγγυόταν ένα μόνο σχήμα: ένα identity Tm απευθείας μετά BT αφαιρείται. Το Peephole_RemovesIdentityTextMatrix τροφοδοτεί τον optimizer με BT 1 0 0 1 0 0 Tm (hello) Tj ET και κάνει assert πως δεν μένει κανένα Tm. Μια προγενέστερη έκδοση είχε ήδη επισημάνει ότι το σβήσιμο ενός identity Tm είναι ανασφαλές όταν το Tlm δεν είναι η ταυτότητα, και μετά κράτησε ούτως ή άλλως τη συμπεριφορά επειδή το τεστ την «κλείδωνε». Διαβασμένο προσεκτικά, το τεστ δεν λέει τίποτα για ένα identity Tm μετά από Td ή μετά από εμφανισμένο κείμενο· η μεταχείριση της κάλυψης ενός δείγματος ως του συμβολαίου όλου του κανόνα ήταν το πραγματικό λάθος. Το fix κρατά εκείνη την αρχική περίπτωση πράσινη και προσθέτει έξι περιπτώσεις που καρφώνουν τόσο τα αφαιρήσιμα σχήματα όσο και τα κρατούμενα, συμπεριλαμβανομένου ενός Tm έξω από κάθε text object και ενός που έπεται του ET

Το trade-off είναι εύκολο να το δεχτείτε μόλις γραφτεί. Γεννήτριες που τυλίγουν κάθε text object ως BT 1 0 0 1 0 0 Tm ... εξακολουθούν να παίρνουν τον περιττό τελεστή αφαιρεμένο, και εκεί ερχόταν σχεδόν όλο το κέρδος. Αυτό που παραδίνει ο optimizer είναι το περιστασιακό identity Tm στη μέση ενός text object, μια χούφτα bytes ανά σελίδα πριν τα δει καν το Flate, σε αντάλλαγμα μιας εγγύησης που ο header της μονάδας δηλώνει ξεκάθαρα: κάθε transform είναι output-ισοδύναμο και δεν αλλάζει ποτέ την ορατή σελίδα. Ένας size optimizer που μετακινεί κείμενο δεν είναι optimizer, είναι ένα rendering bug με καλούς λόγους συμπίεσης

Ο parser του content stream, ο peephole optimizer και οι επιλογές συμπίεσης της αποθήκευσης που περιγράφονται εδώ κυκλοφορούν όλοι στο PDF Library for Delphi και C++Builder, που εκθέτει επίσης NormalizeContentStreams, CompressContent και TPDFlibSaveOptions για ρύθμιση του πώς γράφεται κάθε έγγραφο