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

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

PDFlibPas умеет размечать документ прямо во время отрисовки. Включите SetAutoTagMode, и обычные вызовы DrawText становятся абзацами, текст, нарисованный сразу после RegisterHeading, становится заголовком соответствующего уровня, колонтитулы превращаются в артефакты, которые читалка пропускает, изображения становятся рисунками, а DrawTableRows переносит таблицу, её строки и ячейки в дерево структуры

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

Что покрывают биты режима

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

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

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetAutoTagMode(AUTOTAG_DEFAULT);   // text + furniture + figures + tables
    Lib.AddStandardFont(4);
    Lib.SetTextSize(18);
    Lib.RegisterHeading(1, 'Annual service report');
    Lib.DrawText(72, 96, 'Annual service report');   // becomes H1
    Lib.SetTextSize(11);
    Lib.DrawText(72, 130, 'Every unit installed before 2024 was inspected.');
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

Откуда заголовок знает, к какому тексту он относится?

RegisterHeading задаёт уровень для следующего нарисованного текста и ждёт именно текста. Если между ними рисуется изображение, оно становится рисунком, а заголовок остаётся в ожидании текста, который последует. Такое поведение намеренно: альтернатива, в которой изображение принимает уровень заголовка, порождала документы, где декоративная линейка под заголовком зачитывалась как сам заголовок

То же правило «расходуется на один элемент» управляет и рисунками. RegisterFigure задаёт описание, которое несёт следующее изображение, а RegisterDecoration объявляет следующее изображение линейкой, рамкой или фоном, не несущими смысла. Оба вызова поглощаются одним изображением, поэтому более позднее изображение никогда не наследует описание, предназначенное предыдущему — именно так альтернативный текст в ручной разметке оказывается прикреплён к неправильной картинке

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

Lib.RegisterFigure('Exploded view of the gearbox assembly');
Lib.AddImageFromFile('gearbox.png', 0);      // becomes a tagged Figure

Lib.RegisterDecoration;                       // meaningless rule
Lib.AddImageFromFile('divider.png', 0);       // drawn inside a layout artifact

Таблицы, заголовки и где живёт решение о повторении

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

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

var
  TableID: Integer;
begin
  TableID := Lib.CreateTable(40, 3);
  Lib.SetTableHeaderRowCount(TableID, 1);       // row 1 is the header band
  Lib.SetTableCellContent(TableID, 1, 1, 'Part');
  Lib.SetTableCellContent(TableID, 1, 2, 'Torque');
  Lib.SetTableCellContent(TableID, 1, 3, 'Unit');
  // ... fill the data rows ...
  // Draw rows 1..40 into a 600pt band, repeating one header row per page
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

Смешивание автоматической и ручной разметки

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

Два защитных правила сохраняют вывод чистым. Внутри артефакта ничего не размечается, потому что содержимое, помеченное как артефакт, не должно нести элемент структуры. И пустой текст не открывает элемент, поэтому вызов DrawText с пустой строкой не может породить элемент структуры, который читалка зачитала бы как пустой. Оба дефекта — того типа, что документы с ручной разметкой накапливают незаметно, а валидатор спустя месяцы выдаёт списком

Что автоматическая разметка всё ещё не решает за вас

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

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

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