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

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 символа плюс разделител

// Декодирано съдържание на: 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, а не ограничение на спецификацията: читател, който иска само един член, трябва да инфлейтне целия контейнер, така че прекалено големите контейнери правят малките справки скъпи

Анатомия на декодиран товар на PDF 1.5 ObjStm за Delphi записвачи, показваща ключовете Type, N и First, заглавка от двойки номер-на-обект и офсет, тела на членове, измервани от началото на тялото, и кандидат-обекти, изключени от опаковането
Анатомия на декодиран ObjStm товар: /N брои членовете, /First оразмерява заглавната част на двойките обект-отместване, а всяко отместване в тялото се мери от началото на тялото

Защо членовете на 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, и разширява полето до осем байта при нужда

Съпоставяне на PDF обекти с типове записи в потока с кръстосани препратки в Delphi: свободните слотове ползват тип 0, обикновени обекти и ObjStm контейнери и самият xref поток ползват байтови офсети тип 1, опакованите членове ползват тип 2 с номер на контейнер и индекс, плюс ширини на полета в W масива от едно, променливи четири-до-осем и два байта
Всеки обект попада в точно един от трите двоични типа записи, а масивът /W разширява поле 2, докато най-голямото отместване, номерът на контейнера и целта на свободната верига всички се поберат
// Field 2 трябва да съдържа най-голямото отместване в байтове И най-големия
// номер на контейнера ObjStm И най-голямата цел във веригата от свободни обекти
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;   // номерата на генерация и индексите на членовете се побират

Щом ширините са известни, размерът на 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, за да достигне корена на документа, преди контекстът на декриптиране, който коренът помага да се изгради, да е напълно готов

Правила за асиметрично криптиране за потока с кръстосани препратки, ObjStm контейнера и Catalog в Delphi PDF изход: ShouldCryptStreamData връща False за /Type /XRef, контейнерът се криптира изцяло в един ключован проход върху членовете в чист текст, а Catalog остава директен тип-1 неопакован обект
Шифроването третира трите обекта асиметрично: xref потокът е изцяло освободен, контейнерът се шифрова цял веднъж, а шифрован документ държи своя Catalog извън пакетирането

Включване на пакетирането от 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');        // пакетирането е разрешено само при PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // премахнете осиротелите обекти преди пакетирането
    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