Technischer Artikel

Struktur-Tagging für barrierefreies PDF in Delphi

Öffnen Sie einen generierten Bericht in einem Screenreader, und die üblichen Ausfälle zeigen sich innerhalb einer Minute: „Page 3 of 40“ wird auf jeder Seite vorgelesen, das Firmenlogo wird als „Grafik“ angekündigt und dann kommt nichts, und eine Tabelle kommt als ein einziger langer Strom von Zahlen ohne Spaltennamen heraus. Die PDF Library for Delphi vermeidet alle drei mit automatischem Struktur-Tagging: Einmal SetAutoTagMode aufrufen, und gewöhnliche Zeichenaufrufe erzeugen Absätze, Überschriften, Abbildungen, Tabellen und überspringbare Seiten-Dekoration, während das Dokument gezeichnet wird

Der manuelle Weg existiert weiter. Sie können jeden Zeichenaufruf in BeginTag und EndTag verpacken, und für Dokumente mit ungewöhnlicher Struktur ist das das richtige Werkzeug. Für einen gewöhnlichen Bericht, eine Rechnung oder einen Kontoauszug bedeutet Hand-Tagging jedoch, dass die Barrierefreiheit davon abhängt, dass niemand jemals ein Paar vergisst, in jedem Codepfad, der etwas zeichnet, über die gesamte Lebensdauer des Produkts. Eine Compliance-Anforderung würde wir darauf nicht wetten

Was SetAutoTagMode einschaltet

SetAutoTagMode nimmt eine Bitmaske entgegen und liefert den vor dem Aufruf geltenden Modus zurück. Jedes Bit deckt eine Art von Inhalt ab:

  • AUTOTAG_TEXT (1): Text wird ein Absatz, oder eine Überschrift, wenn eine registriert wurde
  • AUTOTAG_FURNITURE (2): wiederkehrende Kopfzeilen, Fußzeilen und Seitennummern werden Artefakte, die ein Reader überspringt
  • AUTOTAG_FIGURE (4): ein gezeichnetes Bild wird eine Abbildung, oder ein Artefakt, wenn Sie es als dekorativ deklariert haben
  • AUTOTAG_TABLE (8): gezeichnete Tabellen tragen ihre Zeilen und Zellen in den Strukturbaum
  • AUTOTAG_DEFAULT (15): alle vier, und der Wert, den wir für jeden neuen Berichts-Code empfehlen

Das Einschalten des Modus markiert das Dokument zusätzlich als getaggt, und dieser Schritt zählt mehr, als er aussieht. Nach ISO 32000-1 §14.7.1 behandelt ein Reader eine Datei als ungetaggt, sofern das /MarkInfo-Dictionary des Catalog nicht /Marked true sagt. Eine Datei kann einen vollständigen, korrekten Strukturbaum tragen und trotzdem als völlig strukturlos angekündigt werden, weil das eine Flag fehlt, das assistiven Technologien sagt, dass sie danach suchen sollen. Der Baum ist da. Nichts liest ihn

Das Text-Tagging arbeitet pro Aufruf. Jeder DrawText-, DrawWrappedText- oder DrawTextBox-Aufruf wird ein Element, ein Absatz, der Zeile für Zeile mit fünf DrawText-Aufrufen gezeichnet wird, erreicht den Reader also als fünf Einzeilen-Absätze. Wenn Ihr Layout-Code die Zeilen schon selbst bricht, lohnt sich eine Änderung: Geben Sie den ganzen Absatz an DrawWrappedText und lassen Sie einen Aufruf einen Absatz erzeugen

Diagramm der SetAutoTagMode-Bitmasken, die einfache Delphi-Zeichenaufrufe in einen getaggten PDF-Strukturbaum abbilden, mit dem Marked-Flag als erforderlich hervorgehoben
Jedes aktive Bit von SetAutoTagMode leitet gewöhnliche Zeichenaufrufe in ein passendes Element des Strukturbaums
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);                          // Ursprung oben links, y wächst nach unten
    Lib.SetDocumentLanguage('en-US');
    Lib.SetAutoTagMode(AUTOTAG_DEFAULT);       // vor jedem RegisterHeading-Aufruf
    Lib.AddStandardFont(4);

    Lib.SetTextSize(18);
    Lib.RegisterHeading(1, 'Annual service report');
    Lib.DrawText(72, 96, 'Annual service report');        // als H1 getaggt

    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.');  // ein P

    // ... weitere Seiten ...

    // läuft über alle vorhandenen Seiten, also aufrufen, sobald die Seiten gezeichnet sind
    Lib.AddPageFooter('', 'Page &P of &N', 36, 1.0, 1);  // Pagination-Artefakt
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

Der Kommentar in der dritten Zeile steht da nicht ohne Grund. RegisterHeading queued eine Überschriftsebene nur, wenn das Text-Tagging bereits eingeschaltet ist, und SetAutoTagMode räumt jede gequeute Ebene weg, wenn es läuft. Registrieren Sie die Überschrift zuerst und schalten Sie den Modus danach ein, und der Titel kommt als schlichter Absatz heraus, ohne dass irgendein Fehler Ihnen etwas sagt

Warum wartet eine Überschrift das davor gezeichnete Bild ab?

RegisterHeading benennt die Ebene für den nächsten Text, und nur Text kann sie verbrauchen. Wird dazwischen ein Bild gezeichnet, wird das Bild eine Abbildung, und die Überschrift bleibt für den folgenden Text gequeut. Die naheliegende Implementierung lässt jedes Tag die gequeute Ebene verbrauchen, und dann produziert die gewöhnliche Sequenz – Überschrift registrieren, Banner-Bild zeichnen, Titel zeichnen – ein Banner, das als H2 getaggt ist, und einen echten Titel, der zum Absatz degradiert wurde. Ein Reader, der per Überschrift navigiert, landet auf einem Bild

Zwei weitere Dinge folgen daraus, wie die Queue arbeitet. Die Überschriftsebene hängt an dem Text, der als Nächstes kommt, egal welcher String an RegisterHeading übergeben wurde: Zeichnet Ihr Layout einen kleinen „Section 3“-Aufhänger über den Titel, wird der Aufhänger die Überschrift. Und Ebenen unter sechs werden als H6 geschrieben, denn ein Reader kennt nichts darüber hinaus. Das Title-Argument macht weiterhin seinen eigenen Job und füttert die Gliederung, die DrawTOC später in ein Inhaltsverzeichnis verwandelt

Bilder bekommen dieselbe Behandlung des „auf ein Element verbraucht“. RegisterFigure liefert die Beschreibung, die das nächste Bild erhält, und RegisterDecoration deklariert das nächste Bild als Linie, Rahmen oder Hintergrund ohne Bedeutung, was dann als Layout-Artefakt geschrieben wird (ISO 32000-1 §14.8.2.2). Jede Deklaration wird von genau einem Bild verbraucht, sodass ein späteres Bild niemals eine Beschreibung erbt, die für ein früheres geschrieben war. Alt-Text am falschen Bild ist schlimmer als keiner, denn der Reader behauptet dann etwas Falsches mit voller Überzeugung

Absichtlich haben wir nicht versucht zu erraten, welche Bilder dekorativ sind. Ein Bild ist entweder Inhalt, den der Reader beschreiben soll, oder Inhalt, den er überspringen soll, und nur der aufrufende Code weiß, welches. Der Fall, den es zu vermeiden gilt, ist der dritte: Ein Bild, das ohne beide Deklarationen gezeichnet wird, wird eine Abbildung ohne Beschreibung, die bei PDF/UA durchfällt und den Screenreader anhält, um „Grafik“ zu sagen und dann zu schweigen. Wenn Sie Ihren Berichts-Code nach DrawImage greppen, sollte jeder Treffer ein RegisterFigure oder RegisterDecoration direkt darüber stehen haben

PDF Library for Delphi-Diagramm: eine registrierte PDF-Überschrift wartet über ein dazwischenliegendes Bild hinweg, während RegisterFigure und RegisterDecoration jeweils für genau ein Bild aufgebraucht werden
RegisterHeading wartet auf den nächsten Text, unabhängig davon, was in der Zwischenzeit gezeichnet wird
Lib.RegisterHeading(2, 'Gearbox inspection');   // für den nächsten Text gequeut

Lib.RegisterFigure('Exploded view of the gearbox assembly');
Lib.AddImageFromFile('gearbox.png', 0);       // lädt und selektiert das Bild
Lib.DrawImage(72, 160, 360, 270);             // wird eine getaggte Figure

Lib.RegisterDecoration;                       // bedeutungslose Linie
Lib.AddImageFromFile('divider.png', 0);
Lib.DrawImage(72, 450, 360, 4);               // gezeichnet innerhalb eines Layout-Artefakts

Lib.DrawText(72, 480, 'Gearbox inspection');  // erst jetzt erscheint die H2

Die Beschreibung verdient mehr Sorgfalt als jeder andere String im Dokument. Ein nicht sehender Leser erhält sie anstelle des Bildes, und das ist alles, was er erhält. „Diagramm“ sagt ihm nichts; „Quartalsumsatz nach Region, mit der östlichen Region im Q3 am höchsten“ sagt ihm, was ein sehender Leser in zwei Sekunden aus dem Bild zieht

Tabellen: Kopfzeilen werden deklariert, nie erraten

Mit dem Tabellen-Bit eingeschaltet schreibt DrawTableRows die Tabelle, ihre Zeilen und ihre Zellen in den Strukturbaum, sodass ein Reader sagen kann, in welcher Spalte ein Wert steht, statt das Gitter als Folge zusammenhangslosen Textes vorzulesen. SetTableHeaderRowCount sagt, wie viele führende Zeilen Kopfzeilen sind. Diese Zeilen werden als TH-Zellen mit Spalten-Geltungsbereich geschrieben, was den Reader „Torque“ sagen lässt, bevor der Wert kommt, zu dem sich der Nutzer bewegt hat

Die Kopfzeilenanzahl defaultet auf null, und das war eine Entscheidung. Die erste Zeile einer Tabelle ist meist die Kopfzeile, raten läge also meistens richtig. Die Kosten des falschen Rats liegen jedoch schief: Eine Datenzeile, die für eine Kopfzeile gehalten wird, wird vor jedem Wert ihrer Spalte vorgelesen, was weit verwirrender ist als eine Tabelle ohne Kopfzeilen überhaupt. Eine Zeile Code von Ihnen ist billiger als dieser Ausfallmodus

Kopfzeilen zu deklarieren und sie auf jeder Seite zu wiederholen sind getrennte Entscheidungen. SetTableHeaderRowCount ist semantisch und ändert nichts auf der Seite. Die Wiederholung ist Layout und gehört zum RepeatHeaderRows-Argument von DrawTaggedTableRows. Wäre beides eine Einstellung, würde die Bitte um korrekte Kopfzeilen-Tags auch Kopfzeilenbänder zeichnen, die nie jemand bestellt hat, und ein Layout, das Seite für Seite abgenommen war, würde sich verschieben. Zeilen, die als RepeatHeaderRows übergeben werden, werden ebenfalls als Kopfzeilen getaggt, Sie müssen also nicht zweimal dasselbe sagen

PDF Library for Delphi-Diagramm: Tabellenkopfzellen tragen den Spaltenbereich im Strukturbaum, während RepeatHeaderRows die Wiederholung am Seitenumbruch als reine Layoutentscheidung belässt
Kopfzeilen stehen genau einmal im Strukturbaum, wobei jeder TH-Zelle ihr Spaltenbezug zugeordnet ist
var
  TableID: Integer;
begin
  TableID := Lib.CreateTable(40, 3);
  Lib.SetTableHeaderRowCount(TableID, 1);       // Zeile 1 ist das Kopfband
  Lib.SetTableCellContent(TableID, 1, 1, 'Part');
  Lib.SetTableCellContent(TableID, 1, 2, 'Torque');
  Lib.SetTableCellContent(TableID, 1, 3, 'Unit');
  // ... Datenzeilen füllen ...
  // Zeilen 1..40 in ein 600pt-Band zeichnen, pro Seite eine Kopfzeile wiederholen
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

Per Hand getaggte Abschnitte in einem automatischen Dokument

Automatisches Tagging tritt zurück, solange ein von Hand geöffnetes Tag noch offen ist. Das erlaubt Ihrem Code, einen Teil eines Dokuments selbst zu beschreiben und den Rest der Bibliothek zu überlassen, ohne dass die beiden ineinanderschachteln – und das ist die Anordnung, die die meisten realen Dokumente wollen. Die Titelseite und der Signaturblock haben eine Struktur, die nur Sie selbst verstehen. Die zweihundert Seiten Fließtext dazwischen nicht

Drei Regeln halten die Ausgabe gültig, welche Mischung Sie auch verwenden. Innerhalb eines Artefakts wird nichts getaggt, denn die Marked-Content-Sequenz eines Artefakts kann kein Strukturelement tragen, und eine Datei, die es versucht, fällt durch die PDF/UA-Validierung. Leerer Text öffnet kein Element, ein vereinzeltes DrawText mit leerem String kann also kein Element hinterlassen, das ein Reader als leer ankündigt. Und ein Bibliotheksaufruf, der intern über andere Zeichenaufrufe zeichnet, öffnet auf der äußeren Ebene genau ein Tag, sodass seine Teile nie in einem zweiten eigenen Element enden

Lesen Sie nach dem Speichern den Baum zurück, bevor Sie ihm vertrauen. Die Lese-API ist 1-basiert, was uns erwischt hat: Unsere erste Prüfschleife lief von 0 bis StructTreeItemCount - 1, bekam am Anfang einen leeren Datensatz, erreichte nie das letzte Element, und eine Zeit lang waren wir überzeugt, ein Absatz sei verschwunden. Nichts war verschwunden. Die Schleife war nur an beiden Enden um eins daneben

var
  I: Integer;
begin
  // Ein von Hand getaggtes Pull-Quote; das automatische Tagging hält sich heraus
  Lib.BeginTag('BlockQuote', '', '');
  Lib.DrawWrappedText(72, 520, 450,
    'All readings were taken with the unit at operating temperature.');
  Lib.EndTag;
  Lib.SaveToFile('report.pdf');

  // Den Baum zurücklesen: Indizes laufen von 1 bis StructTreeItemCount
  if Lib.LoadFromFile('report.pdf', '') = 1 then
    for I := 1 to Lib.StructTreeItemCount do
      Writeln(Lib.GetStructTreeItemText(I, 2), '  ',    // aufgelöster Typ
              Lib.GetStructTreeItemText(I, 4));         // /Alt, falls vorhanden
end;

Eine solche Ausgabe ist ein schneller Sanity-Check, kein Urteil. Was Sie sehen wollen, ist ein H1 vor dem ersten Absatz, jede Abbildung mit ihrer Beschreibung, und keine Abbildung mit leerem /Alt

Wenn Zeichenreihenfolge und Lesereihenfolge auseinandergehen

Automatisches Tagging zeichnet Elemente in der Reihenfolge auf, in der der Inhalt gezeichnet wird. Zeichnet Ihr Layout-Code die Sidebar vor dem Body, ist das die Reihenfolge, die der Baum hält, und die Reihenfolge, in der ein Screenreader liest. Für Dokumente, bei denen visuelle und Lesereihenfolge wirklich auseinanderfallen, oder die andere Strukturtypen oder Sprache pro Element brauchen, ist die manuelle Tagging-API die bessere Wahl, und der Walkthrough in Tagged PDF und Accessibility-Struktur behandelt Rollen, Geltungsbereiche und Kopfzeilen-Bindungen im Detail

Für Seiten, die Sie nicht selbst gezeichnet haben, etwa importierte Seiten ohne Marked Content, arbeitet AutoTagPages nachträglich: Es nimmt die mediane Textgröße über die übergebenen Seiten als Fließtext an, behandelt deutlich größere Läufe als Überschriften und markiert andere Malerei als Artefakte. Es ist das richtige Werkzeug für Inhalt, den Sie nicht neu erzeugen können. Für alles, was Ihr eigener Code zeichnet, ziehen wir das Taggen zum Zeichenzeitpunkt vor, denn dort wird der Bibliothek gesagt, was eine Überschrift ist, statt aus der Schriftgröße zu raten

Wenn das Dokument fertig ist, schicken Sie es durch einen Checker statt anzunehmen: Die Hinweise zum PDF/A- und PDF/UA-Preflight zeigen, wie man ein Urteil über die produzierte Struktur bekommt, und der Artikel zum dataset-getriebenen Report-Export zeigt, wo diese Aufrufe in einer Report-Engine sitzen, die ihr Layout aus Daten baut

Weil die PDF Library for Delphi in nativem Pascal für Delphi, C++Builder und Lazarus geschrieben ist, ohne externe PDF-Laufzeit, kommt die getaggte Ausgabe aus demselben Code, der die Seite zeichnet. Die vollständige API-Referenz und Plattformliste finden Sie auf der PDF Library for Delphi-Produktseite