Teknisk artikel

PDF-objektstrømme og krydsreferencestrømme i Delphi

PDF 1.5-objektstrømme pakker mange små indirekte objekter ind i én Flate-komprimeret container, og losLab PDF Library udsender dem ved en fuld gem gennem sit PackObjectStreams-flag. Gevinsten er reel: hundredvis af side-, font- og annotations-dictionaries, der hver koster snesevis af ukomprimerede bytes, kollapser ind i en håndfuld komprimerede blobs. Prisen er, at hvert pakket objekt nu har brug for en krydsreferencestrøm til at beskrive det

Den anden halvdel er der, hvor skrivere går i stykker. At bygge en /ObjStm-container er aritmetik; at lære krydsreference-maskineriet at pege ind i den er et redesign. En skriver, der producerer en helt gyldig container og så beskriver dens medlemmer med almindelige type-1-offsets, har produceret en fil, Acrobat vil åbne netop længe nok til at erklære beskadiget. De to funktioner er én funktion, og denne artikel dækker skrivesiden af begge, som defineret i ISO 32000-1 §7.5.7 og §7.5.8

Hvad indeholder en ObjStm-container egentlig

En objektstrøm er en strøm, hvis afkodede bytes er to sammenkædede regioner, og ISO 32000-1 §7.5.7 giver dictionaryet præcis tre nøgler, der betyder noget for konstruktion. /Type /ObjStm identificerer den, /N giver antallet af medlemmer, og /First giver bytelængden af header-regionen — ækvivalent, offsettet hvor kroppen begynder. Headeren er whitespace-adskilte par af objektnummer og offset; kroppen er medlemmerne serialiseret ryg mod ryg, med hvert offset målt fra kroppens start frem for fra den afkodede payloads start. At læse en fuldt afkodet container gør det åbenlyst: nedenfor er /First 14, fordi de tre header-linjer optager fjorten bytes, og objekt 7 sidder 55 bytes ind i kroppen, fordi objekt 4 serialiserede til 54 tegn plus en separator

// 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 ]

To medlemskabsregler er absolutte, og begge kommer direkte fra §7.5.7. Et strøm-objekt kan aldrig være et medlem, fordi en strøm bærer rå bytes, der ville skulle indlejres inde i en anden strøm. Og et medlem skal være en komplet objektværdi, aldrig en bar indirekte reference — et komprimeret objekt, der blot er 5 0 R, skaber en indirektion, læseren ikke kan opløse uden allerede at vide hvor den peger. losLab PDF Library filtrerer begge tilfælde fra under kandidatindsamling, sammen med krypterings-dictionaryet og objekt 0, og pakker derefter hvad der overlever i grupper af 200 per container. Det loft er en random-access-beslutning frem for en spec-grænse: en læser der vil have ét medlem, skal inflate hele containeren, så oversize-containere gør små opslag dyre

Hvorfor skal ObjStm-medlemmer bruge type-2-krydsreferenceposter?

Fordi et pakket objekt ikke har noget filoffset at registrere. ISO 32000-1 §7.5.8 besvarer dette med tre posttyper i en binær krydsreferencestrøm: type 0 for frie objekter, type 1 for almindelige i-brug-objekter gemt ved et byteoffset, og type 2 for komprimerede objekter, hvis to datafelter holder container-objektnummeret og medlemsindekset inde i den. Der er ingen måde at udtrykke et pakket objekt i den klassiske klartekst-xref-tabel, hvilket er præcis hvorfor PDF 1.5 introducerede begge funktioner sammen

Rækkefølgen, der følger, snubler næsten hver første implementering, inklusive vores. Almindelige objekter får type-1-poster. /ObjStm-containerne selv får type-1-poster, fordi en container er et helt normalt indirekte strøm-objekt skrevet ved et rigtigt offset. Kun medlemmerne får type-2-poster. Og krydsreferencestrømmen er selv et indirekte objekt i filen, så den har brug for sin egen type-1-post, der peger på offsettet, hvor den lige blev skrevet — det samme offset startxref registrerer. En tidlig version af vores skriver ekskluderede container-objektnumre fra skrive-loopet i stedet for at ekskludere medlemmer, og resultatet var en fil med en krydsreferencestrøm og ingen objektstrømme overhovedet: strukturelt sammenhængende, semantisk tom, afvist nedstrøms. /Size-værdien skjuler en matchende off-by-one, siden den er det højeste objektnummer plus én, og krydsreferencestrømmen allokeres som det højeste objektnummer, så den skal også tælles med

At dimensionere /W-arrayet: hvorfor fire bytes ikke er nok

/W-arrayet deklarerer bytebredden af hvert af de tre felter, og losLab PDF Library skriver det som /W [1 Field2 Field3] med felt 1 fast ved én byte for typekoden og felt 3 fast ved to bytes, hvilket dækker generationsnumre op til 65535 og medlemsindekser alike. Felt 2 er den, der ikke kan være en konstant, fordi det bærer to urelaterede størrelser: i en type-1-post er det et byteoffset begrænset kun af filstørrelse, mens det i en type-2-post er et container-objektnummer, og i en type-0-post er det det næste frie objekt i kæden. Et fast fire-byte felt 2 virker fint, indtil filen krydser 4 GB, på hvilket tidspunkt hvert offset forbi grænsen stille afkortes, og hele tabellen bliver skrammel. Skriveren scanner derfor den samlede tabel for den største værdi, enhver felt-2-plads nogensinde vil holde, inklusive offsettet for selve krydsreferencestrømmen, og udvider feltet op til otte bytes

// 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 bredderne først er kendt, er payload-størrelsen kendt præcist, så skriveren prealllokerer hele bufferen og udfylder den efter indeks; at tilføje poster byte for byte til en AnsiString gør tabelkonstruktion kvadratisk, hvilket ingen bemærker på en ti-siders faktura, og alle bemærker på et dokument med to hundrede tusinde objekter. To yderligere detaljer holder strenge læsere glade. /Index deklarerer hvilke objektnummer-intervaller tabellen dækker, og for en fuld omskrivning er det simpelthen [0 N] uden huller. Og hver plads, skriveren ikke faktisk udsendte, skal som standard være fri frem for i-brug: objekt 0 leder den frie kæde, hver fri plads linker til den næste, og en plads, der engang holdt et slettet objekt, beholder sit generationsnummer inkrementeret med én. Følgenoten om hukommelsessikkerhed ved parsing af ubetroede PDF'er fører det samme grænse-argument fra læsesiden

Hvorfor må krydsreferencestrømmen aldrig krypteres?

Fordi en læser skal parse den, før den kan vide, hvordan man dekrypterer noget. Krydsreferencestrømmen er hvad der fortæller læseren, hvor /Encrypt-dictionaryet bor; hvis dens bytes selv var krypterede, ville læseren have brug for filnøglen til at finde det objekt, der beskriver filnøglen. losLab PDF Library håndhæver dette i et enkelt predikat: ShouldCryptStreamData returnerer False, når som helst strøm-dictionaryet bærer /Type /XRef, så undtagelsen holder uanset hvilken sti når serialiseren

/ObjStm-containeren får den modsatte behandling, og asymmetrien er bevidst. En container krypteres i sin helhed, nøglet på sit eget objektnummer, ganske som enhver anden strøm. Dens medlemmer krypteres ikke individuelt — de pakkes i deres dekrypterede klartekst-form, og den enkelte gennemgang over den samlede container dækker dem, strenge inklusive. At dobbelt-kryptere medlemmerne producerer en fil, der dekrypterer til ciffertekst, og fordi det ydre lag lykkes, dukker fejlen op som en parse-fejl dybt i objektgrafen frem for som en autentificeringsfejl. Ét objekt forbliver derfor helt uden for ordningen: i et krypteret dokument holdes Kataloget som et direkte type-1-objekt og pakkes aldrig, fordi at pakke det ville tvinge loaderen til at inflate og dekryptere en objektstrøm for at nå dokumentroden, før den dekrypteringskontekst, roden hjælper med at etablere, er fuldt bygget

At slå pakning til fra Delphi

Den offentlige kontakt er PackObjectStreams, eksponeret som et felt på TPDFlibSaveOptions, som den fritstående setter SetPackObjectStreams, og som en egenskab på dokumentobjektet. Den er som standard aktiveret og auto-porteret efter version: skriveren pakker kun, når dokumentet allerede er PDF 1.5 eller senere, og den kalder den interne minimum-version-vagt, så et pakket dokument bumpes til 1.5 frem for at blive fejlmærket. Efter gemningen rapporterer GetLastSaveUsedObjectStreams, om porten faktisk åbnede, hvilket er det assert, man vil have i en regressionstest frem for en byte-størrelse-sammenligning

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;

Rækkefølge betyder noget mellem pakning og garbage collection. Nåbarhedsanalyse skal køre først, fordi et medlem, der overlever ind i en container, trækker containeren med sig — hvis et levende objekt er pakket, er dets container-nummer nåbart per definition, og at feje containeren væk strander medlemmet uden nogen måde at lokalisere det. At køre collectoren først betyder også, at døde objekter aldrig kommer ind i en container overhovedet, hvilket er hvor den akkumulerende størrelsesgevinst kommer fra. Pakning komplementerer de andre størrelsesgreb frem for at erstatte dem; gennemgangen af PDF-filstørrelsesoptimering og font-undersætning dækker de greb, der virker på strøm-payloads, hvor objektstrømme virker på struktur

Grænser værd at kende, før man aktiverer det

Inkrementelle gemninger pakker aldrig. En inkrementel opdatering tilføjer nye objekter og en ny krydsreferencesektion, mens tidligere revisioner efterlades fysisk intakte, så at ompakke eksisterende objekter ind i friske containere ville gøre de type-1-poster, den forrige revision stadig refererer, forældreløse; losLab PDF Library deaktiverer pakning, når som helst append-tilstand er aktiv, og artiklen om inkrementelle opdateringer og append-mode-strømning dækker den sti fuldt ud. Dokumenter under PDF 1.5 beholder den klartekst-krydsreferencetabel betingelsesløst: en 1.4-forbruger har ingen idé om, hvad /ObjStm betyder, og stiltiende at forfremme et dokument, fordi skriveren foretrak en mindre fil, ville være den forkerte afvejning at træffe på kalderens vegne. Én valgfri nøgle, vi bevidst ikke udsender, er /Extends, som ISO 32000-1 §7.5.7 definerer, så en container kan navngive en forgænger, og læsere kan behandle en kæde af containere som en logisk gruppe. Den er reelt valgfri, hver container vi skriver er selvstændig og uafhængigt afkodelig, og at springe den over fjerner en klasse af cyklus- og dinglende-reference-fejl fra skriveren — selvom læsere naturligvis stadig skal respektere /Extends, når de møder den i filer fra andre producenter

Objektstrøm-pakning og krydsreferencestrøm-output leveres som del af losLab PDF Library til Delphi og C++Builder, sammen med garbage collectoren og indholdsstrøm-optimeringen de sammensættes med; produktsiden bærer den fulde gem-options-reference