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

Автоматическая разметка структуры доступного PDF в Delphi

Откройте сгенерированный отчёт в скринридере, и за минуту всплывут привычные отказы: «Страница 3 из 40» зачитывается на каждой странице, логотип компании объявляется как «graphic» и больше ничего, а таблица выходит одной длинной чередой чисел без имён столбцов. PDF Library for Delphi избавляет от всех трёх с помощью автоматической разметки структуры: вызовите SetAutoTagMode один раз, и обычные вызовы рисования по мере отрисовки документа порождают абзацы, заголовки, рисунки, таблицы и пропускаемые колонтитулы

Ручной путь никуда не делся. Можно обернуть каждый вызов рисования в пару BeginTag и EndTag, и для документов с необычной структурой это правильный инструмент. Но для обычного отчёта, счёта или выписки ручная разметка делает доступность вывода зависимой от того, что никто никогда не забудет закрыть пару — в каждом пути кода, который хоть что-то рисует, за всю жизнь продукта. Ставить требование соответствия на такое мы бы не стали

Что включает SetAutoTagMode

SetAutoTagMode принимает битовую маску и возвращает режим, действовавший до вызова. Каждый бит покрывает один тип содержимого:

  • AUTOTAG_TEXT (1): текст становится абзацем, либо заголовком, если он зарегистрирован
  • AUTOTAG_FURNITURE (2): колонтитулы и номера страниц становятся артефактами, которые читалка пропускает
  • AUTOTAG_FIGURE (4): нарисованное изображение становится рисунком, либо артефактом, если вы объявили его декоративным
  • AUTOTAG_TABLE (8): нарисованные таблицы переносят свои строки и ячейки в дерево структуры
  • AUTOTAG_DEFAULT (15): все четыре; значение, которое мы рекомендуем для любого нового отчётного кода

Включение режима также помечает документ как размеченный, и этот шаг важнее, чем кажется. По ISO 32000-1 §14.7.1 читалка считает файл неразмеченным, пока словарь /MarkInfo в каталоге не скажет /Marked true. Файл может нести полное, корректное дерево структуры и всё равно объявляться как вовсе не имеющий структуры, потому что отсутствует единственный флаг, велющий вспомогательным технологиям его искать. Дерево есть. Никто его не читает

Разметка текста работает по вызовам. Каждый вызов DrawText, DrawWrappedText или DrawTextBox становится одним элементом, так что абзац, нарисованный построчно пятью вызовами DrawText, доходит до читалки пятью однострочными абзацами. Если ваш код вёрстки сам переносит строки, это стоит поменять: отдайте весь абзац DrawWrappedText и позвольте одному вызову создавать один абзац

Диаграмма битовых масок SetAutoTagMode, отображающих обычные вызовы рисования Delphi в дерево структуры тегированного PDF, где флаг Marked выделен как обязательный
Каждый активный бит SetAutoTagMode направляет обычные вызовы рисования в соответствующий элемент дерева структуры
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);                          // начало в левом верхнем углу, y растёт вниз
    Lib.SetDocumentLanguage('en-US');
    Lib.SetAutoTagMode(AUTOTAG_DEFAULT);       // до любого вызова RegisterHeading
    Lib.AddStandardFont(4);

    Lib.SetTextSize(18);
    Lib.RegisterHeading(1, 'Annual service report');
    Lib.DrawText(72, 96, 'Annual service report');        // размечается как H1

    Lib.SetTextSize(11);
    Lib.DrawWrappedText(72, 130, 450,
      'Every unit installed before 2024 was inspected. ' +
      'Units that failed the torque check were serviced on site.');  // один P

    // ... остальные страницы ...

    // Проходит по всем существующим страницам, поэтому вызывайте после отрисовки страниц
    Lib.AddPageFooter('', 'Page &P of &N', 36, 1.0, 1);  // артефакт пагинации
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

Комментарий на третьей строке стоит там не зря. RegisterHeading ставит уровень заголовка в очередь, только когда разметка текста уже включена, а SetAutoTagMode при запуске сбрасывает любой ожидающий уровень. Зарегистрируйте заголовок сперва и включите режим после — и название выйдет обычным абзацем, без единой ошибки, которая об этом скажет

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

RegisterHeading задаёт уровень для следующего куска текста, и израсходовать его может только текст. Если между ними рисуется изображение, оно становится рисунком, а заголовок остаётся в очереди для текста, который следует. Очевидная реализация позволяет любому тегу поглотить ожидающий уровень — и тогда типичная последовательность «зарегистрировать заголовок раздела, нарисовать баннер, нарисовать название» выдаёт баннер, размеченный как H2, и настоящее название, пониженное до абзаца. Читалка, листающая по заголовкам, приземляется на картинку

Из работы очереди следуют ещё две вещи. Уровень заголовка цепляется к любому тексту, который пойдёт следом, что бы вы ни передали строкой в RegisterHeading: если вёрстка рисует маленький кикер «Section 3» над названием, кикер и станет заголовком. А уровни глубже шести записываются как H6, поскольку читалка ничего дальше не знает. Аргумент Title при этом делает своё дело, питая outline, который DrawTOC позже превращает в оглавление

Изображения получают то же обращение «расходуется на один элемент». RegisterFigure задаёт описание, которое несёт следующее изображение, а RegisterDecoration объявляет следующее изображение линейкой, рамкой или фоном без смысла — такое записывается как артефакт Layout (ISO 32000-1 §14.8.2.2). Каждое объявление поглощается ровно одним изображением, поэтому более позднее изображение никогда не наследует описание, написанное для предыдущего. Alt-текст, прикреплённый к неправильной картинке, хуже, чем никакой: читалка уверенно сообщает нечто ложное

Мы намеренно не пытались угадывать, какие изображения декоративные. Изображение — либо содержимое, которое читатель должен получить описанием, либо то, что он должен пропустить, и только вызывающий код знает, что именно. Худший случай — третий: изображение, нарисованное без обоих объявлений, становится рисунком без описания, что проваливает PDF/UA и заставляет скринридер остановиться, сказать «graphic» и замолчать. Если вы grep-аете код отчёта по DrawImage, над каждым попаданием должно стоять RegisterFigure или RegisterDecoration

Диаграмма PDF Library for Delphi: зарегистрированный PDF-заголовок ожидает прохождения промежуточного изображения, при этом RegisterFigure и RegisterDecoration расходуются каждый ровно на одно изображение
RegisterHeading ожидает следующий текст независимо от того, что ещё будет нарисовано в это время
Lib.RegisterHeading(2, 'Gearbox inspection');   // в очереди на следующий текст

Lib.RegisterFigure('Exploded view of the gearbox assembly');
Lib.AddImageFromFile('gearbox.png', 0);       // загружает и выбирает изображение
Lib.DrawImage(72, 160, 360, 270);             // становится размеченным Figure

Lib.RegisterDecoration;                       // бессмысленная линейка
Lib.AddImageFromFile('divider.png', 0);
Lib.DrawImage(72, 450, 360, 4);               // рисуется внутри артефакта вёрстки

Lib.DrawText(72, 480, 'Gearbox inspection');  // только теперь появляется H2

Описание заслуживает больше заботы, чем любая другая строка в документе. Незрячий читатель получает его вместо картинки, и это всё, что он получает. «График» не говорит ничего; «Квартальная выручка по регионам, восточный регион лидирует в третьем квартале» говорит то, что зрячий читатель снимает с изображения за две секунды

Таблицы: строки заголовков объявляются, а не угадываются

С включённым битом таблиц DrawTableRows записывает таблицу, её строки и ячейки в дерево структуры, поэтому читалка может сказать, в каком столбце находится значение, а не зачитывать сетку как цепочку несвязанного текста. SetTableHeaderRowCount сообщает, сколько первых строк являются заголовочными. Эти строки записываются как ячейки TH с областью действия по столбцу, что и позволяет читалке объявить «Torque» перед значением, к которому перешёл пользователь

Число строк заголовка по умолчанию — ноль, и это выбор. Первая строка таблицы обычно и есть заголовок, так что угадывание чаще всего было бы верным. Но цена неверной догадки асимметрична: строка данных, принятая за заголовок, зачитывается перед каждым значением своего столбца, что куда запутаннее таблицы вовсе без заголовков. Одна строка кода с вашей стороны дешевле такого сценария отказа

Объявление строк заголовка и их повторение на каждой странице — отдельные решения. SetTableHeaderRowCount семантичен и не меняет на странице ничего. Повторение — это вёрстка, и живёт оно в аргументе RepeatHeaderRows вызова DrawTaggedTableRows. Будь это одна настройка, запрос корректных тегов заголовков запускал бы и отрисовку повторных полос заголовков, о которых вы не просили, и вёрстка, сверенная постранично, поехала бы. Строки, переданные как RepeatHeaderRows, тоже размечаются как заголовки, так что дважды говорить одно и то же не нужно

Диаграмма PDF Library for Delphi: ячейки заголовка таблицы несут область столбцов в дереве структуры, а RepeatHeaderRows оставляет повтор при разрыве страницы решением уровня компоновки
Строки заголовков попадают в дерево структуры один раз, причём область действия столбца привязывается к каждой ячейке TH
var
  TableID: Integer;
begin
  TableID := Lib.CreateTable(40, 3);
  Lib.SetTableHeaderRowCount(TableID, 1);       // строка 1 — полоса заголовка
  Lib.SetTableCellContent(TableID, 1, 1, 'Part');
  Lib.SetTableCellContent(TableID, 1, 2, 'Torque');
  Lib.SetTableCellContent(TableID, 1, 3, 'Unit');
  // ... заполнить строки данных ...
  // Нарисовать строки 1..40 в полосе 600pt, повторяя одну строку заголовка на странице
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

Разметка вручную внутри автоматического документа

Автоматическая разметка отступает, пока тег, открытый вами вручную, ещё не закрыт. Это позволяет коду описать часть документа самому, а остальное оставить библиотеке без взаимного вложения — именно такое устройство нужно большинству реальных документов. Титульная страница и блок подписи имеют структуру, которую понимаете только вы. Двести страниц основного текста между ними — нет

Три правила сохраняют вывод валидным при любой смеси. Внутри артефакта ничего не размечается, потому что marked-content последовательность артефакта не может одновременно нести элемент структуры, и файл, который пытается, проваливает валидацию PDF/UA. Пустой текст не открывает элемент, так что случайный DrawText с пустой строкой не оставит после себя элемента, который читалка объявит пустым. А вызов библиотеки, который внутри рисует через другие вызовы рисования, открывает один тег на внешнем уровне, так что его составные части никогда не оказываются вложенными в отдельный второй элемент

После сохранения прочитайте дерево обратно, прежде чем ему доверять. API чтения индексируется с единицы, и это нас подловило: наш первый проверочный цикл шёл от 0 до StructTreeItemCount - 1, получал пустую запись в начале, никогда не добирался до последнего элемента, и некоторое время мы были уверены, что абзац пропал. Ничего не пропало. Цикл просто ошибался на единицу с обоих концов

var
  I: Integer;
begin
  // Врезка-цитата, размеченная вручную; автоматическая разметка в неё не лезет
  Lib.BeginTag('BlockQuote', '', '');
  Lib.DrawWrappedText(72, 520, 450,
    'All readings were taken with the unit at operating temperature.');
  Lib.EndTag;
  Lib.SaveToFile('report.pdf');

  // Читаем дерево обратно: индексы идут от 1 до StructTreeItemCount
  if Lib.LoadFromFile('report.pdf', '') = 1 then
    for I := 1 to Lib.StructTreeItemCount do
      Writeln(Lib.GetStructTreeItemText(I, 2), '  ',    // разрешённый тип
              Lib.GetStructTreeItemText(I, 4));         // /Alt, если есть
end;

Такая распечатка — быстрая санитарная проверка, а не вердикт. Что вы хотите увидеть: H1 перед первым абзацем, у каждого рисунка его описание и ни одного рисунка с пустым /Alt

Когда порядок отрисовки и порядок чтения расходятся

Автоматическая разметка записывает элементы в порядке отрисовки содержимого. Если код вёрстки рисует боковую панель перед основным текстом, именно этот порядок держит дерево, и именно его читает скринридер. Для документов, где визуальный порядок и порядок чтения действительно различаются, или которым нужны другие типы структуры либо язык на уровне элементов, лучше подходит ручной API разметки, а разбор tagged PDF и структуры доступности подробно описывает роли, области действия и привязки заголовков

Для страниц, которые вы не рисовали сами, — например импортированных без marked content — AutoTagPages работает постфактум: он берёт медианный размер текста по данным страницам как размер основного текста, считает заметно более крупные прогоны заголовками, а остальную живопись помечает как артефакты. Это правильный инструмент для содержимого, которое нельзя перегенерировать. Для всего, что рисует ваш собственный код, мы предпочитаем разметку в момент отрисовки: там библиотеке сообщают, что такое заголовок, вместо угадывания по размеру шрифта

Когда документ готов, прогоните его через проверяльщик вместо того чтобы предполагать: заметки о профилировании PDF/A и PDF/UA показывают, как получить вердикт по построенной структуре, а статья о экспорте отчётов на основе набора данных — куда эти вызовы встают в движке отчётов, строящем вёрстку из данных

Поскольку PDF Library for Delphi написана на нативном Pascal для Delphi, C++Builder и Lazarus, без внешнего PDF-рантайма в поставке, размеченный выход создаётся тем же кодом, что рисует страницу. Полная справка по API и список платформ — на странице продукта PDF Library for Delphi