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

Редактирование name tree и number tree в PDF на Delphi

Патч массива листа, чтобы добавить одно имя, — и запись всё равно может читаться как отсутствующая. PDFium Component отвечает на это TPdfTreeEditor — универсальным редактором name tree и number tree стандарта ISO 32000: он загружает дерево со строгой валидацией, правит записи в памяти, пересобирает сбалансированный набор узлов с корректными /Limits и пишет проверяемое инкрементальное обновление

Отказ, ради которого появился этот модуль, — из тех, что диагностируются только чтением байтов. Некий инструмент дописывает ключ и значение в массив /Names листа, пишет новое обновление, и каждый hex-дамп подтверждает, что ключ в файле. Acrobat всё равно говорит, что вложения не существует. Ничего не повреждено в наивном смысле. Дерево просто перестало быть деревом, по которому алгоритм поиска может спуститься

Почему патченный массив листа ломает поиск?

Потому что поиск — это бинарный спуск по /Limits, а не скан листьев. ISO 32000-1 §7.9.6 определяет name tree как корень, чьи /Kids ведут вниз к листьям с /Names — массивом чередующихся ключей и значений, где ключи упорядочены байтовым сравнением по всему дереву, и каждый некорневой узел объявляет /Limits — свой наименьший и наибольший ключ. Конформный читатель сравнивает искомый ключ с диапазоном каждого ребёнка и открывает ровно одного. Допишите ключ в лист, не тронув старые /Limits, — и спуск пройдёт мимо узла, который на самом деле его держит

Диаграмма поиска по name tree в PDFium Component: читатель бинарно спускается по диапазонам /Limits детей через байтовое сравнение, так что ключ вроде invoice-2026-09, дописанный в лист, чьи устаревшие /Limits всё ещё заканчиваются на (f), никогда не открывается, спуск идёт в ребёнка (g) (z), и вложение читается как отсутствующее, хотя оно в файле
Поиск открывает ровно одного ребёнка, сравнивая искомый ключ с диапазоном каждого, так что лист, получивший ключ без обновления /Limits, проходится спуском мимо

Number tree (§7.9.7) имеет ту же форму с /Nums и целочисленными ключами, и несёт реальный трафик: /PageLabels в §12.4.2 — это number tree, а категории /Names каталога из §7.7.4 дают вам /Dests, /EmbeddedFiles и /JavaScript как name tree. Так что должны держаться вместе четыре инварианта, а не по одному. Ключи обязаны строго возрастать в порядке дерева, каждый уровень должен публиковать limits, совпадающие с его фактическим диапазоном ключей, диапазоны соседних узлов не должны пересекаться, а номер объекта корня и его generation должны остаться ровно там, где были, потому что каталог и любой другой референт указывают на этот корень косвенной ссылкой. Наша заметка о работе с вложениями PDF в Delphi через PDFium Component показывает потребительскую сторону той же структуры

Что TPdfTreeEditor отказывается загружать?

Больше, чем вы могли ожидать, и это намеренно. TPdfTreeEditor.TryLoad обходит граф узлов через TPdfSparseDictionaryReader, разрешая каждого ребёнка ReadDictionary с тем самым generation, который назван в ссылке, а не принимая то обновление, которое случайно активно. Дерево, которое нельзя доказать корректно сформированным, отвергается до того, как будет разрешена хоть одна правка, и это не даёт сломанному входу превратиться в правдоподобно выглядящий сломанный вывод

  • Циклы и повторные ссылки, оба докладываются как ptviCycle, так что запись /Kids, указывающая вверх по цепочке, завершается, а не рекурсирует
  • Смешанные формы узлов: словарь с /Kids вместе с /Names или /Nums — это ptviInvalidNodeShape, как и пустой некорневой лист
  • Несортированные или дублирующиеся ключи, докладываются как ptviUnsortedKey и ptviDuplicateKey, проверяются по всему обходу, а не внутри одного листа
  • Limits, расходящиеся с фактически присутствующими ключами, докладываются как ptviInvalidLimits
  • Сырое значение, не являющееся ровно одним полным объектом PDF, докладывается как ptviInvalidValue
  • Превышения бюджета: ptviDepthLimit, ptviNodeLimit, ptviEntryLimit и ptviDeadlineExceeded, ограниченные и TPdfTreeEditOptions, и центральным бюджетом парсера

TPdfTreeEditOptions.Default выставляет эти локальные для дерева границы в 1 000 000 записей, 100 000 узлов, глубину 64, 64 записи на лист и 32 ребёнка на узел. Цифры листа и детей — не только страховки; это fan-out, используемый позже при пересборке дерева, поэтому запись валидирует, что обе цифры не меньше двух

uses
  FPdfCompress;

var
  Options: TPdfTreeEditOptions;
  RootRef: TPdfTreeObjectReference;
  Editor: TPdfTreeEditor;
  Report: TPdfTreeValidationReport;
begin
  Options := TPdfTreeEditOptions.Default;
  RootRef.ObjectNumber := EmbeddedFilesRootObject;   { каталог /Names /EmbeddedFiles }
  RootRef.Generation := EmbeddedFilesRootGeneration;
  if not TPdfTreeEditor.TryLoad(Source, ptkName, RootRef, Options,
    Editor, Report) then
  begin
    Writeln('tree rejected: ', Report.ErrorMessage);
    Exit;
  end;
  try
    { значение — сырые байты одного полного объекта PDF }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { Editor.Free после сохранения ниже }
  end;
end;

Значения записей остаются сырыми байтами объектов PDF

Значение записи хранится байт в байт, и это единственное проектное решение, которое делает редактор безопасным для файлов, которые писали не вы. TPdfTreeEntry держит NameKey, NumberKey и RawValue, и RawValue — это сырой байтовый прогон одного полного объекта PDF: словарь, массив, строка, ссылка на поток или голая косвенная ссылка. Ничего не декодируется в вариант Delphi, TStrings или типизированную запись. Выигрыш — round-trip без потерь для значений, о которых модуль ничего не знает. /Filespec с вендорским расширением, массив назначений с необычным типом fit, словарь действия /JavaScript с приватными ключами — всё переживает пересборку байт в байт, потому что у редактора никогда не было мнения об их содержимом. Это же сужает API модификации до доказуемого: UpsertName и UpsertNumber должны лишь проверить, что переданное значение парсится ровно как один полный объект, а RemoveName, RemoveNumber и Clear трогают только список записей

Как пересобирается сбалансированное дерево без перенумерации корня?

В два прохода с одной неподвижной точкой. BuildBalancedObjects сначала режет отсортированный список записей на листья не более чем по MaxLeafEntries, затем складывает каждый уровень в родителей не более чем по MaxKidsPerNode и повторяет, пока не останется один узел. Limits вычисляются снизу вверх: лист берёт свой первый и последний ключ, внутренний узел — нижний limit первого ребёнка и верхний limit последнего, так что пересечение диапазонов соседних узлов невозможно по построению

Неподвижная точка — идентичность. Финальный корень всегда получает тот номер объекта и generation, с которыми дерево было загружено, так что ссылка каталога остаётся валидной и ничего больше в документе переписывать не нужно. Каждый прочий узел переиспользует ссылку ранее загруженного узла, только если совпадают вид узла и полный диапазон ключей, а иначе берёт свежий номер, выделенный из активного максимального номера объекта плюс один. Такая комбинация держит нетронутые поддеревья указывающими на их существующие объекты и гарантирует, что любой узел, чьё содержимое сдвинулось, пишется новым объектом, а не молча переинтерпретируется. Та же дисциплина переиспользования ссылок движет редактором оглавления и его перемаппингом страниц в соседнем модуле

Диаграмма пересборки в TPdfTreeEditor из PDFium Component: BuildBalancedObjects режет отсортированный список записей на листья не более чем по MaxLeafEntries ключей, складывает каждый уровень в родителей не более чем по MaxKidsPerNode, вычисляет /Limits снизу вверх из первых и последних ключей и отдаёт готовому корню ровно тот номер объекта и generation, с которыми дерево было загружено
Листья берут свои limits из первых и последних ключей, внутренние узлы — из крайних детей, а финальный корень сохраняет номер объекта и generation, с которыми дерево было загружено
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber приходит из исходного обновления }
  if Editor.BuildBalancedObjects(MaximumObjectNumber + 1,
    Serialized, Report) then
  begin
    Writeln('nodes ', Report.NodeCount, ' depth ', Report.MaximumDepth);
    for I := 0 to High(Serialized) do
      Writeln(Serialized[I].Reference.ObjectNumber, ' ',
        Serialized[I].Reference.Generation, ' obj');
  end
  else
    for I := 0 to High(Report.Issues) do
      Writeln('issue ', Ord(Report.Issues[I].Kind), ' at obj ',
        Report.Issues[I].ObjectNumber, ': ', Report.Issues[I].Message);
end;
>

Запишите обновление, затем прочитайте результат обратно

Сохранение закончено, только когда вывод был переоткрыт и сравнён. SaveIncremental читает факты исходного обновления, отказывается от корня, который является самим каталогом документа, сериализует сбалансированный набор узлов плюс объект каталога и отдаёт всё в AppendPdfSparseUpdate, который дописывает новое обновление по §7.5.6, сохраняя существующую ссылку /Info и массив /ID документа

А затем идёт часть, которую стоит скопировать в свои собственные писатели. Редактор вызывает TPdfTreeEditor.TryLoad ещё раз, теперь уже против целевого потока, с той же ссылкой на корень и теми же опциями, и сравнивает перезагруженное число записей и каждый ключ и RawValue с тем, что собирался записать. Если перезагрузка падает или любое сравнение расходится, целевой поток сбрасывается вместо возврата, так что частично записанное обновление никогда не отдаётся как успех. Верификация читает вывод через тот же ограниченный по бюджету sparse-путь, описанный в нашей статье про sparse-ленивый индекс объектов PDF, — именно это держит проверку дешёвой на больших файлах

Диаграмма проверяемого инкрементального сохранения в PDFium Component: SaveIncremental сериализует сбалансированный набор узлов, AppendPdfSparseUpdate дописывает новое обновление, сохраняя /Info и массив /ID документа, затем TPdfTreeEditor.TryLoad перечитывает целевой поток и сравнивает число записей и каждый ключ и RawValue, сбрасывая поток вместо возврата наполовину записанного обновления
Сохранение закончено, только когда вывод переоткрыт через тот же ограниченный путь и сравнён запись за записью, а неудачное сравнение сбрасывает целевой поток
var
  Dest: TFileStream;
begin
  Dest := TFileStream.Create(OutputName, fmCreate);
  try
    if Editor.SaveIncremental(Source, Dest, Report) then
      Writeln('wrote ', Report.NodeCount, ' nodes, ',
        Report.EntryCount, ' entries')
    else
      { целевой поток уже сброшен, ничего наполовину записанного не осталось }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;
e>

Зашифрованные источники отвергаются, и это честно

Текущая реализация отвергает зашифрованный источник без разговоров. Причина конкретная, а не защитная: пересборка дерева вводит новые строковые объекты и может ввести новые ссылки на потоки, а §7.6 требует шифровать их по объекту ключом документа и номером объекта владельца с его generation. Скопировать вперёд трейлер /Encrypt, дописывая при этом открытый текст строк, — значит получить файл, который открывается, а затем читается как мусор ровно там, где лежат ваши новые ключи, а это хуже чёткого отказа. Стоит проговорить прямо и ещё две границы. Редактор работает по снапшоту исходного потока, так что источник нельзя модифицировать, пока над ним жив редактор, и он отказывается трактовать каталог документа как корень дерева — каталог это словарь, который просто содержит деревья, а не узел дерева. Если вы поддерживаете код на Delphi, C++Builder или Lazarus, который пишет /EmbeddedFiles, /Dests, /JavaScript или /PageLabels, и до сих пор патчили массивы листьев руками, — редактор деревьев и его проверяемое инкрементальное сохранение поставляются в PDFiumPas Delphi PDFium component