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

Розріджений лінивий індекс PDF-об'єктів у Delphi

Вам потрібен один словник із 2-гігабайтного PDF, а інструмент спершу розгортає всю таблицю перехресних посилань у масив, розмір якого задає /Size у трейлері. PDFiumPas замінює цей крок розрідженим лінивим індексом об'єктів: він тримає лише дескриптори секцій xref, розгортає один номер об'єкта на вимогу через обмежені вікна й кешує рівно ті записи, яких ви торкнулися

Стара форма цього коду в FPdfCompress була чесною, але дорогою. ApplyDefaultOpenAction зчитувала весь файл в один TBytes, а потім виділяла щільний масив TPdfActiveXrefEntries із коміркою на кожен номер об'єкта аж до /Size. У масштабі ламалися дві речі. Вартість читання росла лінійно з розміром документа, навіть коли виклику потрібні були лише чотири словники, а щільний масив конфліктував із бюджетом парсера: TPdfParserResourceBudget.Default ставить MaxObjects у 4 000 000, тож цілком валідний файл, у якого найбільший номер об'єкта лежить вище цієї стелі, відхиляли через аргумент пам'яті, а не коректності

Розріджений лінивий індекс об'єктів PDFiumPas у Delphi порівняно зі щільним масивом перехресних посилань: щільний шлях читає весь файл і виділяє комірку на кожен номер об'єкта аж до розміру трейлера, а розріджений шлях тримає лише дескриптори секцій
У пам'яті лишаються лише дескриптори, записи лишаються у файлі, а кожне читання проходить через обмежене вікно в один мебібайт

Чому публічний API PDFium не відповідає на це запитання?

Бо інформація існує всередині PDFium, але ніколи не перетинає межу C. CPDF_Parser веде таблицю перехресних посилань, членство в об'єктних потоках і пріоритет ревізій внутрішньо, однак опубліковані заголовки не дають точки входу, яка б прийняла номер об'єкта й повернула його сирий зсув, покоління, ревізію-переможця чи ObjStm, у якому він живе. Сторона збереження так само закрита: FPDF_SaveAsCopy і FPDF_SaveWithVersion віддають лише послідовний callback запису. Тож будь-яке байтове латання каталогу після нативного збереження доводиться будувати в Pascal-шарі — саме тому PDFiumPas розбирає ці структури сам, а не повторно використовує DLL

Що розріджений індекс насправді тримає в пам'яті?

Дескриптори, а не записи. Для класичної таблиці (ISO 32000-1 §7.5.4) TPdfSparseXrefSubsection зберігає перший номер об'єкта, кількість об'єктів, байтовий зсув, з якого починаються рядки записів, і виміряну ширину запису. Самі записи лишаються у файлі. Ширину вимірюють з першого рядка, а не приймають за 20 байтів, бо виробники розходяться в завершеннях рядків; PDFiumPas приймає від 18 до 64 і відхиляє все поза цим діапазоном, як і будь-яку підсекцію, чия заявлена кількість вийшла б за кінець потоку. Для потоку перехресних посилань (§7.5.8) секція тримає три ширини полів /W, кожна обмежена нулем і вісьмома, сплющені пари /Index та декодовані байти записів, чия очікувана довжина обчислюється з /W і /Index до того, як розпакується хоч один байт

Увесь індекс будує Initialize з хвостового вікна щонайбільше 1 MiB, у якому знаходиться startxref, а кожне наступне читання об'єкта користується віконцем об'єкта в 1 MiB. Стеля сирого потоку — 64 MiB, а один рядок xref не може перевищувати 1024 байти. Якщо ви читали наш нотаток про валідацію об'єктних потоків і потоків перехресних посилань за допомогою PDFiumPas, та сама дисципліна ширин полів діє й тут, лише тепер вона адресує один запис замість аудиту цілої таблиці

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { проходить лише startxref, ланцюжок /Prev і каталог }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

Як один пошук сягає одного об'єкта?

Арифметикою, в обох розкладках. Класична підсекція має рядки фіксованої ширини, тож адреса запису — це початок підсекції плюс зсув об'єкта, помножений на виміряну ширину; далі PDFiumPas читає цей один рядок, розбирає десятцифровий зсув і п'ятицифрове покоління, звіряє покоління зі стелею 65535 із §7.5.4 і класифікує завершальне ключове слово як axkDirect чи axkFree. Потік перехресних посилань потребує ще одного кроку, бо підсекції /Index склеєні в декодованій байтовій послідовності, тож індекс накопичує кількості попередніх підсекцій перед множенням на сумарну ширину /W. Тип 1 дає зсув, тип 2 — номер об'єктного потоку та індекс члена, а все інше стає axkUnknown замість здогадки

{ класична таблиця, ISO 32000-1, розділ 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ потік перехресних посилань, ISO 32000-1, розділ 7.5.8 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Ніщо в жодному зі шляхів не пропорційне /Size. У цьому вся суть переписування: значення розміру трейлера проноситься вперед як метадані і використовується під час запису інкрементної ревізії, але ніколи не керує виділенням пам'яті. Регресійний набір закріплює це фікстурою, чиє дерево сторінок живе в об'єктах 1 000 000 000 і 1 000 000 001 під трейлером, що заявляє /Size 1000000002. Стара щільна реалізація відхиляла такий файл; розріджений індекс розгортає обидва посилання і зберігає заявлений розмір у вихідному трейлері

Як PDFiumPas розгортає один номер об'єкта в Delphi: класична таблиця перехресних посилань множить на виміряну ширину рядка, а потік перехресних посилань накопичує кількості попередніх підсекцій перед множенням на сумарні ширини полів із масиву /W
Обидва пошуки — чиста арифметика, тож жоден з них не пропорційний кількості об'єктів, заявленій у трейлері

Гібридні ревізії, ланцюжки /Prev і варти навколо них

Пріоритет ревізій — те місце, де наївний лінивий індекс помиляється. PDFiumPas проходить ланцюжок від startxref у порядку «найновіші спершу» і зупиняє пошук на першій секції, що відповіла, — це відтворює правило пріоритету без матеріалізації злитої таблиці. Гібридно-посилальні файли (§7.5.8.4) обробляються в межах класичної гілки: коли трейлер несе /XRefStm, секція додаткового потоку реєструється перед класичною секцією, яка на неї посилалася, тож стиснуті об'єкти, невидимі для простої таблиці, все одно знаходяться, а класичні записи зберігають свою вагу. Старіші ревізії потім проходяться через /Prev

Цей прохід обмежують дві варти, і обидві важливі на пошкоджених файлах. Кожен відвіданий зсув записується, тож /Prev, що вказує назад у ланцюжок, завершується замість крутіння, а глибина обходу обрізається MaxRecursionDepth, усталено 1024. Прапорець шифрування накопичується вздовж усього ланцюжка, а не зчитується з найновішого трейлера, бо документ, чий останній трейлер оминає /Encrypt, може бути шифрованим глибше; виклики, що додають ревізії, спираються на цей прапорець, щоб відмовити в записі відкритих об'єктів у шифрований файл

Як PDFiumPas проходить гібридний ланцюжок ревізій PDF у Delphi: секції реєструються від найновішої з startxref, секція додаткового XRefStm стає попереду класичної таблиці, яка її назвала, а прохід /Prev обмежений відвіданими зсувами та стелею глибини
Пошук зупиняється на першій секції, що відповіла, — це відтворює пріоритет ревізій, жодного разу не матеріалізуючи злиту таблицю

Записи типу 2: чому об'єктний потік чекає

Запис типу 2 називає об'єктний потік, і PDFiumPas не торкається цього потоку, доки виклик не попросить його члена. Коли це нарешті станеться, звіряється /Type /ObjStm, /N перевіряється проти бюджету об'єктів, а /First — проти стелі декодованих байтів, і /N проходить перевірку здорового глузду проти /First, бо кожна пара заголовка потребує щонайменше чотирьох байтів. Лише тоді потік розпаковується, а скан заголовка зупиняється на запитаному членові та його наступникові замість побудови повної таблиці членів. Один декодований об'єктний потік утримується в один момент часу — це правильний торг, коли гілка дерева сторінок збивається в один ObjStm; наш матеріал про декодування об'єктних потоків і предикторів у Delphi розповідає, що відбувається всередині того кроку розпакування (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { один утримуваний індекс, багато читань з урахуванням поколінь }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source залишається вашим }
  end;
end;

Де кеш перестає давати обіцянки

Індекс — це знімок, і варто сказати це прямо. Секції розбираються один раз у Initialize; якщо підкладений потік змінено після цього, кожен кешований запис застарілий, і клас цього не помітить. TPdfSparseDictionaryReader тримає індекс упродовж життєвого циклу джерела, що належить виклику, — це рівно те, чого хоче рекурсивний обхід дерева сторінок, і рівно те, чого не можна робити через переписування. Кеш записів — плаский масив із лінійним пошуком, і він зберігає також негативні результати, тож кількасот пошуків дешеві, а кількасот тисяч — ні. ReadDictionary вимагає точного збігу покоління, тоді як ReadLatestDictionary розгортає активне, і ця різниця навмисна: розв'язанню посилань потрібне перше, огляду каталогу — друге. Де ці межі неможливо дотримати, сусідні модулі відступають до легасі-парсера цілого файлу замість звуження множини файлів, що ще працюють, — патерн, який ми вживаємо і для стрімінгу великих PDF на вимогу

Міжкомпіляторні регресії покривають ту саму поведінку на всіх трьох тулчейнах, включно з твердженням, що джерело в 2 MiB жодного разу не бачить читання більшого за 1 MiB. Якщо ви супроводжуєте код Delphi, C++Builder чи Lazarus, що торкається структури PDF напряму, і втомилися платити за розбір цілого файлу заради чотирьох словників, розріджений індекс і публічний шов навколо нього входять до компонента PDFiumPas Delphi PDFium