Object streamy PDF 1.5 balí mnoho malých nepřímých objektů do jednoho Flate-komprimovaného kontejneru a losLab PDF Library je při full save vytváří pomocí příznaku PackObjectStreams. Zisk je reálný: stovky slovníků stránek, fontů a anotací, z nichž každý stojí desítky nekomprimovaných bajtů, se zhroutí do hrstky komprimovaných blobů. Cenou za to je, že každý zabalený objekt nyní potřebuje k popisu cross-reference stream
Právě na této druhé polovině writery selhávají. Sestavit kontejner /ObjStm je aritmetika; naučit mechanismus cross-reference, aby do něj ukazoval, je přepracování. Writer, který vytvoří naprosto validní kontejner a pak jeho členy popíše obyčejnými offsety typu 1, vytvoří soubor, který Acrobat otevře právě tak dlouho, aby jej prohlásil za poškozený. Obě funkce jsou ve skutečnosti jedna funkce a tento článek pokrývá stranu zápisu obou, jak je definuje ISO 32000-1 §7.5.7 a §7.5.8
Co kontejner ObjStm skutečně obsahuje
Object stream je stream, jehož dekódované bajty tvoří dvě spojené oblasti, a ISO 32000-1 §7.5.7 dává slovníku přesně tři klíče, na kterých při konstrukci záleží. /Type /ObjStm jej identifikuje, /N udává počet členů a /First udává délku hlavičkové oblasti v bajtech – neboli offset, kde začíná tělo. Hlavička jsou mezerami oddělené dvojice čísla objektu a offsetu; tělo tvoří členové serializovaní za sebou, přičemž každý offset se měří od začátku těla, nikoli od začátku dekódovaného obsahu. Při čtení plně dekódovaného kontejneru je to zřejmé: níže je /First rovno 14, protože tři řádky hlavičky zabírají čtrnáct bajtů, a objekt 7 sedí 55 bajtů do těla, protože objekt 4 se serializoval na 54 znaků plus oddělovač
// 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 ]
Dvě pravidla členství jsou absolutní a obě vycházejí přímo z §7.5.7. Stream objekt nemůže být nikdy členem, protože stream nese syrové bajty, které by musely být vnořeny do jiného streamu. A člen musí být kompletní hodnota objektu, nikdy pouhá nepřímá reference – komprimovaný objekt, který je jen 5 0 R, vytváří indirekci, kterou čtenář nedokáže vyřešit, aniž by už předem věděl, kam ukazuje. losLab PDF Library oba tyto případy odfiltruje během sběru kandidátů spolu se šifrovacím slovníkem a objektem 0 a pak zabalí vše, co přežije, do skupin po 200 na kontejner. Tento limit je rozhodnutí ohledně náhodného přístupu, nikoli limit ze specifikace: čtenář, který chce jednoho člena, musí rozbalit celý kontejner, takže předimenzované kontejnery prodražují malá vyhledávání
Proč musí členové ObjStm používat záznamy cross-reference typu 2?
Protože zabalený objekt nemá žádný offset v souboru k zaznamenání. ISO 32000-1 §7.5.8 na to odpovídá třemi typy záznamů v binárním cross-reference streamu: typ 0 pro volné objekty, typ 1 pro běžné používané objekty uložené na bajtovém offsetu a typ 2 pro komprimované objekty, jejichž dvě datová pole nesou číslo kontejnerového objektu a index člena uvnitř něj. Zabalený objekt nelze vyjádřit v klasické textové tabulce xref, a přesně proto PDF 1.5 zavedlo obě funkce společně
Pořadí, které z toho vyplývá, zaskočí téměř každou první implementaci, tu naši nevyjímaje. Běžné objekty dostávají záznamy typu 1. Samotné kontejnery /ObjStm dostávají také záznamy typu 1, protože kontejner je naprosto normální nepřímý stream objekt zapsaný na reálném offsetu. Záznamy typu 2 dostávají jen členové. A cross-reference stream je sám o sobě nepřímý objekt v souboru, takže potřebuje vlastní záznam typu 1 ukazující na offset, kam byl právě zapsán – tentýž offset, který zaznamenává startxref. Raná verze našeho writeru vylučovala ze zapisovací smyčky čísla kontejnerových objektů místo vyloučení členů, a výsledkem byl soubor s cross-reference streamem a vůbec žádnými object streamy: strukturálně souvislý, sémanticky prázdný, dále odmítnutý. Hodnota /Size skrývá odpovídající chybu o jedna, protože je to nejvyšší číslo objektu plus jedna a cross-reference stream je alokován jako nejvyšší číslo objektu, takže se musí započítat také
Velikost pole /W: proč čtyři bajty nestačí
Pole /W deklaruje bajtovou šířku každého ze tří polí a losLab PDF Library je zapisuje jako /W [1 Field2 Field3], přičemž pole 1 je pevně jeden bajt pro kód typu a pole 3 je pevně dva bajty, což pokrývá čísla generací až do 65535 i indexy členů. Pole 2 je to, které nemůže být konstantní, protože nese dvě nesouvisející veličiny: v záznamu typu 1 je to bajtový offset omezený jen velikostí souboru, zatímco v záznamu typu 2 je to číslo kontejnerového objektu a v záznamu typu 0 je to další volný objekt v řetězu. Pevné čtyřbajtové pole 2 funguje bez problémů, dokud soubor nepřekročí 4 GB, kdy se každý offset za touto hranicí potichu ořízne a celá tabulka se stane nesmyslnou. Writer proto projde sestavenou tabulku, najde největší hodnotu, kterou bude kdy jakýkoli slot pole 2 obsahovat, včetně offsetu samotného cross-reference streamu, a rozšíří pole až na osm bajtů
// 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
Jakmile jsou šířky známy, je přesně známa i velikost obsahu, takže writer předalokuje celý buffer a vyplní jej podle indexu; přidávání záznamů bajt po bajtu do AnsiString mění konstrukci tabulky na kvadratickou, čehož si nikdo nevšimne u desetistránkové faktury, ale všimne si toho každý u dokumentu s dvěma sty tisíci objekty. Další dva detaily udržují přísné čtenáře spokojené. /Index deklaruje, které rozsahy čísel objektů tabulka pokrývá, a pro úplné přepsání je to prostě [0 N] bez mezer. A každý slot, který writer skutečně nezapsal, musí být ve výchozím stavu volný, nikoli používaný: objekt 0 stojí v čele řetězu volných, každý volný slot odkazuje na další a slot, který kdysi držel smazaný objekt, má číslo generace zvýšené o jedna. Doprovodná poznámka o bezpečnosti paměti při parsování nedůvěryhodných PDF uvádí stejný argument o mezích ze strany čtení
Proč nesmí být cross-reference stream nikdy šifrovaný?
Protože jej čtenář musí zpracovat dřív, než může vědět, jak cokoli dešifrovat. Cross-reference stream je to, co čtenáři říká, kde žije slovník /Encrypt; kdyby byly jeho bajty samy šifrované, čtenář by potřeboval klíč souboru, aby našel objekt, který klíč souboru popisuje. losLab PDF Library to vynucuje jediným predikátem: ShouldCryptStreamData vrátí False vždy, když slovník streamu nese /Type /XRef, takže výjimka platí bez ohledu na to, kterou cestou se dostaneme do serializéru
Kontejneru /ObjStm se dostává opačného zacházení a tato asymetrie je záměrná. Kontejner je šifrován jako celek, klíčovaný podle vlastního čísla objektu, přesně jako kterýkoli jiný stream. Jeho členové nejsou šifrováni jednotlivě – jsou zabaleni ve své dešifrované, čisté podobě, a jediný průchod přes sestavený kontejner je pokryje, včetně řetězců. Dvojité šifrování členů vytvoří soubor, který se dešifruje na šifrovaný text, a protože vnější vrstva uspěje, chyba se projeví jako chyba parsování hluboko v grafu objektů, nikoli jako chyba autentizace. Jeden objekt pak zůstává mimo toto schéma úplně: v šifrovaném dokumentu je Catalog udržován jako přímý objekt typu 1 a nikdy se nebalí, protože jeho zabalení by donutilo loader rozbalit a dešifrovat object stream, aby se dostal ke kořeni dokumentu, ještě předtím, než je plně sestaven dešifrovací kontext, který právě kořen pomáhá vytvořit
Zapnutí balení z Delphi
Veřejný přepínač je PackObjectStreams, vystavený jako pole na TPDFlibSaveOptions, jako samostatný setter SetPackObjectStreams a jako vlastnost na objektu dokumentu. Ve výchozím stavu je zapnutý a automaticky se řídí verzí: writer baluje jen tehdy, když je dokument už PDF 1.5 nebo novější, a volá interní kontrolu minimální verze, takže zabalený dokument se povýší na 1.5 místo toho, aby byl chybně označen. Po uložení GetLastSaveUsedObjectStreams hlásí, zda se brána skutečně otevřela, což je assert, který chcete mít v regresním testu, spíše než porovnání velikosti v bajtech
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;
Na pořadí mezi balením a garbage collection záleží. Analýza dosažitelnosti musí proběhnout jako první, protože člen, který přežije do kontejneru, si s sebou táhne i kontejner – pokud je zabalen živý objekt, je jeho číslo kontejneru dosažitelné z definice, a smetením kontejneru by se člen ocitl bez možnosti jej nalézt. Spuštění kolektoru jako prvního také znamená, že mrtvé objekty se do kontejneru vůbec nedostanou, a právě odtud pochází kumulovaný zisk ve velikosti. Balení doplňuje ostatní páky pro zmenšení velikosti, nenahrazuje je; přehled v článku optimalizace velikosti souboru PDF a subsetting fontů pokrývá páky, které působí na obsah streamů, zatímco object streamy působí na strukturu
Hranice, které je dobré znát před zapnutím
Přírůstková uložení nikdy nebalí. Přírůstková aktualizace přidává nové objekty a novou sekci cross-reference, přičemž ponechává dřívější revize fyzicky nedotčené, takže přebalení existujících objektů do nových kontejnerů by osiřelo záznamy typu 1, na které stále odkazuje předchozí revize; losLab PDF Library balení vypíná vždy, když je aktivní append mód, a článek o přírůstkových aktualizacích a streamování v append módu tuto cestu pokrývá v plném rozsahu. Dokumenty pod PDF 1.5 nepodmíněně ponechávají textovou tabulku cross-reference: čtenář verze 1.4 netuší, co /ObjStm znamená, a tiché povýšení dokumentu jen proto, že writer preferoval menší soubor, by byl špatný kompromis učiněný za volajícího. Jeden volitelný klíč, který záměrně nevytváříme, je /Extends, který ISO 32000-1 §7.5.7 definuje tak, aby kontejner mohl pojmenovat svého předchůdce a čtenáři mohli řetěz kontejnerů považovat za logickou skupinu. Je to skutečně volitelné, každý kontejner, který zapisujeme, je samostatný a nezávisle dekódovatelný, a jeho vynechání odstraňuje z writeru celou třídu chyb s cykly a visícími referencemi – čtenáři však samozřejmě musí /Extends respektovat, když na něj narazí v souborech od jiných producentů
Balení object streamů a výstup cross-reference streamu jsou součástí losLab PDF Library pro Delphi a C++Builder spolu s garbage collectorem a optimalizátorem content streamů, se kterými se skládají; produktová stránka obsahuje kompletní referenci save options