В размеченном PDF колонтитул в верхней части страницы обычно сидит внутри /Artifact <</Type /Pagination>> BDC … EMC, а первый абзац под ним — внутри /P <</MCID 0>> BDC … EMC. Оба — размеченное содержимое в смысле ISO 32000-1 §14.6. До абзаца можно добраться из дерева структуры, потому что только абзац несёт идентификатор размеченного содержимого, и именно это целое число связывает отрисованные глифы с элементом структуры вроде /H1 или /P. Поэтому код, который аудирует или чинит разметку, обязан с каждого объекта страницы прочитать три вещи (тег, его список свойств и MCID), а затем сверить число с MCID, которые элементы структуры на этой странице реально заявляют
Чтение меток BDC и списков свойств из Delphi
В PDFium Component метод TPdf.PageObjectMarks(Index) возвращает все метки, окружающие объект страницы с индексом Index, в виде массива записей TPdfContentMark. Каждая запись несёт тег в Name, массив Params со списком свойств и Handle. Имя приходит без ведущей косой черты: последовательность /P сообщается как P, артефакт — как Artifact. Последовательности размеченного содержимого могут вкладываться, и прогон глифов внутри /Sect, затем /P, затем /Span вернёт три записи на одном объекте — от внешней к внутренней
ISO 32000-1 §14.6.2 допускает для операнда-списка свойств BDC две формы: инлайн-словарь, как в /P <</MCID 3>> BDC, или имя вроде /OC /oc1 BDC, которое указывает в подсловарь /Properties ресурсов страницы. Необязательное содержимое (§8.11.3.2) записывается второй формой. PDFium разрешает именную форму при разборе страницы, так что в обоих случаях Params перечисляет записи словаря, который действует для данной последовательности
TPdf.PageObjectMarkedContentID(Index) отвечает на вопрос, который волнует дерево структуры. Он возвращает MCID, найденный в метках объекта, или -1, если ни одна из них не несёт целочисленный /MCID. Колонтитул из вводного примера вернёт -1, и это корректно: §14.8.2.2 держит артефакты вне логической структуры, поэтому артефакту не с чем создавать связь
var
Marks: TPdfContentMarks;
M: TPdfContentMark;
P: TPdfContentMarkParam;
I: Integer;
begin
Pdf.PageNumber := 1; // PageNumber нумеруется с 1
for I := 0 to Pdf.ObjectCount - 1 do // индексы объектов страницы — с нуля
begin
Marks := Pdf.PageObjectMarks(I);
if Length(Marks) = 0 then
Continue;
Memo1.Lines.Add(Format('object %d, MCID %d',
[I, Pdf.PageObjectMarkedContentID(I)]));
for M in Marks do // сначала самая внешняя метка
begin
Memo1.Lines.Add(' mark ' + M.Name);
for P in M.Params do
case P.Kind of
pmpInt: Memo1.Lines.Add(' ' + P.Key + ' = ' + IntToStr(P.IntValue));
pmpFloat: Memo1.Lines.Add(' ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
pmpString: Memo1.Lines.Add(' ' + P.Key + ' = ' + P.StringValue);
pmpBlob: Memo1.Lines.Add(' ' + P.Key + ' = ' +
IntToStr(Length(P.BlobValue)) + ' bytes');
pmpUnknown: Memo1.Lines.Add(' ' + P.Key + ' = (no readable value)');
end;
end;
end;
end;
Что pmpUnknown может и не может вам сказать
У каждой записи TPdfContentMarkParam есть Key, Kind и одно поле значения, выбираемое этим типом. Отображение достаточно короткое, чтобы держать его в голове:
- Число с целым значением становится
pmpInt, с заполненнымIntValueи тем же числом вFloatValue, так что и/MCID 3, и случайное3.0читаются как целые - Число с дробной частью становится
pmpFloat - Строки PDF и имена PDF оба становятся
pmpString; текстовые строки, начинающиеся с метки порядка байтов UTF-16 (FE FFилиFF FE), декодируются вStringValue, так что/ActualTextприходит как читаемый текст - Прочие типы значений возвращаются как
pmpBlob, когда PDFium отдаёт для них сырые байты, и остаютсяpmpUnknownв противном случае
pmpUnknown покрывает два случая, которые различить нельзя. Первый — значение, о котором PDFium сообщает FPDF_OBJECT_UNKNOWN и для которого не отдаёт байтов. Второй — ключ, который в списке есть, но обратно не читается: ключ, записанный не-ASCII байтами в повреждённом файле, может присутствовать в списке и всё равно возвращаться как pmpUnknown. Запись в обоих случаях выглядит одинаково
Считайте pmpUnknown сигналом «пригодного значения нет» и сообщайте о метке, а не принимайте её молча. Если ваш рабочий процесс зависит от наличия /Lang или /ActualText, ищите ключ с распознаваемым вами типом; никогда не заключайте из неизвестного типа, что свойства нет
Handle идентифицирует метку внутри текущей загруженной страницы и устаревает, как только метка удалена, объект страницы уничтожен или страница выгружена. Копируйте Name и Params в собственные структуры, прежде чем переключать страницы. Для данных, которые должны пережить текущую страницу, — например, для списка или фонового аудита — ReadPageObjectsSnapshot возвращает записи TPdfContentMarkSnapshot, хранящие только имя и параметры, без какого-либо дескриптора. То же правило относится к дескрипторам объектов страницы в целом, как описано в заметках о устаревании дескрипторов объектов страницы после трансформации
Добавление, изменение и удаление меток
Редактирование покрывается четырьмя вызовами. AddPageObjectMark(Index, Name, Params) создаёт на объекте новую метку, применяет параметры по порядку и возвращает индекс новой метки на этом объекте — его остальные три вызова принимают как MarkIndex. Имена передаются без косой черты: 'P', 'Span'. Чтобы изменить один ключ, SetPageObjectMarkParam(Index, MarkIndex, Param) вставляет или заменяет его и не трогает остальные параметры метки. Удаления более грубые: RemovePageObjectMarkParam(Index, MarkIndex, Key) выбрасывает ключ, RemovePageObjectMark(Index, MarkIndex) — всю метку
AddPageObjectMark обращается с меткой и её параметрами как с одним шагом. Если параметр отвергнут (тип pmpUnknown, пустой ключ, пустой блоб), только что созданная метка удаляется обратно до проброса исключения, поэтому объект никогда не остаётся с тегом и половиной списка свойств
Каждый из этих вызовов пересобирает поток содержимого страницы перед возвратом. Это важно, потому что SaveAs пишет поток содержимого как он есть и не перегенерирует его из объектной модели PDFium. Метка, существовавшая только в памяти, оставила бы сохранённый файл без единого оператора BDC. Если вы правите объекты страницы ещё и через сырые импорты PDFium на ObjectHandle, сами вызовите UpdatePage перед сохранением
Пересборка всего потока при каждом вызове нормальна для нескольких правок и расточительна для нескольких сотен. Разметка неразмеченного отчёта страница за страницей — работа второго рода, и там правкам место внутри BeginPageObjectEditTransaction и CommitPageObjectEditTransaction. Внутри транзакции каждая правка лишь помечает страницу как изменённую; фикс один раз генерирует поток содержимого, а RollbackPageObjectEditTransaction возвращает страницу как была. Транзакция привязана к одной странице, и установка PageNumber на другую страницу при открытой транзакции вызывает исключение. На подписанном документе вызыва начала откажется стартовать, если не передать AllowSignedDocument = True, — правка содержимого страницы всё равно ломает подпись
Строковые параметры требуют ещё одного решения. Значения pmpString записываются как байтовые строки, что идеально для ASCII-значений вроде /Lang (en-US). Замещающий текст с акцентами, лигатурами или CJK-символами должен лежать в PDF-текстовой строке в кодировке UTF-16BE с префиксом FE FF (§7.9.2.2). Передавайте эти байты как pmpBlob: PDFium сохранит их строковым объектом, а читатель выше декодирует их обратно в StringValue как pmpString
// Ваш собственный помощник, не часть библиотеки: PDF-текстовая строка как UTF-16BE с BOM
function Utf16TextString(const S: string): TBytes;
var
I: Integer;
begin
SetLength(Result, 2 + Length(S) * 2);
Result[0] := $FE;
Result[1] := $FF;
for I := 1 to Length(S) do
begin
Result[I * 2] := Byte(Ord(S[I]) shr 8);
Result[I * 2 + 1] := Byte(Ord(S[I]));
end;
end;
procedure TagWord(Pdf: TPdf; ObjectIndex, Mcid: Integer; const Spoken: string);
var
Params: TPdfContentMarkParams;
Lang: TPdfContentMarkParam;
MarkIndex: Integer;
begin
Pdf.BeginPageObjectEditTransaction;
try
SetLength(Params, 2);
Params[0].Key := 'MCID';
Params[0].Kind := pmpInt;
Params[0].IntValue := Mcid;
Params[1].Key := 'ActualText';
Params[1].Kind := pmpBlob; // хранится как текстовая строка, читается обратно как pmpString
Params[1].BlobValue := Utf16TextString(Spoken);
MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);
Lang := Default(TPdfContentMarkParam);
Lang.Key := 'Lang';
Lang.Kind := pmpString; // значение ASCII — подойдёт обычная байтовая строка
Lang.StringValue := 'de-DE';
Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
except
Pdf.RollbackPageObjectEditTransaction;
raise;
end;
Pdf.CommitPageObjectEditTransaction; // одна пересборка потока содержимого на всю партию
end;
Фикс сидит вне блока try намеренно. Если генерация содержимого падает, CommitPageObjectEditTransaction сам восстанавливает страницу перед повторным возбуждением исключения, а второй вызов отката лишь поднял бы «No page-object edit transaction is active» поверх настоящей ошибки
Почему у одного абзаца оказывается три последовательности с MCID 5
Потому что каждый вызов AddPageObjectMark создаёт новый, независимый словарь метки, даже когда значения идентичны. Абзац, свёрстанный тремя текстовыми объектами — по одному на строку, — размеченный тремя вызовами с одним и тем же /MCID 5, несёт три отдельные метки. Перегенерируя поток содержимого, PDFium держит соседние объекты внутри одной пары BDC/EMC только когда они разделяют одну метку; он сравнивает идентичность метки, и равные значения не в счёт. Сохранённая страница тогда содержит /P <</MCID 5>> BDC … EMC три раза подряд
ISO 32000-1 §14.7.4.2 описывает MCID как идентификатор, уникальный для последовательности размеченного содержимого внутри её потока содержимого. Три последовательности с одним идентификатором этому не соответствуют, и инструментам, которые разрешают MCID через запись parent tree страницы, остаётся гадать, какую из трёх имеет в виду элемент структуры
SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) подставляет целевому объекту собственный словарь свойств исходной метки вместо построения копии. Добавьте метку один раз первой строке, разделите её с остальными и держите объекты соседними в порядке отрисовки — перегенерированный поток тогда обернёт все три строки одной последовательностью
procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: индексы объектов строк абзаца, соседние в порядке отрисовки
var
Params: TPdfContentMarkParams;
MarkIndex, I: Integer;
begin
SetLength(Params, 1);
Params[0].Key := 'MCID';
Params[0].Kind := pmpInt;
Params[0].IntValue := Mcid;
Pdf.BeginPageObjectEditTransaction;
try
MarkIndex := Pdf.AddPageObjectMark(LineObjects[0], 'P', Params);
for I := 1 to High(LineObjects) do
Pdf.SharePageObjectMark(LineObjects[I], LineObjects[0], MarkIndex);
except
Pdf.RollbackPageObjectEditTransaction;
raise;
end;
Pdf.CommitPageObjectEditTransaction;
end;
У разделения есть и второе преимущество, когда вы копируете существующую метку на новое содержимое, например расширяя артефакт на перерисованную линейку колонтитула. Типы параметров, которые умеет писать PDFium, — целое, дробное, строка и байтовый блоб, отдельного типа для имён нет. /Artifact <</Type /Pagination>>, прочитанный как pmpString и пересобранный через AddPageObjectMark, выйдет с (Pagination) — строкой там, где §14.8.2.2 ожидает имя /Pagination. Разделённая метка — это исходный словарь, поэтому её имена остаются именами. Всякий раз, когда исходная метка уже есть на странице, разделяйте её; пересобирайте из Params только когда пишете метку, которой никогда не существовало
Сверка MCID с деревом структуры
MCID на объекте страницы что-то значит, только когда элемент структуры на той же странице его заявляет. StructureElementsForPage[PageNumber] возвращает элементы страницы как записи TPdfStructureElement, и три поля несут MCID: MarkedContentID (-1, когда у элемента его нет), MarkedContentIDs для элементов с несколькими последовательностями и ChildMarkedContentIDs, где в слотах детей-элементов, а не размеченного содержимого, стоит -1. Аудит обязан смотреть все три, потому что запись /K элемента структуры может смешивать дочерние элементы и ссылки на размеченное содержимое в одном массиве
Приведённый ниже аудит сообщает о двух находках. Текст или изображения, чей MCID не заявлен ни одним элементом, либо вовсе без MCID — это содержимое, которое ассистивные технологии пропустят. Artifact, чей MCID кем-то заявлен, прямо противоречит §14.8.2.2. Объекты страницы берутся из ReadPageObjectsSnapshot, поэтому результаты не держат дескрипторов и переживают переключение страницы
// Ваш собственный помощник, не часть библиотеки
function McidClaimed(const Elements: TPdfStructureElements; Mcid: Integer): Boolean;
var
E: TPdfStructureElement;
Id: Integer;
begin
for E in Elements do
begin
if E.MarkedContentID = Mcid then
Exit(True);
for Id in E.MarkedContentIDs do
if Id = Mcid then
Exit(True);
for Id in E.ChildMarkedContentIDs do
if Id = Mcid then
Exit(True);
end;
Result := False;
end;
procedure AuditPage(Pdf: TPdf; PageNo: Integer; Log: TStrings);
var
Snap: TPdfPageObjectsSnapshot;
Status: TPdfPageObjectSnapshotStatus;
Elements: TPdfStructureElements;
Obj: TPdfPageObjectSnapshot;
IsArtifact: Boolean;
J: Integer;
begin
Snap := Pdf.ReadPageObjectsSnapshot(PageNo,
TPdfPageObjectSnapshotOptions.Full, Status);
if not Status.Completed then
Log.Add(Format('page %d: snapshot hit a limit, results are partial', [PageNo]));
Elements := Pdf.StructureElementsForPage[PageNo];
for Obj in Snap.Objects do
begin
IsArtifact := False;
for J := 0 to High(Obj.Marks) do
if Obj.Marks[J].Name = 'Artifact' then
IsArtifact := True;
if IsArtifact then
begin
if (Obj.MarkedContentID >= 0) and McidClaimed(Elements, Obj.MarkedContentID) then
Log.Add(Format('object %d: artifact claimed by the structure tree', [Obj.Index]));
end
else if Obj.ObjectType in [otText, otImage] then
if (Obj.MarkedContentID < 0) or not McidClaimed(Elements, Obj.MarkedContentID) then
Log.Add(Format('object %d: content with no structure owner', [Obj.Index]));
end;
end;
Пути намеренно пропущены. Декоративные линейки и границы таблиц часто не размечены, и флагование каждой из них похоронит текстовые находки. С Form XObjects осторожность обратная: MCID внутри формы принадлежит собственному пространству имён /StructParents этой формы, так что MCID 0 на странице и MCID 0 внутри формы — разные последовательности. Разбор пространств имён MCID в тегированных Form XObjects рассказывает, как читать их правильно
Метки — по-прежнему лишь половина размеченного документа. ISO 32000-1 §14.7 требует дерева структуры, чьи элементы ссылаются на эти MCID, словаря /MarkInfo в каталоге с /Marked true и типов структуры либо стандартных, либо отображённых на стандартные через карту ролей. Pdf.IsTagged сообщает, объявляет ли каталог документ размеченным. Метка /P, чей MCID не указан ни одним элементом, даёт содержимое, заявляющее о разметке на странице, о которой дерево структуры никогда не упоминает
Для стороны дерева структуры в этой работе разбор валидации дерева структуры PDF/UA подхватывает там, где аудит MCID останавливается. А опыт чтения, ради которого существуют теги, — предмет заметок о построении доступной читалки PDF в Delphi
Использованные здесь вызовы (чтение меток, транзакции правки, разделённые метки, снимки объектов страницы и элементы структуры) — часть того же VCL API, который PDFium Component даёт проектам Delphi, C++Builder и Lazarus над движком PDFium. Остальное API доступности и правки страниц описывает страница продукта PDFium Component