PDF 1.5 object streams verpakken veel kleine indirecte objecten in één met Flate gecomprimeerde container, en losLab PDF Library schrijft ze bij een volledige save via de PackObjectStreams-vlag. De winst is reëel: honderden pagina-, font- en annotatie-dictionaries die elk tientallen ongecomprimeerde bytes kosten, vallen samen tot een handvol gecomprimeerde blobs. De prijs is dat elk verpakt object nu een cross-reference stream nodig heeft om het te beschrijven
Die tweede helft is waar writers breken. Het bouwen van een /ObjStm-container is rekenwerk; de cross-reference-machinerie leren ernaar te wijzen is een herontwerp. Een writer die een perfect geldige container produceert en de leden ervan vervolgens beschrijft met gewone type-1-offsets, heeft een bestand geproduceerd dat Acrobat lang genoeg opent om te verklaren dat het beschadigd is. De twee functies zijn één functie, en dit artikel behandelt de schrijfkant van beide, zoals gedefinieerd in ISO 32000-1 §7.5.7 en §7.5.8
Wat een ObjStm-container daadwerkelijk bevat
Een object stream is een stream waarvan de gedecodeerde bytes twee aaneengesloten regio's vormen, en ISO 32000-1 §7.5.7 geeft het dictionary precies drie sleutels die van belang zijn voor de constructie. /Type /ObjStm identificeert het, /N geeft het aantal leden, en /First geeft de bytelengte van de header-regio — equivalent, de offset waarop het body-gedeelte begint. De header bestaat uit door witruimte gescheiden paren van objectnummer en offset; het body-gedeelte bevat de leden achter elkaar geserialiseerd, waarbij elke offset gemeten wordt vanaf het begin van het body-gedeelte in plaats van vanaf het begin van de gedecodeerde payload. Het lezen van een volledig gedecodeerde container maakt het duidelijk: hieronder is /First 14 omdat de drie headerregels veertien bytes innemen, en object 7 staat 55 bytes in het body-gedeelte omdat object 4 werd geserialiseerd tot 54 tekens plus een scheidingsteken
// 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 ]
Twee lidmaatschapsregels zijn absoluut en komen beide rechtstreeks uit §7.5.7. Een stream-object kan nooit een lid zijn, omdat een stream ruwe bytes bevat die genest zouden moeten worden binnen een andere stream. En een lid moet een complete objectwaarde zijn, nooit een kale indirecte referentie — een gecomprimeerd object dat slechts 5 0 R is, creëert een indirectie die de lezer niet kan oplossen zonder al te weten waar het naar wijst. losLab PDF Library filtert beide gevallen eruit tijdens de kandidaatverzameling, samen met het encryptie-dictionary en object 0, en verpakt vervolgens wat overblijft in groepen van 200 per container. Die grens is een beslissing over willekeurige toegang, geen speclimiet: een lezer die één lid wil, moet de hele container inflaten, dus maken overgrote containers kleine lookups duur
Waarom moeten ObjStm-leden type-2 cross-reference-entries gebruiken?
Omdat een verpakt object geen bestandsoffset heeft om te registreren. ISO 32000-1 §7.5.8 beantwoordt dit met drie entry-types in een binaire cross-reference stream: type 0 voor vrije objecten, type 1 voor gewone in-gebruik-objecten opgeslagen op een byte-offset, en type 2 voor gecomprimeerde objecten, waarvan de twee datavelden het containerobjectnummer en de lidindex daarbinnen bevatten. Er is geen manier om een verpakt object uit te drukken in de klassieke platte-tekst-xref-tabel, wat precies is waarom PDF 1.5 beide functies samen introduceerde
De volgorde die daaruit volgt, doet bijna elke eerste implementatie struikelen, ook de onze. Gewone objecten krijgen type-1-entries. De /ObjStm-containers zelf krijgen type-1-entries, omdat een container een volkomen normaal indirect stream-object is dat op een echte offset geschreven wordt. Alleen de leden krijgen type-2-entries. En de cross-reference stream is zelf een indirect object in het bestand, dus heeft hij zijn eigen type-1-entry nodig die wijst naar de offset waarop hij zojuist geschreven is — dezelfde offset die startxref registreert. Een vroege versie van onze writer sloot containerobjectnummers uit van de schrijflus in plaats van leden uit te sluiten, en het resultaat was een bestand met een cross-reference stream en helemaal geen object streams: structureel coherent, semantisch leeg, verderop afgewezen. De /Size-waarde verbergt een bijpassende off-by-one, aangezien het het hoogste objectnummer plus één is en de cross-reference stream wordt toegewezen als het hoogste objectnummer, dus die moet ook meegeteld worden
De /W-array dimensioneren: waarom vier bytes niet genoeg is
De /W-array declareert de bytebreedte van elk van de drie velden, en losLab PDF Library schrijft het als /W [1 Field2 Field3] met veld 1 vast op één byte voor de typecode en veld 3 vast op twee bytes, wat generatienummers tot 65535 en ledenindices allebei dekt. Veld 2 is degene die geen constante kan zijn, omdat het twee ongerelateerde grootheden draagt: in een type-1-entry is het een byte-offset alleen begrensd door bestandsgrootte, terwijl het in een type-2-entry een containerobjectnummer is en in een type-0-entry het volgende vrije object in de keten. Een vast veld 2 van vier bytes werkt prima totdat het bestand de 4 GB grens overschrijdt, waarna elke offset voorbij die grens stilzwijgend afgekapt wordt en de hele tabel rommel wordt. De writer scant daarom de opgebouwde tabel voor de grootste waarde die enig veld-2-slot ooit zal bevatten, inclusief de offset van de cross-reference stream zelf, en verbreedt het veld tot maximaal acht 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
Zodra de breedtes bekend zijn, is de payloadgrootte exact bekend, dus reserveert de writer de hele buffer vooraf en vult die per index; entries byte voor byte toevoegen aan een AnsiString maakt de tabelconstructie kwadratisch, wat niemand merkt bij een factuur van tien pagina's en iedereen merkt bij een document met tweehonderdduizend objecten. Twee verdere details houden strikte lezers tevreden. /Index declareert welke objectnummerbereiken de tabel bestrijkt, en voor een volledige herschrijving is dat simpelweg [0 N] zonder gaten. En elk slot dat de writer niet daadwerkelijk uitgestuurd heeft, moet standaard vrij zijn in plaats van in gebruik: object 0 leidt de vrije keten, elk vrij slot koppelt naar het volgende, en een slot dat ooit een verwijderd object bevatte houdt zijn generatienummer met één verhoogd. De begeleidende notitie over geheugenveiligheid bij het parsen van niet-vertrouwde PDF's voert hetzelfde grenzenargument vanuit de leeskant
Waarom mag de cross-reference stream nooit versleuteld zijn?
Omdat een lezer hem moet parsen voordat hij kan weten hoe iets te ontsleutelen. De cross-reference stream vertelt de lezer waar het /Encrypt-dictionary zich bevindt; als de bytes ervan zelf versleuteld zouden zijn, zou de lezer de bestandssleutel nodig hebben om het object te vinden dat de bestandssleutel beschrijft. losLab PDF Library dwingt dit af in één enkel predicaat: ShouldCryptStreamData retourneert False wanneer het stream-dictionary /Type /XRef draagt, zodat de uitzondering standhoudt ongeacht welk pad de serializer bereikt
De /ObjStm-container krijgt de omgekeerde behandeling, en de asymmetrie is opzettelijk. Een container wordt in zijn geheel versleuteld, gesleuteld op zijn eigen objectnummer, precies zoals elke andere stream. Zijn leden worden niet individueel versleuteld — ze worden verpakt in hun ontsleutelde platte-tekstvorm, en de enkele doorgang over de opgebouwde container dekt ze, strings inbegrepen. Dubbel versleutelen van de leden produceert een bestand dat ontsleutelt tot ciphertext, en omdat de buitenste laag slaagt, verschijnt de fout als een parsefout diep in de objectgraaf in plaats van als een authenticatiefout. Eén object blijft dan volledig buiten het schema: in een versleuteld document blijft de Catalog een direct type-1-object en wordt nooit verpakt, omdat het verpakken ervan de loader zou dwingen een object stream te inflaten en te ontsleutelen om bij de documentroot te komen, voordat de ontsleutelingscontext die de root helpt vast te stellen volledig is opgebouwd
Verpakking inschakelen vanuit Delphi
De publieke schakelaar is PackObjectStreams, beschikbaar als veld op TPDFlibSaveOptions, als de losstaande setter SetPackObjectStreams, en als eigenschap op het documentobject. Standaard staat het aan en wordt het automatisch begrensd door versie: de writer verpakt alleen wanneer het document al PDF 1.5 of hoger is, en roept de interne minimumversie-guard aan zodat een verpakt document opgehoogd wordt naar 1.5 in plaats van verkeerd gelabeld te worden. Na de save rapporteert GetLastSaveUsedObjectStreams of de poort daadwerkelijk openging, wat de assertie is die je wilt in een regressietest in plaats van een bytegrootte-vergelijking
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;
Volgorde is van belang tussen verpakking en garbage collection. Bereikbaarheidsanalyse moet eerst draaien, want een lid dat de container in overleeft, sleept de container met zich mee — als een levend object verpakt wordt, is het containernummer per definitie bereikbaar, en het wegvegen van de container zou het lid stranden zonder manier om het te lokaliseren. Eerst de collector laten draaien betekent ook dat dode objecten nooit een container binnenkomen, wat de bron is van de cumulatieve grootte-winst. Verpakking vult de andere groottehefbomen aan in plaats van ze te vervangen; de doorloop van PDF-bestandsgrootte-optimalisatie en font subsetting behandelt de hefbomen die inwerken op stream-payloads, waar object streams inwerken op structuur
Grenzen die het waard zijn te kennen voordat je het inschakelt
Incrementele saves verpakken nooit. Een incrementele update voegt nieuwe objecten en een nieuwe cross-reference-sectie toe terwijl eerdere revisies fysiek intact blijven, dus zou het herverpakken van bestaande objecten in nieuwe containers de type-1-entries die de vorige revisie nog steeds refereert, wezen maken; losLab PDF Library schakelt verpakking uit wanneer append-modus actief is, en het artikel over incrementele updates en append-mode streaming behandelt dat pad volledig. Documenten onder PDF 1.5 behouden onvoorwaardelijk de platte-tekst cross-reference-tabel: een 1.4-consument heeft geen idee wat /ObjStm betekent, en het document stilzwijgend promoveren omdat de writer een kleiner bestand verkoos, zou de verkeerde afweging zijn om namens de aanroeper te maken. Eén optionele sleutel die we bewust niet uitsturen is /Extends, die ISO 32000-1 §7.5.7 definieert zodat een container een voorganger kan noemen en lezers een keten van containers als een logische groep kunnen behandelen. Het is werkelijk optioneel, elke container die wij schrijven is zelfstandig en onafhankelijk decodeerbaar, en het overslaan ervan verwijdert een klasse van cyclus- en bungelende-referentiebugs uit de writer — hoewel lezers /Extends natuurlijk nog steeds moeten respecteren wanneer ze het tegenkomen in bestanden van andere producenten
Object-stream-verpakking en cross-reference-stream-output worden geleverd als onderdeel van losLab PDF Library voor Delphi en C++Builder, naast de garbage collector en content-stream-optimizer waarmee ze samenwerken; de productpagina bevat de volledige save-options-referentie