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

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

Размеченное содержимое (marked content) — механизм, определяемый в ISO 32000-1 §14.6 для разметки содержимого страницы, и размеченный PDF, и PDF/UA оба построены на нём. PDFium Component предоставляет его напрямую: PageObjectMarks читает каждый тег BDC и его список свойств с объекта страницы, AddPageObjectMark записывает такой тег, RemovePageObjectMark удаляет, а PageObjectMarkedContentID сообщает MCID, связывающий содержимое с деревом структуры

Пока дерево структуры нельзя связать обратно с описываемым им содержимым, инструменты доступности остаются гаданием. Дерево структуры говорит: «это заголовок»; MCID говорит, какими метками на какой странице этот заголовок является. Обе половины должны быть читаемы, прежде приложение сможет проверять, восстанавливать или отчитываться о разметке

Что такое метка в байтах?

Оператор BDC с именем тега и опциональным списком свойств, закрываемый EMC. В потоке содержимого это выглядит как /P <</MCID 3>> BDC ... EMC: тег /P называет роль, словарь несёт свойства, а всё между операторами и есть размеченное содержимое. Объект страницы внутри этого span несёт метку — именно его возвращает PDFium и во что PDFium Component превращает в запись

TPdfContentMark хранит дескриптор, имя тега Name и массив TPdfContentMarkParam. Каждый параметр имеет Key, Kind и одно значимое поле значения, выбираемое этим типом: pmpInt, pmpFloat, pmpString или pmpBlob. Тип приходит из собственного отчёта PDFium, а не от того, какой getter случайно удался, — и в этом разница между чтением списка свойств и гаданием по нему

var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber is 1-based
  for I := 0 to Pdf.ObjectCount - 1 do    // page object indexes are 0-based
  begin
    Marks := Pdf.PageObjectMarks(I);
    for M in Marks do
    begin
      Memo1.Lines.Add('mark ' + M.Name +
        ' (MCID ' + IntToStr(Pdf.PageObjectMarkedContentID(I)) + ')');
      for P in M.Params do
        case P.Kind of
          pmpInt:    Memo1.Lines.Add('  ' + P.Key + ' = ' + IntToStr(P.IntValue));
          pmpString: Memo1.Lines.Add('  ' + P.Key + ' = ' + P.StringValue);
          pmpFloat:  Memo1.Lines.Add('  ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
          pmpBlob:   Memo1.Lines.Add('  ' + P.Key + ' = ' +
                       IntToStr(Length(P.BlobValue)) + ' bytes');
        end;
    end;
  end;
end;

Почему pmpUnknown означает две разные вещи

pmpUnknown возвращается, когда PDFium сообщает FPDF_OBJECT_UNKNOWN, а PDFium также возвращает его для ключа, которого не существует. Эти два случая на этом слое неразличимы, и делать вид иначе хуже, чем об этом сказать

Практическое следствие для вашего кода: трактуйте pmpUnknown как «здесь нет пригодного значения», а не как тип, который вы могли бы декодировать и так. Если свойство значимо для вашего рабочего процесса, проверьте его присутствие с типом, который вы распознаёте, и не выводите отсутствие из неизвестного: метка, чей список свойств вы не можете прочитать, — это метка, о которой стоит отчитаться, а не та, что стоит молча принять

Запись метки — снимок, а не дескриптор, которым вы владеете

Поле Handle принадлежит библиотеке. Оно становится устаревшим в тот момент, когда метка удаляется, объект страницы уничтожается или страница выгружается, поэтому запись — снимок только для чтения с короткой жизнью. Кэшируйте его через переключение страницы — и вы держите указатель в память, которую движок уже reclaimed

Это та же дисциплина, что применяется к дескрипторам объектов страницы вообще в PDFium, и ловит она в том же месте: список, заполненный записями меток, пользователь уходит на другую страницу и сбой, выглядящий не связанным с навигацией. Копируйте нужные вам значения — имя, ключи, числа — и отпускайте дескриптор. Заметки о устаревании дескрипторов объектов страницы после трансформации описывают общее правило и то, как оно кусает в других местах

Добавление метки и легко упускаемый шаг сохранения

AddPageObjectMark принимает индекс объекта страницы, имя тега и полный набор параметров. Параметры записываются как набор, а не патчатся по одному ключу, поэтому у TPdfContentMarkParam нет Has*-сигналов — случай «обновить одно поле существующей записи», который они охраняли бы, не возникает

То, о чём стоит сказать прямо: добавление метки перестраивает поток содержимого страницы, чтобы тег пережил сохранение. Это пришлось сделать явным, потому что SaveAs сам по себе не перегенерирует содержимое — изменение, живущее лишь в объектной модели, было бы отброшено, а сохранённый файл выглядел бы точно как тот, с которого вы начали. Если вы когда-либо добавляли что-то на страницу PDFium и обнаруживали, что этого нет в выводе, обычно причина в этом

var
  Params: TPdfContentMarkParams;
begin
  SetLength(Params, 1);
  Params[0].Key := 'MCID';
  Params[0].Kind := pmpInt;
  Params[0].IntValue := NextMcid;
  Pdf.AddPageObjectMark(ObjectIndex, 'P', Params);   // rebuilds the content stream
  Pdf.UpdatePage;
  Pdf.SaveAs('tagged-out.pdf');
end;

Что это даёт и не даёт документу

Одни метки не делают размеченный PDF. Соответствующий размеченный документ требует дерева структуры, чьи элементы ссылаются на эти MCID, записи /MarkInfo, объявляющей документ размеченным, и имена ролей, значащих то, что говорит стандарт. Запись /P с MCID, на который не указывает ни один элемент структуры, даёт вам содержимое, заявляющее себя размеченным, и дерево структуры, которое о нём никогда не упоминает

Где размеченное содержимое на этом уровне по-настоящему окупается — инспекция и восстановление: аудит того, какие объекты страницы размечены, поиск артефактов, которые должны были быть помечены как таковые, или сопоставление MCID с деревом структуры для поиска сирот. Для половины дерева структуры в этой работе см. разбор валидации дерева структуры PDF/UA, а для того опыта чтения, ради которого метки в конечном счёте существуют, — заметки о построении доступной читалки PDF в Delphi

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