Teknisk artikel

PDF-objektströmmar och korsreferensströmmar i Delphi

PDF 1.5-objektströmmar packar många små indirekta objekt i en enda Flate-komprimerad behållare, och losLab PDF Library skriver ut dem vid en fullständig sparning via flaggan PackObjectStreams. Vinsten är verklig: hundratals sido-, teckensnitts- och anteckningsordböcker som var och en kostar dussintals okomprimerade byte kollapsar till en handfull komprimerade blobbar. Priset är att varje packat objekt nu behöver en korsreferensström för att beskrivas

Den andra halvan är där skrivare går sönder. Att bygga en /ObjStm-behållare är aritmetik; att lära korsreferensmaskineriet att peka in i den är en omkonstruktion. En skrivare som producerar en helt giltig behållare och sedan beskriver dess medlemmar med vanliga typ-1-offset har producerat en fil som Acrobat öppnar precis länge nog för att förklara den skadad. De två funktionerna är egentligen en enda funktion, och den här artikeln täcker skrivsidan av båda, så som de definieras i ISO 32000-1 §7.5.7 och §7.5.8

Vad en ObjStm-behållare faktiskt innehåller

En objektström är en ström vars avkodade byte utgör två sammanslagna regioner, och ISO 32000-1 §7.5.7 ger ordboken exakt tre nycklar som spelar roll för konstruktionen. /Type /ObjStm identifierar den, /N anger antalet medlemmar, och /First anger bytelängden på huvudregionen — med andra ord det offset där kroppen börjar. Huvudet är blankstegsseparerade par av objektnummer och offset; kroppen är medlemmarna serialiserade efter varandra, där varje offset mäts från kroppens början snarare än från början av den avkodade nyttolasten. Att läsa en fullständigt avkodad behållare gör det uppenbart: nedan är /First 14 eftersom de tre huvudraderna upptar fjorton byte, och objekt 7 ligger 55 byte in i kroppen eftersom objekt 4 serialiserades till 54 tecken plus en avgränsare

// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
//                        /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]

Två medlemskapsregler är absoluta och kommer båda direkt från §7.5.7. Ett strömobjekt kan aldrig vara medlem, eftersom en ström bär rådata som skulle behöva nästlas inuti en annan ström. Och en medlem måste vara ett komplett objektvärde, aldrig en ren indirekt referens — ett komprimerat objekt som bara är 5 0 R skapar en indirektion som läsaren inte kan lösa upp utan att redan veta vart den pekar. losLab PDF Library filtrerar bort båda fallen under kandidatinsamlingen, tillsammans med krypteringsordboken och objekt 0, och packar sedan det som återstår i grupper om 200 per behållare. Det taket är ett beslut om slumpmässig åtkomst snarare än en gräns i specifikationen: en läsare som vill ha en medlem måste packa upp hela behållaren, så överdimensionerade behållare gör små uppslag dyra

Varför måste ObjStm-medlemmar använda typ-2-korsreferensposter?

Därför att ett packat objekt inte har något filoffset att registrera. ISO 32000-1 §7.5.8 besvarar detta med tre posttyper i en binär korsreferensström: typ 0 för fria objekt, typ 1 för vanliga objekt i bruk lagrade vid ett byteoffset, och typ 2 för komprimerade objekt, vars två datafält innehåller behållarens objektnummer och medlemsindexet inom den. Det finns inget sätt att uttrycka ett packat objekt i den klassiska klartext-xref-tabellen, vilket är precis varför PDF 1.5 introducerade båda funktionerna tillsammans

Ordningsföljden som följer snubblar nästan varje förstagångsimplementation, inklusive vår egen. Vanliga objekt får typ-1-poster. Själva /ObjStm-behållarna får typ-1-poster, eftersom en behållare är ett helt normalt indirekt strömobjekt skrivet vid ett riktigt offset. Bara medlemmarna får typ-2-poster. Och korsreferensströmmen är själv ett indirekt objekt i filen, så den behöver sin egen typ-1-post som pekar på offsetet där den just skrevs — samma offset som startxref registrerar. En tidig version av vår skrivare exkluderade behållarnas objektnummer från skrivloopen i stället för att exkludera medlemmarna, och resultatet var en fil med en korsreferensström och inga objektströmmar alls: strukturellt sammanhängande, semantiskt tom, avvisad längre fram i kedjan. /Size-värdet döljer ett motsvarande off-by-one-fel, eftersom det är det högsta objektnumret plus ett, och korsreferensströmmen allokeras som det högsta objektnumret, så den måste räknas med också

Att storleksbestämma /W-arrayen: varför fyra byte inte räcker

/W-arrayen deklarerar bytebredden för vart och ett av de tre fälten, och losLab PDF Library skriver den som /W [1 Field2 Field3] med fält 1 fast på en byte för typkoden och fält 3 fast på två byte, vilket täcker både generationsnummer upp till 65535 och medlemsindex. Fält 2 är det som inte kan vara konstant, eftersom det bär två orelaterade storheter: i en typ-1-post är det ett byteoffset begränsat bara av filstorleken, medan det i en typ-2-post är ett behållarobjektnummer och i en typ-0-post är det nästa fria objekt i kedjan. Ett fast fyrabytefält 2 fungerar bra tills filen passerar 4 GB, då varje offset bortom gränsen tyst trunkeras och hela tabellen blir skräp. Skrivaren skannar därför den sammanställda tabellen efter det största värde någon fält-2-plats någonsin kommer att hålla, inklusive offsetet för själva korsreferensströmmen, och breddar fältet upp till åtta byte

// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
  if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
    Field2Value := XRefTable[X].ObjStrNum   // type-2: container number
  else
    Field2Value := XRefTable[X].ObjPos;     // type-1 offset / type-0 next-free
  if Field2Value > MaxField2Value then
    MaxField2Value := Field2Value;
end;

Field2 := 4;
while (Field2 < 8) and
      (MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
  Inc(Field2);
Field3 := 2;   // generation numbers and member indices both fit

När bredderna väl är kända är nyttolastens storlek exakt känd, så skrivaren förallokerar hela bufferten och fyller den via index; att lägga till poster byte för byte till en AnsiString gör tabellkonstruktionen kvadratisk, vilket ingen märker på en tiosidig faktura och alla märker på ett dokument med tvåhundratusen objekt. Två ytterligare detaljer håller strikta läsare nöjda. /Index deklarerar vilka objektnummerintervall tabellen täcker, och för en fullständig omskrivning är det helt enkelt [0 N] utan luckor. Och varje plats skrivaren faktiskt inte skrev ut måste som standard vara fri i stället för i bruk: objekt 0 leder den fria kedjan, varje fri plats länkar till nästa, och en plats som en gång innehöll ett borttaget objekt behåller sitt generationsnummer ökat med ett. Följdanteckningen om minnessäkerhet vid tolkning av opålitliga PDF-filer framför samma gränsargument från läsidan

Varför får korsreferensströmmen aldrig krypteras?

Därför att en läsare måste tolka den innan den kan veta hur något ska dekrypteras. Korsreferensströmmen är det som talar om för läsaren var /Encrypt-ordboken finns; om dess byte själva vore krypterade skulle läsaren behöva filnyckeln för att hitta objektet som beskriver filnyckeln. losLab PDF Library upprätthåller detta i ett enda predikat: ShouldCryptStreamData returnerar False närhelst strömordboken bär /Type /XRef, så undantaget gäller oavsett vilken väg som når serialiseraren

/ObjStm-behållaren får den motsatta behandlingen, och asymmetrin är avsiktlig. En behållare krypteras i sin helhet, nyckelad på sitt eget objektnummer, precis som vilken annan ström som helst. Dess medlemmar krypteras inte individuellt — de packas i sin dekrypterade klartextform, och den enda genomgången av den sammansatta behållaren täcker dem, strängar inkluderade. Att dubbelkryptera medlemmarna ger en fil som dekrypteras till chiffertext, och eftersom det yttre lagret lyckas dyker felet upp som ett tolkningsfel djupt inne i objektgrafen snarare än som ett autentiseringsfel. Ett objekt hålls då helt utanför schemat: i ett krypterat dokument hålls katalogen (Catalog) som ett direkt typ-1-objekt och packas aldrig, eftersom packning skulle tvinga inläsaren att packa upp och dekryptera en objektström för att nå dokumentroten, innan den dekrypteringskontext som roten hjälper till att etablera är fullt uppbyggd

Att slå på packning från Delphi

Den publika omkopplaren är PackObjectStreams, exponerad som ett fält på TPDFlibSaveOptions, som den fristående sättaren SetPackObjectStreams, och som en egenskap på dokumentobjektet. Den är som standard aktiverad och spärras automatiskt utifrån version: skrivaren packar bara när dokumentet redan är PDF 1.5 eller senare, och den anropar det interna minimiversionsskyddet så att ett packat dokument höjs till 1.5 i stället för att felmärkas. Efter sparningen rapporterar GetLastSaveUsedObjectStreams huruvida spärren faktiskt öppnades, vilket är det påstående du vill ha i ett regressionstest snarare än en jämförelse av bytestorlek

var
  Doc: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('report.pdf', '') <= 0 then
      Exit;

    Doc.SetInformation(0, '1.5');        // packing is gated on PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // drop orphans before packing
    Options.PackObjectStreams  := True;

    if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
      if Doc.GetLastSaveUsedObjectStreams = 1 then
        Writeln('Saved with ObjStm containers and an xref stream');
  finally
    Doc.Free;
  end;
end;

Ordningen spelar roll mellan packning och skräpsamling. Nåbarhetsanalysen måste köras först, eftersom en medlem som överlever in i en behållare drar med sig behållaren — om ett levande objekt packas är dess behållarnummer per definition nåbart, och att sopa bort behållaren strandar medlemmen utan något sätt att lokalisera den. Att köra insamlaren först innebär också att döda objekt aldrig ens kommer in i en behållare, vilket är där den sammansatta storleksvinsten kommer ifrån. Packning kompletterar de andra storleksspakarna snarare än att ersätta dem; genomgången av PDF-filstorleksoptimering och font-subsetting täcker spakarna som verkar på strömmars nyttolast, där objektströmmar verkar på strukturen

Gränser värda att känna till innan du aktiverar den

Inkrementella sparningar packar aldrig. En inkrementell uppdatering lägger till nya objekt och ett nytt korsreferensavsnitt samtidigt som tidigare revisioner lämnas fysiskt intakta, så att packa om befintliga objekt i nya behållare skulle göra föräldralösa de typ-1-poster som den föregående revisionen fortfarande refererar till; losLab PDF Library inaktiverar packning närhelst append-läge är aktivt, och artikeln om inkrementella uppdateringar och streaming i append-läge täcker den vägen i sin helhet. Dokument under PDF 1.5 behåller ovillkorligen klartext-korsreferenstabellen: en 1.4-konsument har ingen aning om vad /ObjStm betyder, och att tyst uppgradera ett dokument bara för att skrivaren föredrog en mindre fil vore fel avvägning att göra å anroparens vägnar. En valfri nyckel vi medvetet inte skriver ut är /Extends, som ISO 32000-1 §7.5.7 definierar så att en behållare kan namnge en föregångare och läsare kan behandla en kedja av behållare som en logisk grupp. Den är genuint valfri, varje behållare vi skriver är självständig och oberoende avkodningsbar, och att hoppa över den tar bort en hel klass av cykel- och hängande referens-buggar från skrivaren — även om läsare förstås fortfarande måste respektera /Extends när de möter den i filer från andra tillverkare

Packning av objektströmmar och utskrift av korsreferensströmmar levereras som en del av losLab PDF Library för Delphi och C++Builder, tillsammans med skräpsamlaren och innehållsström-optimeraren de samverkar med; produktsidan innehåller den fullständiga referensen för sparalternativen