技術記事

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

PDFlibPasはドキュメントを描画しながらタグ付けできる。SetAutoTagModeをオンにすると、通常のDrawText呼び出しは段落になり、RegisterHeadingの直後に描かれたテキストはそのレベルの見出しになり、ヘッダやフッタの繰り返し部分はリーダーが読み飛ばすアーティファクトになり、画像は図になり、DrawTableRowsは表全体を行やセルとともに構造ツリーへ運ぶ

これ以外の方法——つい最近まで唯一の選択肢だった方法——は、すべての描画呼び出しを手作業でBeginTagEndTagで包むことだった。これでも動くし、特殊な構造を持つドキュメントでは今でも正しい道具だ。だが標準的なレポートや請求書、明細書では、出力のアクセシビリティが「何かを描画するすべてのコードパスで、誰もペアを忘れない」ことに依存することを意味する

モードビットが何をカバーするのか

SetAutoTagModeはビットマスクを受け取り、それまで有効だったモードを返す。AUTOTAG_TEXT(1)はテキストを段落としてタグ付けし、見出しの順番が来ていれば見出しとして扱う。AUTOTAG_FURNITURE(2)はヘッダ、フッタ、ページ番号をアーティファクトとしてマークする。AUTOTAG_FIGURE(4)は描画された画像を図に変換し、装飾として宣言されていればアーティファクトにする。AUTOTAG_TABLE(8)は描画された表を構造ツリーへ運ぶ。AUTOTAG_DEFAULTは15、つまり4つすべてだ

モードをオンにすると同時に、ドキュメントをタグ付きとしてマークする。このステップは見た目以上に重要だ。カタログが別の宣言をしていない限り、リーダーはドキュメントをタグなしと見なす(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は次に描画されるテキストのレベルを指定し、テキストが来るのを待つ。間に画像が描かれれば、その画像は図になり、見出しはその後に続くテキストのために保留され続ける。この挙動は意図的なものだ。仮に画像が見出しレベルを取得する仕様だったとすると、タイトルの下の装飾罫線がタイトルとしてアナウンスされるドキュメントを生んでいただろう

同じ「1項目で消費される」ルールが図にも適用される。RegisterFigureは次の画像が持つ説明を供給し、RegisterDecorationは次の画像を意味を持たない罫線、枠、背景として宣言する。どちらも1枚の画像で消費されるので、後の画像が前の画像向けの説明を引き継ぐことはない。手作業でタグ付けしたコードでaltテキストが誤った画像に付いてしまうのは、典型的にこのルールが欠けているからだ

説明は、アクセシブルドキュメントの中で他のどんな1文字列よりも重要だ。全盲の読者は画像の代わりに説明を得るが、それが彼らが得るすべてだ。「グラフ」は説明ではない、「地域別四半期売上、Q3が東地域で最高」といった説明が説明だ

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

表、ヘッダ、そして繰り返しの判断をどこで行うのか

tableビットをオンにすると、DrawTableRowsは表、その行、そのセルを構造ツリーへ運ぶ。これによりリーダーは、表全体を無関係なテキストの羅列として読み上げる代わりに、値がどの列にあるかを伝えられるようになる。SetTableHeaderRowCountは先頭から何行がヘッダかを指定する。それらの行は列スコープを持つヘッダセルとして書き込まれ、リーダーがユーザーが今いる値の見出しをアナウンスできるのはこの仕組みのおかげだ

こうして指定されたヘッダ行は、その場所に留まる。各ページの先頭で繰り返すかどうかはレイアウトの判断であり、そうであり続ける。DrawTaggedTableRowsはまさにその目的でRepeatHeaderRows引数を取る。この2つを分けておくことで、自動繰り返しが生んでしまう「ページ分割のたびに構造ツリーにヘッダの複製が2つ目、3つ目と積もる」事態を避けられる

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;

自動タグ付けと手動タグ付けの混在

自動タグ付けは、手作業で開かれたタグの内側では脇に退く。ドキュメントの一部をコードで記述し、残りをライブラリに任せるという構成が、両者が互いにネストすることなく成り立つ。これこそ、ほとんどの実ドキュメントが求める配置だ。表紙と署名ブロックはあなたにしか分からない構造を持つが、その間に挟まった200ページの本文はそうではない

2つの安全ルールが出力を清浄に保つ。アーティファクトの内側では何もタグ付けされない。アーティファクトとしてマークされたコンテンツは構造要素を持ってはならないからだ。そして空のテキストは要素を開かないので、空文字列を渡したDrawTextが、リーダーに空だとアナウンスされる構造要素を生むことはない。どちらも、手作業でタグ付けされたドキュメントが静かに蓄積し、バリデータが何カ月もあとに一括で報告する種類の欠陥だ

自動タグ付けがまだ代わりに決めてくれないこと

描画順を超えた読み上げ順序、段落・見出し・図・表以外のセマンティックロール、そして言語宣言。自動タグ付けはコンテンツが描かれた順序で構造を割り当てる。つまりレイアウトコードが本文より先にサイドバーを描けば、ツリーが記録するのはその順序だ。視覚的な順序と読み上げ順序が本気で異なるドキュメントでは、手動タグ付けAPIが引き続き正しい道具であり、タグ付きPDFとアクセシビリティ構造の解説がロール、スコープ、ヘッダ結合を詳しく扱っている

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

PDFlibPasはDelphi、C++Builder、Lazarus向けのネイティブPascal PDFライブラリで、外部PDFランタイムを持たない。だからアクセシブルな出力は、ドキュメントを描画するのと同じコードから生み出される。完全なAPIとプラットフォーム一覧はPDFlibPas製品ページを参照のこと