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 — сам элемент структуры, а не массив
Формат контейнера — универсальное числовое дерево из §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 продвигается до нового ключа плюс один
Новая пара вставляется в /Nums по возрастанию, а не дописывается, потому что поиски в числовом дереве полагаются на тот порядок. Если лист несёт /Limits, границы пересчитываются из переписанного массива. Когда у корня дерева структуры нет вовсе /ParentTree — обычное дело в файлах, тегированных инструментами, писавшими лишь прямое дерево, — EditPdfDocumentStructure синтезирует новый косвенный объект << /Nums [] >> и привязывает его от /StructTreeRoot прежде вставки первой записи
Form XObject, /StmOwn и ссылки на объекты
Marked content внутри Form XObject поддерживается, только когда та форма используется ровно одной страницей. MCR, который EditPdfDocumentStructure пишет для формы, несёт /Pg, /Stm, называющий поток формы, и /StmOwn, называющий страницу, а ключ /StructParents ложится на словарь формы, а не страницы. Форма, нарисованная тремя страницами, имеет трёх правдоподобных владельцев, поэтому любое единственное /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