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

Разреден мързелив индекс на PDF обекти в Delphi

Искате един речник от PDF с 2 GB, а инструментът първо разширява цялата кръстосана референтна таблица в масив, оразмерен по /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, всяка ограничена от 0 до 8, изгладените двойки /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; нашата статия за декодиране на обектни потоци и predictor в 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 компонента