Teknisk artikel

Identity-Tm i PDF-strömmar: säker peephole-borttagning

PDF Library for Delphi tar bort en identity-textmatrisoperator, 1 0 0 1 0 0 Tm, under sin peephole-optimering av innehållsströmmar vid sparande bara när textmatrisen och textradsmatrisen redan är identiteten: direkt efter BT, eller direkt efter en tidigare identity-Tm. En identity-cm tas fortfarande alltid bort, eftersom cm multiplicerar CTM:en medan Tm ersätter båda textmatriserna rakt av. Sedan v3.539.28 stannar varje annan identity-Tm kvar i strömmen

Buggen som detta fixar är av den tysta sorten. En rapportgenerator sänder ut BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET och förlitar sig på identity-Tm:en för att skicka tillbaka den andra strängen till textrummet origo innan den tillämpar sin egen positioneringslogik. Den äldre optimeraren såg sex tal som stavar identitetsmatrisen, beslöt att operatorn omöjligt kunde ändra något, och raderade den. Inget fallerade, inget loggade en varning, och den sparade sidan ritade "Total" direkt efter "Invoice" på samma baslinje, vilket är precis den klass av defekt som ingen lägger märke till förrän en kund skriver ut PDF:en

Varför är 1 0 0 1 0 0 Tm inte alltid en no-op?

En identity-Tm är en no-op bara när den skulle ersätta två matriser som redan innehåller identiteten, och det är en egenskap hos operatorerna före den, inte hos dess egna operander. ISO 32000-1 §9.4.1 säger att BT initierar både textmatrisen (Tm) och textradsmatrisen (Tlm) till identiteten, och §9.4.2 definierar Tm som att sätta båda till de givna värdena, inte att konkatenera på dem. Jämför med cm (§8.4.4), som högermultiplicerar den aktuella transformationsmatrisen: att multiplicera med identiteten lämnar varje CTM oförändrad, så 1 0 0 1 0 0 cm är säker att radera var som helst. Inuti ett textobjekt ser bilden annorlunda ut. Td, TD, T* och en icke-identity-Tm flyttar alla Tlm, och varje textvisande operator (Tj, TJ, ', ") flyttar fram Tm med bredden av de glyfer den ritade. Efter någon av dem är en identity-Tm en äkta återställning till origo. Om du någon gång har spårat textpositioner för hand med tillståndsspåraren för innehållsströmmars CTM och textmatris är det samma distinktion mellan att konkatenera tillstånd och att ersätta det

PDFlibPas behandlar 1 0 0 1 0 0 cm och 1 0 0 1 0 0 Tm olika: cm högermultiplicerar CTM:en och är en no-op var som helst, medan Tm ersätter Tm och Tlm rakt av, och varje Tj flyttar fram Tm med bredden den ritade, så en identity-Tm efter visad text är en äkta återställning
Rapportgeneratorn räknade med den återställningen: att radera identity-Tm:en ritade Total direkt efter Invoice på samma baslinje, och inget fallerade, loggade eller varnade på vägen till kundens skrivare

Hur den bakåtgående skanningen avgör vilka identity-Tm som tas bort

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices går nu baklänges från varje identity-Tm och tar bort den bara om skanningen når BT eller en annan identity-Tm först. Den tidigare identity-Tm:en räknas oavsett om den behålls eller själv just har schemalagts för radering, för på båda sätten lämnade den båda matriserna vid identiteten, exakt som BT gör. Regeln sorterar varje operator den kan träffa på i en av två grupper:

  • Stanna och behåll Tm:en: Td, TD, T*, en icke-identity-Tm, Tj, TJ, ', ", ET, varje operator parsaren inte känner igen, eller strömmens början
  • Stega förbi och fortsätt skanna: operatorer som aldrig rör Tm eller Tlm, som Tf, Tc, färgsättare, gs, marked-content-operatorer och cm
PDFlibPas RemoveIdentityMatrices går baklänges från varje identity-Tm: Tf, Tc, färgsättare, gs och cm stegas förbi, medan Td, TD, T*, en icke-identity Tm, Tj, TJ, en okänd operator eller ET stoppar skanningen och behåller Tm:en, och BT intygar att den får tas bort
En tidigare identity-Tm stoppar också skanningen, för behållen eller redan schemalagd för radering lämnade den båda matriserna vid identiteten — oavsett vilket flyttar optimeraren aldrig en glyf

De konservativa fallen är medvetna val. En okänd operator kan vara vad som helst, så skanningen vägrar resonera förbi den. ET stänger textobjektet, så en Tm efter den har ingen BT som intygar matrisvärdena. Skanningen arbetar också med en innehållsström i taget, vilket spelar roll för sidor vars /Contents är en array: ett lager som börjar mitt i ett textobjekt, utan egen BT, behåller sin identity-Tm även när föregående lager hade gjort den redundant. Det kostar några byte på udda filer och flyttar aldrig en glyf. Om du redigerar sidtext på instruktionsnivå, som i genomgången av mappningen från tecken till innehållsbyte, är samma parsade TPDFContentProgram-modell det optimeraren skriver om

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; // skadad ström: lämna bytena i fred
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // returnerar antalet borttagna instruktioner
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // en instruktion per rad
  finally
    Prog.Free;
  end;
end;

// Borttagna: Tm direkt efter BT, den andra av två identity-Tm i rad
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Behållna: Tm efter Td, efter Tj, efter ett icke-identity Tm, eller utanför BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Kör hjälparen på fakturaströmmen från inledningen så överlever identity-Tm:en, eftersom den bakåtgående skanningen träffar Tj innan den når BT. Lägg /F1 12 Tf, 2 Tc och 0 g mellan BT och identity-Tm:en så går den ändå, eftersom ingen av dem rör textmatriserna. En sekvens som BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm förlorar exakt en operator: den första identity-Tm:en återställer matrisen som Td flyttade, och bara den andra är redundant

När körs peephole-optimeraren egentligen?

Optimeraren körs bara under komprimeringspasset, inuti TPDFPageTree.Compress, och bara på innehållsströmmar som inte redan är Flate-komprimerade. TPDFlib.SetOptimizeContentStreams(1) är standard, och samma omkopplare exponeras som fältet OptimizeContentStreams i TPDFlibSaveOptions; både CompressContent och CompressPage respekterar den. En ström vars /Filter redan är /FlateDecode hoppas över helt, så att inläsning av en befintlig komprimerad PDF som sparas igen inte skriver om dess operatorer. Om strömmen inte går att parsa komprimeras de ursprungliga avkodade bytena oförändrade. TPDFlib.NormalizeContentStreams parsar och sänder ut innehåll med kanoniska mellanrum och tal men anropar aldrig optimeraren, vilket gör den till en användbar baslinje när du vill se hur stor del av storleksskillnaden peephole-reglerna bidrar med, jämte de större vinsterna som täcks i PDF-filsoptimering med teckensnitts-subsetting

PDFlibPas kör peephole-optimeraren bara inuti komprimeringspasset vid sparande: TPDFPageTree.Compress respekterar SetOptimizeContentStreams, en ström som redan filtrerats med /FlateDecode hoppas över helt, en oparsbar ström komprimeras med sina ursprungliga byte oförändrade, och NormalizeContentStreams anropar aldrig optimeraren
Hoppade komprimerade strömmar är den tysta delen: läs in en befintlig PDF, spara den igen, och dess operatorer kommer ut orörda eftersom optimeraren bara skriver om strömmar den först avkodade
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Okomprimerade strömmar går genom peephole-reglerna, sedan Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // Samma val via de medföljande sparalternativen; False tackar nej
    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;

Vad garanterade det gamla regressionstestet egentligen?

Det gamla regressionstestet garanterade bara en form: en identity-Tm direkt efter BT tas bort. Peephole_RemovesIdentityTextMatrix matar BT 1 0 0 1 0 0 Tm (hello) Tj ET till optimeraren och gör påståendet att ingen Tm återstår. En tidigare release hade redan konstaterat att det är otryggt att ta bort en identity-Tm när Tlm inte är identiteten, och behöll ändå beteendet eftersom testet "låste" det. Läst noggrant säger testet ingenting om en identity-Tm efter Td eller efter visad text; att behandla ett enskilt provs täckning som hela regelns kontrakt var själva misstaget. Fixen låter det ursprungliga fallet passera och lägger till sex fall som låser både de borttagningsbara formerna och de behållna, inklusive en Tm utanför varje textobjekt och en som följer ET

Avvägningen är lätt att acceptera när den väl är nedskriven. Generatorer som slår in varje textobjekt som BT 1 0 0 1 0 0 Tm ... får fortfarande den redundanta operatorn borttagen, och det är där nästan all besparing kom ifrån. Det optimeraren ger upp är den enstaka identity-Tm:en mitt i ett textobjekt, en handfull byte per sida innan Flate ens ser dem, i utbyte mot en garanti som modulhuvudet säger rakt ut: varje transform är utdataekvivalent och ändrar aldrig den synliga sidan. En storleksoptimerare som flyttar text är ingen optimerare, det är en renderingsbugg med bra komprimeringsgrad

Innehållsström-parsaren, peephole-optimeraren och sparalternativen för komprimering som beskrivs här följer alla med i PDF Library for Delphi and C++Builder, som också exponerar NormalizeContentStreams, CompressContent och TPDFlibSaveOptions för att ställa in hur varje dokument skrivs