技術記事

DelphiでアクセシブルPDFを自動タグ付けする構造ツリー生成

生成したレポートをスクリーンリーダーで開くと、お決まりの失敗は1分以内に顔を出します。40ページあるのに毎ページ「3ページ目」と読み上げられ、会社のロゴは「グラフィック」とだけ宣言され、表は列名もない数字の長い羅列として出てきます。PDF Library for Delphiは自動構造タグ付けでこの3つを回避します。SetAutoTagModeを1回呼べば、普通の描画呼び出しが、描画と同時に段落、見出し、図、表、そして読み飛ばされるべきページ飾りを生み出します

手動の経路もまだ存在します。すべての描画呼び出しをBeginTagとEndTagで包めるし、特殊な構造のドキュメントにはそれが正しい道具です。ただし普通のレポート、請求書、明細書では、手動タグ付けは「何かを描くすべてのコードパスで、プロダクトの寿命の間、誰もペアを忘れないこと」にアクセシビリティを賭けることになります。コンプライアンス要件をそこに賭ける気にはなれません

SetAutoTagModeがオンにするもの

SetAutoTagModeはビットマスクを受け取り、呼び出し前に有効だったモードを返します。各ビットは1種類のコンテンツを担当します:

  • AUTOTAG_TEXT(1):テキストは段落になり、見出しが登録されていれば見出しになる
  • AUTOTAG_FURNITURE(2):実行時ヘッダー、フッター、ページ番号は、リーダーが読み飛ばすアーティファクトになる
  • AUTOTAG_FIGURE(4):描画された画像は図になり、装飾と宣言していればアーティファクトになる
  • AUTOTAG_TABLE(8):描画された表は、行とセルを構造ツリーへ運ぶ
  • AUTOTAG_DEFAULT(15):4つすべて。新しいレポートコードにはこの値を推奨します

モードをオンにすると、ドキュメントがタグ付きとしてマークされます。このステップは見た目以上に効いてきます。ISO 32000-1 §14.7.1では、カタログの/MarkInfo辞書が/Marked trueと言わないかぎり、リーダーはファイルをタグなしとして扱います。完全で正しい構造ツリーを保持したファイルでも、支援技術に探してくれと告げるたった1つのフラグが欠ければ、構造がまったくないとアナウンスされ得ます。ツリーはある。誰も読まないだけです

テキストのタグ付けは呼び出しごとに働きます。DrawText、DrawWrappedText、DrawTextBoxの各呼び出しが1要素になるので、5回のDrawTextで行ごとに描かれた段落は、読者には1行の段落が5つとして届きます。レイアウトコードが自分で改行しているなら、変える価値があります。段落全体をDrawWrappedTextに渡し、1呼び出し1段落にさせるのです

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.');  // 1つのP

    // ... 残りのページ ...

    // 既存の全ページを走るので、ページを描き終えてから呼ぶ
    Lib.AddPageFooter('', 'Page &P of &N', 36, 1.0, 1);  // ページネーションアーティファクト
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

3行目のコメントには理由があります。RegisterHeadingはテキストタグ付けがすでにオンのときにしか見出しレベルをキューに入れず、SetAutoTagModeは走るときキューに入ったレベルを消します。先に見出しを登録してからモードをオンにすると、タイトルはただの段落として出力され、教えてくれるエラーはどこにもありません

見出しはなぜ手前の画像を待ち越えるのか

RegisterHeadingは次に描かれるテキスト断片のレベルを指定します。そしてテキストしかそれを消費できません。間に画像が描かれると、画像は図になり、見出しは後に続くテキストのためにキューに留まります。素朴な実装ではどのタグもキューのレベルを消費できるので、セクション見出しを登録し、バナー画像を描き、タイトルを描くというありふれた順序が、H2としてタグ付けされたバナーと、段落に格下げされた本物のタイトルを生みます。見出しでナビゲートしている読者は、画像に着地します

キューの仕組みから、さらに2つが従います。見出しレベルは次に来るどんなテキストにも付く、ということです。RegisterHeadingに渡した文字列は関与しません。レイアウトがタイトルの上に小さな「Section 3」のキッカーを描くなら、キッカーが見出しになります。そして6より深いレベルはH6として書かれます。リーダーはその先を知らないからです。Title引数は別の仕事を持ち、DrawTOCが後で目次に組み立てるアウトラインを養います

画像も同じ「1項目で消費される」扱いです。RegisterFigureは次の画像が運ぶ説明を供給し、RegisterDecorationは次の画像を意味を持たない罫線、枠、背景として宣言します。それはLayoutアーティファクト(ISO 32000-1 §14.8.2.2)として書かれます。各宣言はちょうど1枚の画像に消費されるので、後の画像が前の画像向けの説明を引き継ぐことはありません。間違った画像に付いたaltテキストは、ないより悪いのです。リーダーは満面の自信で嘘を述べる

どの画像が装飾かを推測するのは、意図的にやめています。画像は、読者が説明されるべきコンテンツか、読み飛ばすべきコンテンツかのどちらかで、それを知っているのは呼び出し側のコードだけです。避けるべきは3つ目のケースです。どちらの宣言もないまま描かれた画像は、説明のない図になり、PDF/UAに落ちて、スクリーンリーダーは立ち止まって「グラフィック」と言ったきりになります。レポートコードをDrawImageでgrepしたら、すべてのヒットの直上にRegisterFigureかRegisterDecorationがあるべきです

PDF Library for Delphi の図。登録済み PDF 見出しが途中の画像を挟んで待機し、RegisterFigure と RegisterDecoration がそれぞれ 1 つの画像にのみ消費される様子
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が現れる

説明は、ドキュメント中の他のどんな文字列よりも手間をかける価値があります。全盲の読者は画像の代わりにそれを得るのですが、それがすべてです。「チャート」では何も伝わりません。「地域別の四半期売上、Q3は東地域が最高」なら、晴眼の読者が画像から2秒で取るものを伝えます

表:ヘッダ行は宣言されるもので、推測されるものではない

tableビットがオンだと、DrawTableRowsは表、その行、そのセルを構造ツリーへ書き込むので、リーダーはグリッドを無関係なテキストの羅列として読み上げる代わりに、値がどの列にあるかを伝えられます。SetTableHeaderRowCountは先頭の何行がヘッダかを指定します。それらの行は列スコープ付きのTHセルとして書かれ、ユーザーが移動した値の前に「Torque」とアナウンスできるのはこの仕組みです

ヘッダ行数の既定値はゼロで、これは選択の結果です。表の最初の行はたいていヘッダなので、推測すれば大抵は当たります。しかし間違った推測の代償は一方的に重いのです。データ行をヘッダと見間違えると、その列のすべての値の前に読み上げられてしまい、ヘッダがまったくない表よりはるかに混乱させられます。1行のコードをあなたが書く方が、その失敗モードより安上がりです

ヘッダ行の宣言と、ページごとの繰り返しは、別の判断です。SetTableHeaderRowCountはセマンティクスで、ページには何も変えません。繰り返しはレイアウトで、それはDrawTaggedTableRowsのRepeatHeaderRows引数の持ち物です。2つが1つの設定だったら、正しいヘッダタグを求めるだけで、頼んでいない繰り返しヘッダ帯の描画が始まり、ページごとに検査済みのレイアウトがずれます。RepeatHeaderRowsとして渡された行もヘッダとしてタグ付けされるので、同じことを2度言う必要はありません

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の帯に描画し、ページごとにヘッダ行を1行繰り返す
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

自動ドキュメントの中の手動タグ付けセクション

手で開いたタグが開いたままの間、自動タグ付けは脇に退きます。これで、コードがドキュメントの一部を自分で記述し、残りをライブラリに任せられます。互いにネストしないので、ほとんどの実ドキュメントが望む構成です。表紙と署名ブロックは、あなたにしか分からない構造を持っています。その間に挟まれた200ページの本文は持っていません

どんな混在でも出力を有効に保つのが3つのルールです。アーティファクトの内側では何もタグ付けされません。アーティファクトのマーク済みコンテンツシーケンスは構造要素を運べず、試みたファイルはPDF/UA検証に落ちます。空のテキストは要素を開きません。空文字列のDrawTextが、リーダーに空白としてアナウンスされる要素を残すことはありません。そして内部的に他の描画呼び出しを通って描くライブラリ呼び出しは、外側のレベルで1つのタグを開くので、その断片が自分用の2つ目の要素の中にネストされることはありません

保存したら、信じる前にツリーを読み戻してください。読み取りAPIは1ベースで、これには痛い目を見ました。最初のチェックループは0からStructTreeItemCount - 1まで回り、先頭で空レコードを取って最後の要素に届かず、しばらくの間、段落が1つ消えたと確信していました。何も消えていない。ループが両端で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を持つ図が1つもないことです

描画順と読み上げ順が分かれるとき

自動タグ付けは、コンテンツが描かれた順序で要素を記録します。レイアウトコードが本文より先にサイドバーを描くなら、ツリーが保持するのはその順序で、スクリーンリーダーが読むのもその順序です。視覚順と読み上げ順が本気で異なるドキュメントや、他の構造タイプや要素ごとの言語が必要なドキュメントでは、手動タグ付けAPIの方が適任です。タグ付きPDFとアクセシビリティ構造の解説が、ロール、スコープ、ヘッダ結合を詳しく扱っています

自分で描いていないページ、たとえばマーク済みコンテンツを持たない取り込まれたページには、AutoTagPagesが後付けで働きます。与えられたページ群のテキストサイズの中央値を本文とみなし、目立って大きい文字列の連なりを見出しとし、その他の描画をアーティファクトにします。再生成できないコンテンツには正しい道具です。自分のコードが描くものには、描画時点でのタグ付けを選びます。そこではライブラリは、フォントサイズからの推測ではなく、何が見出しかを告げられるからです

ドキュメントが完成したら、思い込みではなくチェッカーに通します。PDF/AとPDF/UAのプレフライトのノートは、生成した構造への評定の出し方を示し、データセット駆動のレポートエクスポートの解説は、データからレイアウトを組み立てるレポートエンジンのどこにこれらの呼び出しが座るかを扱っています

PDF Library for DelphiはDelphi、C++Builder、Lazarus向けのネイティブPascalで書かれ、出荷する外部PDFランタイムを持たないので、タグ付き出力はページを描くのと同じコードから来ます。完全なAPIリファレンスとプラットフォーム一覧は、PDF Library for Delphi製品ページにあります