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

Согласование /ParentTree и /StructParents в Delphi

PDFium Component маркирует существующий контент PDF из Delphi через EditPdfDocumentStructure в FPdfDocumentStructure.pas. Операции psekAddMarkedContent и psekAddObjectReference делают больше, чем дописывают MCR или OBJR к /K элемента структуры: они также выделяют ключ /StructParents или /StructParent и пишут соответствующую запись /ParentTree — тот обратный индекс, которого требует ISO 32000-1 §14.7.4.4

Вторая половина — то место, где ретрофит тегов обычно идёт вкривь. Добавить обёртку BDC и MCR просто, и вьювер, спускающийся по дереву структуры сверху, с радостью найдёт новый контент. Но вспомогательные технологии часто идут с другой стороны: они стартуют от куска контента на странице и спрашивают, какой элемент структуры им владеет. Если у страницы нет ключа /StructParents или в /ParentTree нет записи для нового MCID, тот поиск проваливается, и чекеры рапортуют об отсутствующем или несогласованном parent tree. Файл выглядит тегированным и всё равно не работает

Что /ParentTree реально отображает?

/ParentTree — числовое дерево, отображающее целые ключи в элементы структуры, и ключи приходят от контента, а не от дерева. ISO 32000-1 §14.7.4.4 даёт каждой странице или Form XObject, содержащей marked content, целое /StructParents, и значение parent tree под тем ключом — массив, индексируемый MCID: элемент 3 массива — это элемент структуры, владеющий <</MCID 3>>. Аннотация или XObject, использованные как целый контентный элемент, получают вместо этого одиночный ключ /StructParent, и их значение parent tree — сам элемент структуры, а не массив

Обратный индекс parent tree PDF/UA, записываемый PDFium Component в Delphi: контентная метка с MCID 3 разрешается через ключ /StructParents страницы 17 в числовое дерево /ParentTree, чей элемент массива 3 — элемент структуры, владеющий marked content
Для тегированного PDF порядок чтения важен: вспомогательная технология идёт от MCID на странице через /StructParents в массив /ParentTree, и отсутствующий ключ или запись ломает тот поиск, хотя само дерево структуры выглядит нормально

Формат контейнера — универсальное числовое дерево из §7.9.7. Лист держит /Nums, плоский массив пар ключ-значение, отсортированный по возрастанию ключа. Промежуточные и листовые узлы под корнем несут /Limits с наименьшим и наибольшим покрываемым ключом, тогда как сам корень /Limits иметь не должен. Поверх того корень дерева структуры может нести /ParentTreeNextKey (§14.7.2) — следующий ключ, который писателю стоит раздать. Каждая правка, тегирующая новый контент, потому трогает до четырёх объектов, обязанных согласиться: content stream, словарь страницы или формы, лист parent tree и корень дерева структуры

У прямого направления свои правила, изложенные в §14.7.4.2 и §14.7.4.3. Ссылка на marked content (MCR) для контента в собственном content stream страницы нуждается лишь в /Pg и /MCID. Когда marked content живёт внутри Form XObject, MCR нуждается ещё и в /Stm, называющем тот поток, потому что MCID нумеруются по content stream. Ссылка на объект (OBJR) называет аннотацию или XObject через /Obj и её страницу через /Pg

Тегирование content stream через EditPdfDocumentStructure

EditPdfDocumentStructure — пакетный редактор со стражей снимка. Сперва вы вызываете ReadPdfDocumentStructure, чтобы получить TPdfDocumentStructureSnapshot, затем возвращаете его SourceSha256 вместе с массивом записей TPdfStructureEditOperation. Если исходные байты тем временем менялись, правка отказывается без вывода. Существующие элементы адресуются номером объекта в NodeID, а 0 обозначает корень дерева структуры

uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;

function TagContentStream(const FileName, OutName: string;
  ElementID, StreamObj, StreamGen: Integer): Boolean;
var
  Source: TMemoryStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  Output: TBytes;
begin
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(FileName);
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);

    SetLength(Ops, 1);
    Ops[0] := Default(TPdfStructureEditOperation);
    Ops[0].Kind := psekAddMarkedContent;
    Ops[0].NodeID := ElementID;            // существующий элемент, по номеру объекта
    Ops[0].TargetObjectNumber := StreamObj; // content stream страницы или Form XObject страницы
    Ops[0].TargetGeneration := StreamGen;

    Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
      Ops, Options, Output, Report);
    if not Result then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
  finally
    Source.Free;
  end;
end;

Для той одной операции EditPdfDocumentStructure оборачивает целевой поток в /P <</MCID n>> BDC ... EMC, дописывает MCR к /K элемента, даёт странице (или форме) ключ /StructParents, если его нет, и пишет элемент n в массив parent tree того ключа. Результат пишется инкрементальным обновлением после исходных байтов, а Report.CreatedMarkedContents сообщает, сколько MCR создано. Когда поток уже содержит сбалансированные пары BDC/EMC, оборачивается только нетегированный хвост после последнего верхнеуровневого EMC; иначе — весь поток

Как выбираются новые MCID и ключи parent tree?

Новый MCID — текущий максимум плюс один, а новый ключ parent tree — Max(MaxKey + 1, ParentTreeNextKey). Для MCID EditPdfDocumentStructure берёт максимум по MCR, уже записанным для той страницы (или того Form XObject), и по каждому /MCID, найденному в собственных property list потока. Считать оба важно: дерево структуры могло потерять MCR, пока контент всё ещё несёт метку, и переиспользование того числа сделало бы два элемента претендентами на один контент

Логика ключей следует той же защитной идее. Если у владельца уже есть значение /StructParents, существующий массив продлевается по индексу MCID, с заполнением любых дыр значением null, и новый ключ не выделяется. Если нет — компонент сканирует каждый лист /Nums на самый высокий ключ, сравнивает его с /ParentTreeNextKey и берёт большее. Протухшее /ParentTreeNextKey, меньшее уже используемых ключей, и такое, что резервирует ключи за последней записью, обрабатываются оба, потому что большее из двух значений всегда свободно. После выделения /ParentTreeNextKey продвигается до нового ключа плюс один

Выделение MCID и ключа parent tree в редакторе структуры PDFium Component для Delphi: новый MCID на единицу выше максимума, уже записанного в MCR и property list потока, а ключ — большее из высшего ключа /Nums плюс один и /ParentTreeNextKey, с заполнением дыр массива значением null
EditPdfDocumentStructure выводит новый MCID и из MCR, и из меток в потоке, а ключ parent tree выделяет с оглядкой, поэтому протухший /ParentTreeNextKey или переиспользованный MCID не позволят двум элементам претендовать на один контент

Новая пара вставляется в /Nums по возрастанию, а не дописывается, потому что поиски в числовом дереве полагаются на тот порядок. Если лист несёт /Limits, границы пересчитываются из переписанного массива. Когда у корня дерева структуры нет вовсе /ParentTree — обычное дело в файлах, тегированных инструментами, писавшими лишь прямое дерево, — EditPdfDocumentStructure синтезирует новый косвенный объект << /Nums [] >> и привязывает его от /StructTreeRoot прежде вставки первой записи

Form XObject, /StmOwn и ссылки на объекты

Marked content внутри Form XObject поддерживается, только когда та форма используется ровно одной страницей. MCR, который EditPdfDocumentStructure пишет для формы, несёт /Pg, /Stm, называющий поток формы, и /StmOwn, называющий страницу, а ключ /StructParents ложится на словарь формы, а не страницы. Форма, нарисованная тремя страницами, имеет трёх правдоподобных владельцев, поэтому любое единственное /StmOwn неверно для двух из них. Компонент отказывает правке с ошибкой, а не угадывает

Правило одного владельца при тегировании содержимого Form XObject в PDFium Component для Delphi: MCR на форме, используемой одной страницей, несёт /Pg, /Stm и /StmOwn с /StructParents на словаре формы, тогда как форма, нарисованная тремя страницами, делает /StmOwn неоднозначным, и правка отвергается
Ключ /StmOwn имеет смысл, только когда формой владеет одна страница, поэтому разделяемые формы и разделяемые аннотации отказываются, вместо того чтобы тегироваться угадываемым владельцем

То же правило уникальности действует для psekAddObjectReference. Цель обязана быть аннотацией, встречающейся в /Annots ровно одной страницы, либо Form или Image XObject, встречающимся в ресурсах ровно одной страницы; всё остальное отвергается. OBJR получает /Obj и /Pg, цель получает ключ /StructParent (переиспользуется при наличии), а значение parent tree — прямая ссылка на элемент. Это обычный фикс для аннотации-ссылки или изображения-иллюстрации, тегированных визуально, но никогда не подключённых к дереву

// Завернуть нетегированное изображение в новый элемент Figure, затем привязать двумя способами:
// как OBJR для XObject изображения и как marked content на странице
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // временный ID, обязан быть отрицательным и уникальным
Ops[0].ParentNodeID := SectID;    // существующий элемент Sect
Ops[0].Value := 'Figure';         // стандартная роль или запись RoleMap

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // ссылается на контейнер, созданный выше
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;

Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;

if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
  Output, Report) then
  Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
    Report.InsertedNodes[0].ObjectNumber,
    Report.CreatedObjectReferences, Report.CreatedMarkedContents]));

Отрицательные значения NodeID называют контейнеры, созданные раньше в том же пакете, а Report.InsertedNodes отображает каждый запрошенный ID в полученный им номер объекта. psekInsertContainer принимает стандартные роли PDF 1.7, PDF 2.0 и PDF/UA вроде P, H1 по H6, Figure, Table или LI, плюс кастомные роли, уже отображённые в /RoleMap. Установка BeforeNodeID на двух контентных операциях ставит новый MCR или OBJR перед существующим потомком, а не в конец

Почему правка одного потока дважды в пакете требует аккуратности?

Пакет может ударить по одному объекту повторно, и каждая поздняя операция обязана видеть результат ранних. Две операции psekAddMarkedContent против одной страницы обе переписывают её content stream, и обе обновляют один и тот же лист parent tree. Реализация, ставящая каждую правку отдельным дописанным объектом, пишет две версии объекта 12 в обновлении. В зависимости от того, какую из них оставит читатель, вы теряете первую обёртку BDC или первую пару /Nums, а второй MCID может столкнуться с первым, потому что вычислялся по протухшим байтам

EditPdfDocumentStructure ключует поставленные на подготовку обновления по (ObjectNumber, Generation) и перезаписывает существующую запись на месте, одновременно освежая копию объекта в памяти. Вторая операция потому разбирает уже обёрнутый поток, видит MCID 0, выделяет MCID 1 и продлевает тот же массив parent tree. Один объект — одна финальная версия. Прежде чем хоть что-то вернётся, вывод перечитывается и сверяется с моделью в памяти: число элементов, число контентных элементов и каждый удержанный MCR или OBJR обязаны совпасть, иначе вызов проваливается без вывода

Сжатые потоки идут тем же путём. Content stream с /Filter /FlateDecode (или одноэлементным массивом [/FlateDecode]) и без предиктора /DecodeParms распаковывается ограниченным декодером с потолком 32 МиБ или MaxOutputBytes, что меньше, оборачивается и пережимается обратно в /FlateDecode с новым /Length. Поток, распаковавшийся бы за потолок, отвергается, а не обрезается, что важно для файлов из недоверенных источников

Пределы, о которых стоит знать перед ремонтным прогоном PDF/UA

EditPdfDocumentStructure консервативен по дизайну, и большинство его пределов проявляется ясным Report.ErrorMessage, а не тихо испорченным файлом. Держите в голове при планировании пакетного ремонта:

  • Оборачиваются только нефильтрованные и обычные потоки FlateDecode; прочие фильтры, либо Flate с предиктором, отвергаются
  • Content stream обязан принадлежать /Contents ровно одной страницы, а Form XObject, аннотации и XObject обязаны использоваться ровно одной страницей
  • Обёртка всегда использует тег /P; семантическая роль приходит из /S элемента структуры, а не из имени тега BDC
  • Синхронизация parent tree читает плоский корень /Nums или один уровень листьев /Kids; более глубокие промежуточные узлы не обходятся, поэтому такие файлы после правки стоит перевалидировать
  • Файлы с подписями или полями подписей отвергаются начисто, так что подписывайте после починки структуры, а не до

Шаг валидации для ремонтного конвейера не опционален. Прогоните валидацию дерева структуры PDF/UA в PDFium Component до и после пакета, а API marked content объектов страницы для чтения тегов BDC и MCID используйте, когда надо подтвердить, какие объекты страницы оказались внутри новых меток. Чистое parent tree говорит вам, что поиски разрешаются; оно не говорит, что порядок чтения или альтернативный текст верны

FAQ

Перестраивает ли EditPdfDocumentStructure весь /ParentTree?

Нет. EditPdfDocumentStructure обновляет только записи для контента, который тегирует в текущем пакете, переиспользуя существующие ключи /StructParents и выделяя новые по необходимости, так что существующие записи parent tree остаются нетронутыми

Что будет, если у PDF есть дерево структуры, но нет /ParentTree?

Компонент создаёт косвенное числовое дерево << /Nums [] >>, привязывает его от /StructTreeRoot и вставляет первый ключ для тегируемого контента

Можно ли тегировать контент в Form XObject, разделяемом несколькими страницами?

Не этой операцией. Разделяемая форма делает /StmOwn неоднозначным, поэтому psekAddMarkedContent требует, чтобы форма ссылалась ровно одной страницей, и сообщает об ошибке в противном случае

Чтение и правка дерева структуры, валидация PDF/UA и доступ к marked content — часть PDFium Component for Delphi and C++Builder, оборачивающего PDFium для проектов на Delphi, C++Builder и Lazarus