Техническа статия

Object Streams и Cross-Reference Streams в PDF с Delphi

Object streams по PDF 1.5 пакетират много малки indirect обекти в един Flate-компресиран контейнер, а losLab PDF Library ги излъчва при full save чрез своя флаг PackObjectStreams. Печалбата е реална: стотици речници за страници, шрифтове и анотации, всеки от които струва по десетки некомпресирани байтове, се свиват в шепа компресирани блобове. Цената е, че всеки пакетиран обект вече се нуждае от cross-reference stream, за да бъде описан

Точно тук се чупят писателите. Изграждането на контейнер /ObjStm е аритметика; научаването на cross-reference машинерията да сочи в него е преработка. Писател, който произвежда напълно валиден контейнер и после описва членовете му с обикновени offset-и от тип 1, е произвел файл, който Acrobat ще отвори точно колкото да обяви за повреден. Двете функции са една функция, и тази статия покрива страната на записа и на двете, както са дефинирани в ISO 32000-1 §7.5.7 и §7.5.8

Какво всъщност съдържа контейнерът ObjStm

Object stream е stream, чиито декодирани байтове са два свързани региона, а ISO 32000-1 §7.5.7 дава на речника точно три ключа, важни за конструкцията. /Type /ObjStm го идентифицира, /N дава броя на членовете, а /First дава дължината в байтове на header региона — еквивалентно, offset-а, при който започва тялото. Header-ът е двойки обект-номер/offset, разделени с whitespace; тялото е членовете, сериализирани един след друг, като всеки offset се измерва от началото на тялото, а не от началото на декодирания payload. Прочитането на напълно декодиран контейнер го прави очевидно: по-долу /First е 14, защото трите header реда заемат четиринадесет байта, а обект 7 седи 55 байта навътре в тялото, защото обект 4 се е сериализирал в 54 символа плюс разделител

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

Две правила за членство са абсолютни и двете идват директно от §7.5.7. Stream обект никога не може да бъде член, защото stream носи сурови байтове, които трябва да бъдат вложени в друг stream. И член трябва да бъде пълна обектна стойност, никога гола indirect референция — компресиран обект, който е просто 5 0 R, създава непряка връзка, която читателят не може да разреши, без вече да знае къде сочи. losLab PDF Library филтрира и двата случая по време на събирането на кандидати, заедно с речника за криптиране и обект 0, после пакетира каквото оцелее на групи по 200 в контейнер. Този таван е решение за random-access, а не ограничение на спецификацията: читател, който иска само един член, трябва да инфлейтне целия контейнер, така че прекалено големите контейнери правят малките справки скъпи

Защо членовете на ObjStm трябва да използват cross-reference записи тип 2?

Защото пакетиран обект няма файлов offset за записване. ISO 32000-1 §7.5.8 отговаря на това с три типа записи в двоичен cross-reference stream: тип 0 за свободни обекти, тип 1 за обикновени използвани обекти, съхранени на байтов offset, и тип 2 за компресирани обекти, чиито две данни-полета носят номера на обекта-контейнер и индекса на члена вътре в него. Няма начин да се изрази пакетиран обект в класическата текстова таблица xref, което е точно защо PDF 1.5 въведе двете функции заедно

Редът, който следва, спъва почти всяка първа реализация, включително нашата. Обикновените обекти получават записи тип 1. Самите контейнери /ObjStm получават записи тип 1, защото контейнерът е напълно нормален indirect stream обект, записан на реален offset. Само членовете получават записи тип 2. А cross-reference stream-ът сам по себе си е indirect обект във файла, така че се нуждае от собствен запис тип 1, сочещ към offset-а, на който току-що е записан — същия offset, който startxref записва. Ранна версия на нашия писател изключваше номерата на обектите-контейнери от write цикъла вместо да изключва членовете, а резултатът беше файл с cross-reference stream и без нито един object stream: структурно съгласуван, семантично празен, отхвърлен надолу по веригата. Стойността на /Size крие съвпадащ off-by-one, защото е най-високият номер на обект плюс едно, а cross-reference stream-ът е разпределен като най-високия номер на обект, така че и той трябва да се брои

Определяне размера на масива /W: защо четири байта не стигат

Масивът /W декларира байтовата ширина на всяко от трите полета, а losLab PDF Library го записва като /W [1 Field2 Field3], с поле 1 фиксирано на един байт за кода на типа и поле 3 фиксирано на два байта, което покрива номера на поколения до 65535 и индекси на членове еднакво. Поле 2 е това, което не може да е константа, защото носи две несвързани величини: в запис тип 1 то е байтов offset, ограничен само от размера на файла, докато в запис тип 2 е номер на обект-контейнер, а в запис тип 0 е следващият свободен обект във веригата. Фиксирано четирибайтово поле 2 работи добре, докато файлът не премине 4 ГБ, момент, в който всеки offset след границата тихо се отрязва и цялата таблица става боклук. Затова писателят сканира сглобената таблица за най-голямата стойност, която някое гнездо на поле 2 някога ще носи, включително offset-а на самия cross-reference stream, и разширява полето до осем байта при нужда

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

Щом ширините са известни, размерът на payload-а е известен точно, така че писателят предварително разпределя целия буфер и го запълва по индекс; добавянето на записи байт по байт към AnsiString прави конструкцията на таблицата квадратична, което никой не забелязва при десетстранична фактура и всеки забелязва при документ с двеста хиляди обекта. Още два детайла държат стриктните читатели доволни. /Index декларира кои диапазони от номера на обекти покрива таблицата, а за пълно пренаписване това е просто [0 N] без пропуски. И всяко гнездо, което писателят реално не е излъчил, трябва по подразбиране да е свободно, а не използвано: обект 0 оглавява свободната верига, всяко свободно гнездо се верижва към следващото, а гнездо, което някога е държало изтрит обект, пази номера на поколението, увеличен с едно. Съпътстващата бележка за безопасност на паметта при парсване на недоверени PDF-и прави същия аргумент за граници откъм страната на четенето

Защо cross-reference stream-ът никога не бива да е криптиран?

Защото читателят трябва да го парсне, преди да може да знае как да декриптира каквото и да е. Cross-reference stream-ът е това, което казва на читателя къде живее речникът /Encrypt; ако собствените му байтове бяха криптирани, читателят би имал нужда от файловия ключ, за да намери обекта, който описва файловия ключ. losLab PDF Library налага това в един-единствен предикат: ShouldCryptStreamData връща False винаги когато stream речникът носи /Type /XRef, така че изключението важи независимо кой път достига до серилизатора

Контейнерът /ObjStm получава обратното третиране, и асиметрията е умишлена. Контейнерът се криптира изцяло, ключуван по собствения си номер на обект, точно като всеки друг stream. Неговите членове не се криптират индивидуално — те са пакетирани в декриптирана обикновен текст форма, а единственото преминаване върху сглобения контейнер ги покрива, включително низовете. Двойното криптиране на членовете произвежда файл, който се декриптира до шифротекст, и понеже външният слой успява, провалът излиза на повърхността като грешка при парсване дълбоко в графа от обекти, а не като грешка при удостоверяване. Един обект тогава остава напълно извън схемата: в криптиран документ каталогът се пази като директен обект тип 1 и никога не се пакетира, защото пакетирането му би принудило зареждащото да инфлейтне и декриптира object stream, за да достигне корена на документа, преди контекстът на декриптиране, който коренът помага да се изгради, да е напълно готов

Включване на пакетирането от Delphi

Публичният превключвател е PackObjectStreams, изложен като поле на TPDFlibSaveOptions, като самостоятелния setter SetPackObjectStreams и като свойство на обекта на документа. По подразбиране е включен и е автоматично ограден по версия: писателят пакетира само когато документът вече е PDF 1.5 или по-нов, и извиква вътрешния guard за минимална версия, така че пакетиран документ да бъде повишен до 1.5, вместо да бъде етикетиран грешно. След save-а GetLastSaveUsedObjectStreams отчита дали оградата действително се е отворила, което е assert-ът, който искате в regression тест, вместо сравнение на размера в байтове

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;

Редът има значение между пакетирането и garbage collection. Анализът на достижимост трябва да върви пръв, защото член, който оцелее в контейнер, повлича контейнера със себе си — ако жив обект е пакетиран, номерът на неговия контейнер е достижим по дефиниция, а помитането на контейнера оставя члена без начин да бъде намерен. Пускането на колектора първо също означава, че мъртвите обекти никога не влизат в контейнер, откъдето идва натрупващата се печалба в размера. Пакетирането допълва другите лостове за размер, вместо да ги замества; обиколката на оптимизация на размера на PDF и subsetting на шрифтове покрива лостовете, действащи върху stream payload-ите, докато object streams действат върху структурата

Граници, които си струва да знаете преди да го включите

Incremental save-овете никога не пакетират. Incremental update добавя нови обекти и нова cross-reference секция, оставяйки по-ранните ревизии физически непокътнати, така че препакетирането на съществуващи обекти в свежи контейнери би направило изоставени записите тип 1, към които предишната ревизия все още реферира; losLab PDF Library изключва пакетирането винаги когато append режимът е активен, а статията за incremental update и append-mode streaming покрива този път изцяло. Документите под PDF 1.5 пазят текстовата cross-reference таблица безусловно: консуматор от версия 1.4 няма представа какво означава /ObjStm, а тихото повишаване на документ, защото писателят е предпочел по-малък файл, би било грешният компромис, взет от името на извикващия. Един опционален ключ, който умишлено не излъчваме, е /Extends, който ISO 32000-1 §7.5.7 дефинира, за да може контейнер да назове предшественик и читателите да третират верига от контейнери като логическа група. Той е истински опционален, всеки контейнер, който записваме, е самодостатъчен и независимо декодируем, а пропускането му премахва цял клас грешки с цикли и висящи референции от писателя — макар че читателите, разбира се, все пак трябва да спазват /Extends, когато го срещнат във файлове от други производители

Пакетирането на object streams и извеждането на cross-reference stream се доставят като част от losLab PDF Library за Delphi и C++Builder, заедно с garbage collector-а и оптимизатора на content stream, с които се съчетават; продуктовата страница носи пълния справочник за опциите за save