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

Чтение и запись размеченного содержимого PDF в Delphi

В размеченном 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 держит артефакты вне логической структуры, поэтому артефакту не с чем создавать связь

Диаграмма анатомии маркированного содержимого PDF в Delphi: тег /P со списком свойств MCID между BDC и EMC, поля записи TPdfContentMark и связь MCID с деревом структуры
Отметка — это тег и типизированный список свойств между BDC и EMC, возвращаемый в виде записи, чей MCID связывает её с деревом структуры
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 перед сохранением

Диаграмма добавления тега маркированного содержимого в Delphi: AddPageObjectMark перестраивает контентный поток страницы до SaveAs, тогда как изменение только в объектной модели молча теряется при сохранении файла
AddPageObjectMark перестраивает поток содержимого, поэтому SaveAs сохраняет тег, а правка, остановившаяся на модели объектов, никогда не доходит до файла

Пересборка всего потока при каждом вызове нормальна для нескольких правок и расточительна для нескольких сотен. Разметка неразмеченного отчёта страница за страницей — работа второго рода, и там правкам место внутри 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 не указан ни одним элементом, даёт содержимое, заявляющее о разметке на странице, о которой дерево структуры никогда не упоминает

Диаграмма PDFium Component: одни метки BDC не делают PDF тегированным — нужны ещё дерево структуры со ссылками на MCID, объявление MarkInfo и стандартные имена ролей
Отметки поддерживают аудит и ремонт сирот, тогда как соответствующий стандарту тегированный документ требует ещё дерева структуры, MarkInfo и стандартных имён ролей

Для стороны дерева структуры в этой работе разбор валидации дерева структуры PDF/UA подхватывает там, где аудит MCID останавливается. А опыт чтения, ради которого существуют теги, — предмет заметок о построении доступной читалки PDF в Delphi

Использованные здесь вызовы (чтение меток, транзакции правки, разделённые метки, снимки объектов страницы и элементы структуры) — часть того же VCL API, который PDFium Component даёт проектам Delphi, C++Builder и Lazarus над движком PDFium. Остальное API доступности и правки страниц описывает страница продукта PDFium Component