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

Ліниві члени object stream і повний перезапис PDF у Delphi

Коли HotPDF Delphi Component завантажує файл PDF 1.5 через LoadFromFile, він не розбирає об'єкти, спаковані в контейнерах /Type /ObjStm. Він запам'ятовує, де живе кожен стиснутий член, і розбирає його лише тоді, коли щось його попросить. Цей лінивий інваріант — те, що тримає час завантаження пропорційним до того, до чого ви справді торкаєтеся, і він же причина, чому повний перезапис мусить зробити одну додаткову роботу перед тим, як піде хоч один байт: розгорнути кожен член, який досі не розібрано, бо перезапис ось-ось відкине контейнери, у яких ті члени живуть

Симптом, який спричинив цю нотатку, легко описати й неприємно налагоджувати. Завантажте файл, чиї шрифти, колірні простори й дерево структури сидять в object streams, проженіть його через пару генерації BeginDoc і EndDoc — і вивід відкриється без скарг. Кількість сторінок правильна, текст видно на сторінках, які ви вибірково перевірили. А потім колега відкриває сторінку 40 і бачить основний текст у підставленому шрифті, або команда Extract Text повертає сміття там, де раніше була заміна ActualText. Ніщо не впало. Письменник просто серіалізував об'єкт, який ніколи не завантажували, а незавантажений об'єкт серіалізується як ніщо

Що LoadFromFile насправді тримає для стиснутого об'єкта?

Для кожного cross-reference запису типу 2 LoadFromFile тримає маленький record у FCompactObjects: номер об'єкта, індекс потоку-контейнера в таблиці контейнерів, позицію члена всередині цього потоку і вказівник ParsedObject, який починається як nil. Сам контейнер знаходиться, розшифровується, якщо документ зашифровано, і розпаковується, але тіла членів лишаються байтами. ISO 32000-1 §7.5.7 визначає структуру контейнера, яка це уможливлює: заголовок із пар «номер об'єкта — зсув», а далі тіла членів, склеєні після /First, тож будь-який окремий член можна вирізати, не торкаючись сусідів

EnsureCompressedObjectLoaded — єдиний шлях, який перетворює record на об'єкт. Він знаходить record за номером об'єкта і, якщо ParsedObject уже встановлено, повертає той кешований об'єкт і зараховує попадання в кеш. Інакше він перезавантажує контейнер, якщо його було витіснено, обчислює байтовий діапазон члена з таблиці зсувів, віддає парсеру zero-copy вигляд цього зрізу й зберігає результат назад у record. Відтоді об'єкт є непрямим, несе свій справжній номер об'єкта й зареєстрований в індексі об'єктів документа, як будь-який об'єкт, розібраний із тіла файлу. Каталог, словник info, корінь дерева сторінок і об'єкти сторінок проходять цим шляхом під час завантаження, бо навігація їх потребує. Шрифти, колірні простори, словники ExtGState й елементи структури — ні, і вони лишаються записами, доки їх не торкне рендеринг сторінки або перезапис

Як HotPDF Delphi Component тримає стиснутий член до його розбору: record у FCompactObjects зберігає номер об'єкта, індекс контейнера, індекс члена і nil-вказівник ParsedObject, а EnsureCompressedObjectLoaded перетворює record на зареєстрований об'єкт через попадання в кеш, перезавантаження контейнера, нарізання за таблицею зсувів і zero-copy розбір
LoadFromFile лишає тіла членів /ObjStm байтами й розбирає їх лише коли читач попросить, тож час завантаження йде за тим, до чого ви торкаєтеся — каталог і дерево сторінок приходять рано, поки шрифти, колірні простори й елементи структури лишаються записами

Це можна спостерігати ззовні. GetLoadedObjectStreamCacheInfo повідомляє, скільки існує контейнерів, скільки членів проіндексовано і скільки з них уже розібрано:

var
  Pdf: THotPDF;
  Info: THPDFObjectStreamCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('tagged-report.pdf');
    if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
      Writeln(Format('%d containers, %d members indexed, %d parsed so far',
        [Info.ContainerCount, Info.IndexedObjectCount,
         Info.MaterializedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

На файлі, насиченому структурою, третє число — це мала частка другого відразу після завантаження. Ця різниця і є сенс лінивого завантаження, і вона ж — рівно та множина об'єктів, за якими повний перезапис мусить повернутися

Чому повний перезапис відкидає шрифти, які інкрементальне збереження зберігає?

Повний перезапис відкидає контейнери /ObjStm і /XRef вихідного файлу й серіалізує граф об'єктів із нуля, тож будь-який член, чий ParsedObject усе ще nil, не має у виводі жодного представлення. Інкрементальне оновлення такої проблеми не має ніколи, бо воно дописує нові об'єкти після оригінальних байтів і лишає старі контейнери на місці, щоб на них могла вказувати попередня секція cross-reference. Різниця не в тому, як два режими поводяться зі шрифтами. Вона в тому, чи виживають оригінальні контейнери, щоб їх прочитав наступний переглядач

Виправлення живе в SaveToStream — серіалізаторі, яким керує EndDoc, незалежно від того, ви виставили FileName чи OutputStream. Перед тим як перейти до будь-якої гілки письменника, він обходить FCompactObjects і викликає EnsureCompressedObjectLoaded для кожного запису. Якщо член завантажити неможливо, збереження піднімає помилку, а не продовжує, бо перезапис, який тихо губить словник шрифту, гірший за той, що зупиняється. Розгортання мусить стояти саме на цьому рівні — вище за класичну, спаковану й лінеаризовану гілки, і вище за вичищення перезавантажених структурних потоків у лінеаризованому маршруті. Раніша версія розгортала члени лише всередині SaveLoadedDocument, що покривало словник завантаженого документа й зовсім пропускало словник генерації. LoadFromFile з наступними BeginDoc, правками сторінок і EndDoc ішли прямо до письменника з кожним недоторканим членом усе ще нерозібраним

Де стоїть розгортання для повного перезапису в HotPDF: SaveToStream обходить кожен запис FCompactObjects через EnsureCompressedObjectLoaded, перш ніж перейти до класичного, спакованого чи лінеаризованого письменника, тож і словник SaveLoadedDocument, і словник LoadFromFile плюс BeginDoc плюс EndDoc серіалізують повністю розібрані об'єкти, а не nil-записи
Інкрементальне оновлення дописує після оригінальних байтів і лишає старі контейнери читабельними, а повний перезапис їх відкидає — один прохід розгортання над кожною гілкою письменника — це те, що не дає незавантаженому шрифту чи елементу структури серіалізуватися як ніщо
// Обидва словники перезапису тепер розгортають компактні члени до запуску будь-якого письменника.
// Шлях завантаженого документа:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// Шлях генерації поверх завантаженого файла:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc;   // SaveToStream спершу матеріалізує кожен запис FCompactObjects

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

Чому перевірка пікселів на трьох сторінках не ловить випадок ActualText

Елементи структури — це місце, де цей баг ховається найдовше. Запис ActualText на послідовності marked content, визначений у ISO 32000-1 §14.9.4, замінює гліфи для витягування й доступності, але не впливає на рендеринг. Якщо елемент структури живе в object stream, а перезапис його губить, сторінка все одно малюється правильно, перша, середня й остання сторінки порівнюються піксель у піксель із джерелом, і регресія проявляється лише коли хтось запускає витягування тексту або скрін-рідер. Тест перезапису, який лише рендерить сторінки, не є тестом перезапису для тегованого PDF. Порівнюйте також витягнутий текст і дерево структури

Як порожній пароль користувача змінює завантаження?

Порожній пароль користувача все одно означає, що файл зашифровано, а object streams у такому файлі є шифротекстом, доки не відновлено ключ файлу. ISO 32000-1 §7.6.3.4 Algorithm 2 виводить цей ключ із пароля, запису /O, /P і першого ідентифікатора документа, і HotPDF мусить прогнати його проти порожнього рядка, перш ніж прохід типу 2 зможе розпакувати бодай один контейнер. Саме тому BeginDoc на завантаженому зашифрованому документі першим ділом викликає DecryptLoadedDocument з порожнім паролем: граф об'єктів мусить бути автентифікований і розшифрований до початку перезапису, незалежно від того, чи виклик має намір захистити вивід. Шифрування виводу — окреме рішення, яке визначають налаштування захисту виклику, і BeginDoc відновлює ці налаштування після проходу розшифрування, щоб зашифрований вхід не перетворився тихо на зашифрований вивід

Політику щодо контейнерів читають зі словника /Encrypt до того, як спробувати будь-який пароль. Для /V 1 і 2 кожен потік шифрується ключем файлу. Для crypt-фільтрів HotPDF розв'язує /StmF через /CF: фільтр Identity або /CFM зі значенням None означає контейнери у відкритому тексті, тоді як V2 і AESV2 — зашифровані. Відповідь лягає в FReloadObjectStreamsEncrypted, і вона важлива для одного конкретного випадку. Коли контейнери у відкритому тексті, а рядки — ні, члени несуть зашифровані рядки, які треба розшифровувати поштучно, тож MaterializeMembersOfPlaintextObjectStreams розгортає кожен компактний член перед проходом пооб'єктного розшифрування. Він не робить нічого, коли політика ще невідома, і нічого, коли самі контейнери були зашифровані, бо члени зашифрованого контейнера вже розшифровані разом із ним і ніколи не повинні розшифровуватися двічі

Що стається, коли контейнер неможливо розшифрувати?

Контейнер, який не проходить розшифрування, ізолюється, а не стає фатальним. Прохід типу 2 записує запис THPDFObjStmQuarantineInfo у FObjStmQuarantine з номером об'єкта контейнера, значенням THPDFObjStmQuarantineReason, діагностичним рядком і списком номерів об'єктів членів, які cross-reference направив у нього. osqrDecryptFailed піднімається для чотирьох різних ситуацій: жоден crypt-фільтр не вдалося розв'язати, розшифрування AES-256 або AES-GCM кинуло виняток, розшифрування legacy RC4 або AES-128 кинуло виняток, або робочого ключа файлу не існує взагалі. Незалежні контейнери продовжують завантажуватися, тож документ з одним пошкодженим контейнером усе ще відкривається й рендерить кожну сторінку, яка від нього не залежить

Як працює карантин розшифрування в HotPDF на завантаженому PDF: контейнер, розшифрування якого кидає виняток, записується як THPDFObjStmQuarantineInfo з причиною osqrDecryptFailed і номерами об'єктів своїх членів, незалежні контейнери продовжують завантажуватися, а BeginDoc піднімає помилку на першому невдалому записі, перш ніж перезапис зможе повідомити про успіх
Записи карантину переживають відкат парсера, і BeginDoc перевіряє їх за назвою, а не за прапорцем шифрування, тож документ з одним пошкодженим контейнером усе ще відкривається, поки шлях перезапису зупиняється замість того, щоб записати порожні об'єкти

Список карантину переживає відкат парсера. Якщо основне завантаження cross-reference падає і HotPDF реконструює таблицю об'єктів скануванням файлу, прапорець шифрування з першої спроби може цю реконструкцію не пережити, але записи карантину переживають. Саме тому BeginDoc перевіряє список карантину, а не прапорець шифрування: на завантаженому документі він обходить FObjStmQuarantine і піднімає помилку на першому записі osqrDecryptFailed, називаючи контейнер і просячи перезавантаження з чинним паролем. Перезапис, який пройшов би далі цієї точки, записав би члени, які контейнер мав тримати, як порожні об'єкти й повідомив про успіх. Ви можете виконати ту саму перевірку самі, раніше й зі своєю політикою, через публічні аксесори:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // порожній пароль користувача
  for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
    if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
       (Info.Reason = osqrDecryptFailed) then
      raise Exception.CreateFmt(
        'Object stream %d is unreadable (%s); %d members unresolved',
        [Info.ContainerObjNum, String(Info.Diagnostic),
         Length(Info.MemberObjNums)]);
  // звідси перезапис безпечний
end;

Інші причини карантину покривають некриптографічні відмови: контейнер, який не є потоком, відсутній словник, невалідні /N або /First, розмір потоку поза прийнятним діапазоном, відмова декомпресії, /First, що вказує за дані, або тіло члена, яке декодувалося, але не розібралося. Їх варто логувати при прийманні, бо кожна з них називає точні члени, яких вам бракуватиме далі

Чому перезапису потрібен оригінальний числовий токен?

HotPDF зберігає кожен числовий об'єкт як Single, а Single не може відтворити вихідний текст дійсного числа. ISO 32000-1 §7.3.3 дозволяє письменнику видати 0.750000, .75 або 0.75 для того самого значення, і жодне з них не переживе подорожі через 24-бітну двійкову форму й загальний форматувальник без змін. Гірше того, значення на кшталт 0.7 у Single взагалі не представимо; воно розбирається в найближчий float, а переформатування цього float може дати 0.69999999 або округленого сусіда залежно від циклу цифр. На кольорі заливки чи константі прозорості /CA це різниця в один крок у 8-бітному каналі — достатньо, щоб не пройти піксельне порівняння з джерелом, і на межах градієнтів достатньо, щоб це було видно

THPDFNumericObject.RememberSourceToken розв'язує це для незміненого випадку. Парсер викликає його з сирим токеном одразу після присвоєння Value; метод приймає лише токени з цифр, щонайбільше з однією десятковою крапкою і опційним провідним знаком, і зберігає токен разом зі значенням, якому він відповідав, у FSourceValue. Властивість SourceToken повертає збережений текст лише поки Value усе ще дорівнює FSourceValue. Змініть число — і токен випаровується, тож змінене значення завжди йде наявним шляхом форматування й ніколи не видає застарілий текст. SaveNumericObject перевіряє SourceToken першим і пише його дослівно, коли він є, а до гілок цілих, посилань на колірний простір і дробових спускається лише для чисел, створених або відредагованих у пам'яті

Інваріант маленький, і його варто сформулювати прямо: число, якого ви не торкалися, записується тими байтами, якими його прочитали, а число, якого ви торкнулися, записується власним форматувальником HotPDF. Компактні члени виграють від цього так само, як об'єкти з тіла файлу, бо EnsureCompressedObjectLoaded проганяє той самий парсер над зрізом члена. Саме форматування чисел і його незалежність від локалі процесу розібрані в статті про інваріантне до локалі форматування чисел PDF у HotPDF

Як тестувати шлях перезапису проти object streams

Три перевірки ловлять кожну описану вище відмову, і жодна з них не потребує Acrobat. Перша: порівняйте IndexedObjectCount з MaterializedObjectCount після збереження; при повному перезаписі вони мусять бути рівні, і будь-яка різниця — це втрачений член. Друга: витягніть текст і перелічіть дерево структури в обох файлах, а не лише відрендерте їх, щоб втрачений ActualText або втрачений елемент структури проявився як розбіжність. Третя: завантажте вивід свіжим екземпляром і переконайтеся, що GetLoadedQuarantinedObjStmCount дорівнює нулю, — це також доводить, що письменник не виробив контейнер, який читач не може відкрити. Комбінації crypt-фільтрів, які визначають FReloadObjectStreamsEncrypted, розписані в статті про політики StmF, StrF і EFF. Бік письменника цієї історії — як видавати object streams і коли віддавати перевагу інкрементальному оновленню над перезаписом — у посібнику з object streams та інкрементальних оновлень

Ліниве завантаження членів, прохід розгортання перед письменником, карантин розшифрування й збереження вихідних токенів — усе це постачається в HotPDF Delphi Component для Delphi і C++Builder. Сторінка продукту посилається на довідку з API, якщо ви хочете простежити GetLoadedObjectStreamCacheInfo і аксесори карантину проти власного конвеєра приймання