Технічна стаття

Потоки об'єктів і xref-потоки PDF у Delphi

Потоки об'єктів PDF 1.5 пакують багато дрібних непрямих об'єктів в один Flate-стиснений контейнер, і losLab PDF Library записує їх при повному збереженні через прапорець PackObjectStreams. Виграш реальний: сотні словників сторінок, шрифтів та анотацій, кожен з яких коштує десятки нестиснених байтів, згортаються у жменьку стиснених блобів. Ціна в тому, що кожен запакований об'єкт тепер потребує потоку перехресних посилань, щоб його описати

Саме на цій другій половині ламаються писачі. Побудувати контейнер /ObjStm — це арифметика; навчити механізм перехресних посилань вказувати всередину нього — це редизайн. Писач, що продукує цілком коректний контейнер, а потім описує його члени звичайними зсувами типу 1, створив файл, який Acrobat відкриє рівно настільки, щоб оголосити його пошкодженим. Ці дві можливості — насправді одна можливість, і ця стаття охоплює бік запису обох, як визначено в ISO 32000-1 §7.5.7 та §7.5.8

Що насправді містить контейнер ObjStm

Потік об'єктів — це потік, чиї декодовані байти складаються з двох конкатенованих ділянок, і ISO 32000-1 §7.5.7 дає словнику рівно три ключі, важливі для побудови. /Type /ObjStm ідентифікує його, /N дає кількість членів, а /First дає довжину в байтах ділянки заголовка — що еквівалентно зсуву, з якого починається тіло. Заголовок — це розділені пробілами пари номер-об'єкта та зсув; тіло — це серіалізовані один за одним члени, причому кожен зсув вимірюється від початку тіла, а не від початку декодованого вмісту. Читання повністю декодованого контейнера робить це очевидним: нижче /First дорівнює 14, бо три рядки заголовка займають чотирнадцять байтів, а об'єкт 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. Об'єкт-потік ніколи не може бути членом, бо потік несе сирі байти, які довелося б вкладати всередину іншого потоку. А член має бути повним значенням об'єкта, а не голим непрямим посиланням — стиснений об'єкт, що є просто 5 0 R, створює непрямість, яку читач не може розв'язати, не знаючи заздалегідь, куди вона вказує. losLab PDF Library відфільтровує обидва випадки під час збору кандидатів разом зі словником шифрування та об'єктом 0, а потім пакує все, що залишилося, групами по 200 на контейнер. Цей ліміт — рішення про довільний доступ, а не обмеження специфікації: читач, якому потрібен один член, мусить розпакувати весь контейнер, тож завеликі контейнери роблять дрібні пошуки дорогими

Чому члени ObjStm мають використовувати записи xref типу 2?

Тому що запакований об'єкт не має зсуву у файлі, який можна записати. ISO 32000-1 §7.5.8 відповідає на це трьома типами записів у бінарному потоці перехресних посилань: тип 0 для вільних об'єктів, тип 1 для звичайних об'єктів у використанні, збережених за байтовим зсувом, і тип 2 для стиснених об'єктів, чиї два поля даних тримають номер об'єкта-контейнера та індекс члена всередині нього. Немає способу виразити запакований об'єкт у класичній текстовій таблиці xref, і саме тому PDF 1.5 запровадив обидві можливості разом

Порядок, що з цього випливає, збиває з пантелику майже кожну першу реалізацію, включно з нашою. Звичайні об'єкти отримують записи типу 1. Самі контейнери /ObjStm отримують записи типу 1, бо контейнер — це цілком звичайний непрямий об'єкт-потік, записаний за реальним зсувом. Лише члени отримують записи типу 2. А сам потік перехресних посилань — це непрямий об'єкт у файлі, тож йому потрібен власний запис типу 1, що вказує на зсув, за яким його щойно записали — той самий зсув, що фіксує startxref. Рання версія нашого писача виключала номери об'єктів-контейнерів з циклу запису замість виключення членів, і результатом був файл з потоком перехресних посилань і взагалі без потоків об'єктів: структурно когерентний, семантично порожній, відхилений на подальших етапах. Значення /Size приховує відповідну помилку на одиницю, бо це найбільший номер об'єкта плюс один, а потік перехресних посилань виділяється як найбільший номер об'єкта, тож і його треба порахувати

Розмір масиву /W: чому чотирьох байтів не досить

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

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

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

Чому потік перехресних посилань ніколи не можна шифрувати?

Тому що читач мусить розпарсити його ще до того, як зможе знати, як щось розшифрувати. Потік перехресних посилань — це те, що повідомляє читачеві, де живе словник /Encrypt; якби його байти самі були зашифровані, читачеві знадобився б ключ файлу, щоб знайти об'єкт, який описує цей ключ файлу. losLab PDF Library примусово забезпечує це одним предикатом: ShouldCryptStreamData повертає False, коли словник потоку несе /Type /XRef, тож виняток діє незалежно від того, який шлях досягає серіалізатора

Контейнер /ObjStm отримує протилежну обробку, і ця асиметрія навмисна. Контейнер шифрується цілком, за ключем на основі власного номера об'єкта, точно як будь-який інший потік. Його члени не шифруються окремо — вони пакуються у своїй розшифрованій, чистій формі, і єдиний прохід по зібраному контейнеру охоплює їх, включно з рядками. Подвійне шифрування членів виробляє файл, що розшифровується в шифротекст, і оскільки зовнішній шар успішний, помилка проявляється як помилка парсингу глибоко в графі об'єктів, а не як помилка автентифікації. Один об'єкт при цьому лишається повністю поза схемою: у зашифрованому документі каталог зберігається як прямий об'єкт типу 1 і ніколи не пакується, бо пакування змусило б завантажувач розпаковувати і розшифровувати потік об'єктів, щоб дістатися кореня документа, ще до того, як контекст розшифрування, який допомагає встановити цей корінь, повністю побудований

Як увімкнути пакування з Delphi

Публічний перемикач — PackObjectStreams, представлений як поле в TPDFlibSaveOptions, як окремий сеттер SetPackObjectStreams та як властивість об'єкта документа. За замовчуванням увімкнений і автоматично блокується за версією: писач пакує лише тоді, коли документ уже PDF 1.5 або новіший, і викликає внутрішню перевірку мінімальної версії, щоб запакований документ підвищувався до 1.5, а не позначався неправильно. Після збереження GetLastSaveUsedObjectStreams повідомляє, чи блокування дійсно відкрилося, і саме це варто перевіряти в регресійному тесті, а не порівняння розміру в байтах

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;

Порядок має значення між пакуванням і збиранням сміття. Аналіз досяжності мусить виконуватися першим, бо член, що виживає в контейнер, тягне за собою і контейнер — якщо живий об'єкт запакований, номер його контейнера досяжний за визначенням, і замітання контейнера залишить член без способу його знайти. Запуск колектора першим також означає, що мертві об'єкти взагалі ніколи не потрапляють у контейнер, і саме звідти йде накопичувальний виграш у розмірі. Пакування доповнює інші важелі розміру, а не замінює їх; огляд оптимізації розміру PDF-файлу та субсетингу шрифтів охоплює важелі, що діють на вміст потоків, тоді як потоки об'єктів діють на структуру

Межі, які варто знати перед увімкненням

Інкрементальні збереження ніколи не пакують. Інкрементальне оновлення дописує нові об'єкти та новий розділ перехресних посилань, лишаючи попередні ревізії фізично незайманими, тож перепакування наявних об'єктів у свіжі контейнери осиротило б записи типу 1, на які все ще посилається попередня ревізія; losLab PDF Library вимикає пакування щоразу, коли активний режим дописування, і стаття про інкрементальні оновлення та потокове збереження в режимі дописування повністю охоплює цей шлях. Документи нижче PDF 1.5 безумовно зберігають текстову таблицю перехресних посилань: споживач версії 1.4 гадки не має, що означає /ObjStm, а тихе підвищення версії документа лише тому, що писач волів менший файл, було б неправильним рішенням, прийнятим за викликача. Один опційний ключ, який ми свідомо не видаємо, — це /Extends, який ISO 32000-1 §7.5.7 визначає так, щоб контейнер міг називати попередника, а читачі могли трактувати ланцюжок контейнерів як логічну групу. Він справді опційний, кожен контейнер, що ми пишемо, самодостатній і незалежно декодовний, і пропуск цього ключа усуває цілий клас циклів та багів висячих посилань у писачі — хоча читачі, звісно, все одно мусять поважати /Extends, коли зустрічають його у файлах від інших виробників

Пакування потоків об'єктів та вивід потоку перехресних посилань постачаються як частина losLab PDF Library для Delphi та C++Builder, поруч зі збирачем сміття та оптимізатором потоків вмісту, з якими вони компонуються; сторінка продукту містить повний довідник опцій збереження