Articolo tecnico

Tagging della struttura per PDF accessibili in Delphi

Aprite un report generato con un screen reader e i soliti guasti emergono entro un minuto: «Pagina 3 di 40» viene letto ad alta voce a ogni pagina, il logo dell'azienda viene annunciato come «immagine» senza nulla dopo, e una tabella esce come una lunga fila di numeri senza nomi di colonna. PDF Library for Delphi evita tutti e tre con il tagging automatico della struttura: chiamate SetAutoTagMode una volta, e le normali chiamate di disegno producono paragrafi, titoli, figure, tabelle e furniture di pagina skippabile mentre il documento viene disegnato

La via manuale esiste ancora. Potete avvolgere ogni chiamata di disegno in BeginTag e EndTag, e per documenti con struttura inconsueta è lo strumento giusto. Per un report, una fattura o un estratto conto ordinario, però, il tagging a mano fa dipendere l'accessibilità dal fatto che nessuno dimentichi mai una coppia, in ogni percorso di codice che disegna qualcosa, per tutta la vita del prodotto. Su un requisito di compliance non ci punterei

Cosa attiva SetAutoTagMode

SetAutoTagMode prende una maschera di bit e restituisce la modalità in vigore prima della chiamata. Ogni bit copre un tipo di contenuto:

  • AUTOTAG_TEXT (1): il testo diventa un paragrafo, o un titolo quando ne è stato registrato uno
  • AUTOTAG_FURNITURE (2): testate, piè di pagina e numeri di pagina diventano artefatti che un lettore salta
  • AUTOTAG_FIGURE (4): un'immagine disegnata diventa una figura, o un artefatto quando l'avete dichiarata decorativa
  • AUTOTAG_TABLE (8): le tabelle disegnate portano righe e celle nell'albero di struttura
  • AUTOTAG_DEFAULT (15): tutti e quattro, ed è il valore che raccomandiamo per qualsiasi nuovo codice di report

Attivare la modalità marca anche il documento come tagged, e quel passo conta più di quanto sembri. Secondo ISO 32000-1 §14.7.1 un lettore considera un file come non taggato a meno che il dizionario /MarkInfo del catalogo non dica /Marked true. Un file può portare un albero di struttura completo e corretto e venire comunque annunciato come privo di struttura, perché manca l'unico flag che dice alle tecnologie assistive di cercarla. L'albero c'è. Niente lo legge

Il tagging del testo funziona per chiamata. Ogni chiamata DrawText, DrawWrappedText o DrawTextBox diventa un elemento, quindi un paragrafo disegnato riga per riga con cinque chiamate DrawText arriva al lettore come cinque paragrafi di una riga. Se il vostro codice di layout spezza già le righe da solo, vale la pena cambiare: consegnate l'intero paragrafo a DrawWrappedText e lasciate che una chiamata produca un paragrafo

Diagramma delle maschere di bit di SetAutoTagMode che mappano semplici chiamate di disegno Delphi in un albero della struttura PDF taggato, con il flag Marked evidenziato come richiesto
Ogni bit attivo di SetAutoTagMode instrada le normali chiamate di disegno verso l'elemento corrispondente dell'albero della struttura
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);                          // origine in alto a sinistra, y cresce verso il basso
    Lib.SetDocumentLanguage('en-US');
    Lib.SetAutoTagMode(AUTOTAG_DEFAULT);       // prima di qualsiasi chiamata RegisterHeading
    Lib.AddStandardFont(4);

    Lib.SetTextSize(18);
    Lib.RegisterHeading(1, 'Annual service report');
    Lib.DrawText(72, 96, 'Annual service report');        // taggato 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.');  // un solo P

    // ... resto delle pagine ...

    // Gira su tutte le pagine esistenti, quindi chiamatelo una volta disegnate le pagine
    Lib.AddPageFooter('', 'Page &P of &N', 36, 1.0, 1);  // Artefatto di paginazione
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

Il commento sulla terza riga c'è per un motivo. RegisterHeading accoda un livello di titolo solo quando il tagging del testo è già attivo, e SetAutoTagMode cancella qualsiasi livello in coda quando gira. Registrare prima il titolo e attivare dopo la modalità fa uscire il titolo come paragrafo semplice, senza alcun errore da nessuna parte a dirvelo

Perché un titolo attende attraverso l'immagine disegnata prima di lui?

RegisterHeading nomina il livello per il prossimo pezzo di testo, e solo il testo può consumarlo. Se in mezzo viene disegnata un'immagine, l'immagine diventa una figura e il titolo resta in coda per il testo che segue. L'implementazione ovvia lascia che qualsiasi tag consumi il livello in coda, e allora la sequenza comune di registrare un titolo di sezione, disegnare un'immagine banner e disegnare il titolo produce un banner taggato come H2 e un vero titolo declassato a paragrafo. Un lettore che naviga per titoli atterra su una foto

Due altre cose seguono da come funziona la coda. Il livello di titolo si attacca a qualunque testo venga dopo, qualunque stringa vi abbiate passato a RegisterHeading: se il vostro layout disegna un piccolo «Sezione 3» sopra il titolo, quel kicker diventa il titolo. E i livelli oltre il sesto vengono scritti come H6, perché un lettore non conosce nulla oltre. L'argomento Title continua a fare il suo lavoro, alimentando l'outline che DrawTOC trasforma poi in un indice

Le immagini ricevono lo stesso trattamento «consumato da un solo elemento». RegisterFigure fornisce la descrizione che la prossima immagine porta con sé, e RegisterDecoration dichiara la prossima immagine una linea, un bordo o uno sfondo privo di significato, che viene poi scritto come artefatto Layout (ISO 32000-1 §14.8.2.2). Ogni dichiarazione viene consumata da esattamente una immagine, quindi un'immagine successiva non eredita mai una descrizione scritta per una precedente. Un testo alternativo attaccato alla foto sbagliata è peggio di niente, perché il lettore afferma qualcosa di falso con piena sicurezza

Non abbiamo volutamente provato a dedurre quali immagini siano decorative. Un'immagine è o contenuto che il lettore deve descrivere o contenuto che deve saltare, e solo il codice chiamante lo sa. Il caso da evitare è il terzo: un'immagine disegnata senza alcuna dichiarazione diventa una figura senza descrizione, fallisce PDF/UA e fa fermare lo screen reader per dire «immagine» e poi il silenzio. Se cercate DrawImage con grep nel vostro codice di report, ogni occorrenza dovrebbe avere una RegisterFigure o una RegisterDecoration subito sopra

Diagramma PDF Library for Delphi che mostra un'intestazione PDF registrata in attesa attraverso un'immagine interposta mentre RegisterFigure e RegisterDecoration vengono spese ciascuna su esattamente un'immagine
RegisterHeading attende il testo successivo indipendentemente da ciò che viene disegnato nel frattempo
Lib.RegisterHeading(2, 'Gearbox inspection');   // in coda per il prossimo testo

Lib.RegisterFigure('Exploded view of the gearbox assembly');
Lib.AddImageFromFile('gearbox.png', 0);       // carica e seleziona l'immagine
Lib.DrawImage(72, 160, 360, 270);             // diventa una Figure taggata

Lib.RegisterDecoration;                       // linea priva di significato
Lib.AddImageFromFile('divider.png', 0);
Lib.DrawImage(72, 450, 360, 4);               // disegnata dentro un artefatto di layout

Lib.DrawText(72, 480, 'Gearbox inspection');  // solo ora appare l'H2

La descrizione merita più cura di qualsiasi altra stringa nel documento. Un lettore non vedente la ottiene al posto della foto, e questo è tutto ciò che ottiene. «Grafico» non gli dice nulla; «Ricavi trimestrali per regione, con la regione orientale più alta nel Q3» gli dice ciò che un lettore vedente ricava dall'immagine in due secondi

Tabelle: le righe di intestazione si dichiarano, non si indovinano

Con il bit di tabella attivo, DrawTableRows scrive la tabella, le sue righe e le sue celle nell'albero di struttura, così un lettore può dire in quale colonna si trova un valore invece di leggere la griglia come testo non correlato. SetTableHeaderRowCount dice quante righe iniziali sono intestazioni. Quelle righe vengono scritte come celle TH con uno scope di colonna, che è ciò che permette a un lettore di annunciare «Torque» prima del valore su cui l'utente è arrivato

Il numero di righe di intestazione è predefinito a zero, e fu una scelta. La prima riga di una tabella di solito è l'intestazione, quindi indovinare sarebbe giusto la maggior parte delle volte. Il costo dell'errore è però sbilanciato: una riga di dati scambiata per intestazione viene letta ad alta voce davanti a ogni valore della sua colonna, cosa molto più confusionaria di una tabella senza intestazioni. Una riga di codice da parte vostra costa meno di quel guasto

Dichiarare le righe di intestazione e ripeterle su ogni pagina sono decisioni separate. SetTableHeaderRowCount è semantico e non cambia nulla sulla pagina. La ripetizione è layout, e appartiene all'argomento RepeatHeaderRows di DrawTaggedTableRows. Se le due fossero una sola impostazione, chiedere tag di intestazione corretti farebbe anche comparire fasce di intestazione ripetute mai richieste, e un layout verificato pagina per pagina si sposterebbe. Le righe passate come RepeatHeaderRows vengono taggate come intestazioni anche loro, così non dovete dire la stessa cosa due volte

Diagramma PDF Library for Delphi di celle di intestazione di tabella che portano l'ambito di colonna nell'albero della struttura, mentre RepeatHeaderRows mantiene la ripetizione a fine pagina una decisione solo di layout
Le righe di intestazione vivono una sola volta nell'albero della struttura, con l'ambito di colonna associato a ogni cella TH
var
  TableID: Integer;
begin
  TableID := Lib.CreateTable(40, 3);
  Lib.SetTableHeaderRowCount(TableID, 1);       // la riga 1 è la fascia di intestazione
  Lib.SetTableCellContent(TableID, 1, 1, 'Part');
  Lib.SetTableCellContent(TableID, 1, 2, 'Torque');
  Lib.SetTableCellContent(TableID, 1, 3, 'Unit');
  // ... riempi le righe di dati ...
  // Disegna le righe 1..40 in una fascia da 600pt, ripetendo una riga di intestazione per pagina
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

Sezioni taggate a mano dentro un documento automatico

Il tagging automatico si fa da parte mentre un tag aperto a mano è ancora aperto. Questo lascia al vostro codice di descrivere da sé una parte del documento e lasciare il resto alla libreria, senza che i due si annidino l'uno nell'altro, che è l'assetto che la maggior parte dei documenti reali vuole. La copertina e il blocco della firma hanno una struttura che solo voi capite. Le duecento pagine di corpo testo in mezzo no

Tre regole mantengono l'output valido qualunque miscela usiate. Niente viene taggato dentro un artefatto, perché la sequenza di contenuto marcato di un artefatto non può anche portare un elemento di struttura, e un file che ci prova fallisce la validazione PDF/UA. Il testo vuoto non apre alcun elemento, quindi una DrawText dispersa con stringa vuota non può lasciare dietro un elemento che un lettore annuncerebbe come vuoto. E una chiamata di libreria che disegna internamente attraverso altre chiamate di disegno apre un solo tag al livello esterno, così i suoi pezzi non finiscono mai annidati dentro un secondo elemento proprio

Dopo il salvataggio, rileggete l'albero prima di fidarvi. L'API di lettura è 1-based, e ci ha colto in contropiede: il nostro primo controllo ciclava da 0 a StructTreeItemCount - 1, riceveva un record vuoto all'inizio, non arrivava mai all'ultimo elemento, e per un po' eravamo convinti che un paragrafo fosse sparito. Non era sparito nulla. Il ciclo era semplicemente sbagliato di uno su entrambi i lati

var
  I: Integer;
begin
  // Una citazione evidenziata taggata a mano; il tagging automatico resta fuori
  Lib.BeginTag('BlockQuote', '', '');
  Lib.DrawWrappedText(72, 520, 450,
    'All readings were taken with the unit at operating temperature.');
  Lib.EndTag;
  Lib.SaveToFile('report.pdf');

  // Rileggi l'albero: gli indici vanno da 1 a StructTreeItemCount
  if Lib.LoadFromFile('report.pdf', '') = 1 then
    for I := 1 to Lib.StructTreeItemCount do
      Writeln(Lib.GetStructTreeItemText(I, 2), '  ',    // tipo risolto
              Lib.GetStructTreeItemText(I, 4));         // /Alt, se presente
end;

Un elenco come questo è un rapido controllo di sanità, non un verdetto. Ciò che volete vedere è un H1 prima del primo paragrafo, ogni figura con la propria descrizione, e nessuna figura con /Alt vuoto

Quando ordine di disegno e ordine di lettura si separano

Il tagging automatico registra gli elementi nell'ordine in cui il contenuto viene disegnato. Se il vostro codice di layout disegna la barra laterale prima del corpo, quello è l'ordine che l'albero conserva, e quello è l'ordine che uno screen reader legge. Per i documenti in cui l'ordine visivo e quello di lettura differiscono davvero, o che hanno bisogno di altri tipi di struttura o di lingua per elemento, l'API di tagging manuale è più adatta, e la guida di tagged PDF e struttura di accessibilità copre ruoli, scope e binding delle intestazioni in dettaglio

Per le pagine che non avete disegnato voi, come le pagine importate senza contenuto marcato, AutoTagPages funziona dopo il fatto: prende la dimensione mediana del testo attraverso le pagine date come corpo del testo e tratta le sequenze marcatamente più grandi come titoli, e marca le altre pitture come artefatti. È lo strumento giusto per contenuto che non potete rigenerare. Per tutto ciò che il vostro codice disegna, preferiamo il tagging al momento del disegno, perché lì alla libreria viene detto che cos'è un titolo invece di indovinarlo dalla dimensione del font

Quando il documento è finito, fatelo girare in un checker invece di presumere: le note su preflight PDF/A e PDF/UA mostrano come ottenere un verdetto sulla struttura che avete prodotto, e l'articolo sull'esportazione di rapporti guidata da dataset mostra dove queste chiamate si collocano in un motore di report che genera il proprio layout dai dati

Poiché PDF Library for Delphi è scritta in Pascal nativo per Delphi, C++Builder e Lazarus, senza alcun runtime PDF esterno da distribuire, l'output taggato viene dallo stesso codice che disegna la pagina. Il riferimento API completo e la lista delle piattaforme sono sulla pagina prodotto PDF Library for Delphi