Artigo Técnico

Marcação Automática para PDF Acessível em Delphi

Abra um relatório gerado num leitor de tela e as falhas de sempre aparecem dentro de um minuto: "Página 3 de 40" é lido em voz alta em toda página, o logo da empresa é anunciado como "gráfico" sem nada depois, e uma tabela sai como uma longa sequência de números sem nome de coluna. O PDF Library for Delphi evita os três com marcação automática de estrutura: chame SetAutoTagMode uma vez, e chamadas comuns de desenho produzem parágrafos, títulos, figuras, tabelas e elementos repetitivos de página que o leitor pode pular enquanto o documento é desenhado

O caminho manual continua existindo. Você pode envolver cada chamada de desenho em BeginTag e EndTag, e para documentos com estrutura incomum essa é a ferramenta certa. Para um relatório, fatura ou demonstrativo comuns, porém, a marcação à mão faz a acessibilidade depender de ninguém nunca esquecer um par, em todo caminho de código que desenha qualquer coisa, pela vida do produto. Nós não apostaríamos um requisito de compliance nisso

O que o SetAutoTagMode liga

SetAutoTagMode recebe uma máscara de bits e devolve o modo que estava em vigor antes da chamada. Cada bit cobre um tipo de conteúdo:

  • AUTOTAG_TEXT (1): texto vira parágrafo, ou título quando um tiver sido registrado
  • AUTOTAG_FURNITURE (2): cabeçalhos, rodapés e números de página correntes viram artefatos que o leitor pula
  • AUTOTAG_FIGURE (4): uma imagem desenhada vira figura, ou artefato quando você a declarou decorativa
  • AUTOTAG_TABLE (8): tabelas desenhadas levam as linhas e as células delas para a árvore de estrutura
  • AUTOTAG_DEFAULT (15): os quatro, e o valor que recomendamos para qualquer código de relatório novo

Ligar o modo também marca o documento como tagged, e esse passo importa mais do que parece. Sob a ISO 32000-1 §14.7.1, um leitor trata um arquivo como não tagged a menos que o dicionário /MarkInfo do catálogo diga /Marked true. Um arquivo pode carregar uma árvore de estrutura completa e correta e ainda ser anunciado como se não tivesse estrutura alguma, porque falta a única flag que diz à tecnologia assistiva para procurá-la. A árvore está lá. Nada a lê

A marcação de texto funciona por chamada. Cada chamada de DrawText, DrawWrappedText ou DrawTextBox vira um elemento, então um parágrafo desenhado linha por linha com cinco chamadas de DrawText chega ao leitor como cinco parágrafos de uma linha. Se o seu código de layout já quebra linhas por conta própria, vale mudar isso: entregue o parágrafo inteiro ao DrawWrappedText e deixe uma chamada produzir um parágrafo

Diagrama das máscaras de bits do SetAutoTagMode mapeando chamadas de desenho comuns do Delphi em uma árvore de estrutura de PDF etiquetado, com o flag Marked destacado como obrigatório
Cada bit ativo de SetAutoTagMode roteia chamadas comuns de desenho para um elemento correspondente da árvore de estrutura
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);                          // origem no canto superior esquerdo, y cresce para baixo
    Lib.SetDocumentLanguage('en-US');
    Lib.SetAutoTagMode(AUTOTAG_DEFAULT);       // antes de qualquer chamada de RegisterHeading
    Lib.AddStandardFont(4);

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

    // ... páginas restantes ...

    // Percorre toda página existente, então chame quando as páginas estiverem desenhadas
    Lib.AddPageFooter('', 'Page &P of &N', 36, 1.0, 1);  // artefato de Pagination
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

O comentário na terceira linha está lá por um motivo. O RegisterHeading só enfileira um nível de título quando a marcação de texto já está ligada, e o SetAutoTagMode limpa qualquer nível em fila quando roda. Registre o título primeiro e ligue o modo depois, e o título sai como um parágrafo comum sem erro nenhum para avisar

Por que um título espera pela imagem desenhada antes dele?

O RegisterHeading nomeia o nível para o próximo texto, e só texto o consome. Se uma imagem for desenhada no meio, a imagem vira figura e o título segue na fila para o texto que vem depois. A implementação óbvia deixa qualquer tag consumir o nível em fila, e então a sequência comum de registrar um título de seção, desenhar uma imagem de banner e desenhar o título produz um banner marcado como H2 e um título de verdade rebaixado a parágrafo. Um leitor navegando por títulos pousa numa figura

Duas coisas mais decorrem de como a fila funciona. O nível de título se prende ao texto que vier depois, seja qual for a string que você passou ao RegisterHeading: se o seu layout desenha um pequeno "Section 3" acima do título, o kicker vira o título. E níveis mais fundos que seis são escritos como H6, porque o leitor não sabe nada além disso. O argumento Title continua fazendo o trabalho dele, alimentando o outline que o DrawTOC depois transforma num sumário

Imagens recebem o mesmo tratamento de "gasto em um item". O RegisterFigure fornece a descrição que a próxima imagem carrega, e o RegisterDecoration declara a próxima imagem como uma linha, borda ou fundo sem significado, que então é escrita como artefato Layout (ISO 32000-1 §14.8.2.2). Cada declaração é consumida por exatamente uma imagem, então uma imagem posterior nunca herda uma descrição escrita para uma anterior. Texto alternativo preso à figura errada é pior que nenhum, porque o leitor afirma algo falso com total confiança

Nós deliberadamente não tentamos inferir quais imagens são decorativas. Uma imagem ou é conteúdo que o leitor deve descrever ou é conteúdo que ele deve pular, e só o código chamador sabe qual. O caso a evitar é o terceiro: uma imagem desenhada sem declaração nenhuma vira uma figura sem descrição, o que falha no PDF/UA e faz o leitor de tela parar para dizer "gráfico" e depois nada. Se você der um grep no seu código de relatório por DrawImage, todo hit deve ter um RegisterFigure ou um RegisterDecoration logo acima

Diagrama do PDF Library for Delphi mostrando um título de PDF registrado aguardando durante uma imagem intermediária, enquanto RegisterFigure e RegisterDecoration são cada um gastos em exatamente uma imagem
RegisterHeading aguarda o próximo texto, não importa o que mais seja desenhado nesse meio-tempo
Lib.RegisterHeading(2, 'Gearbox inspection');   // na fila para o próximo texto

Lib.RegisterFigure('Exploded view of the gearbox assembly');
Lib.AddImageFromFile('gearbox.png', 0);       // carrega e seleciona a imagem
Lib.DrawImage(72, 160, 360, 270);             // vira uma Figure marcada

Lib.RegisterDecoration;                       // linha sem significado
Lib.AddImageFromFile('divider.png', 0);
Lib.DrawImage(72, 450, 360, 4);               // desenhada dentro de um artefato de layout

Lib.DrawText(72, 480, 'Gearbox inspection');  // só agora o H2 aparece

A descrição merece mais cuidado do que qualquer outra string do documento. Um leitor não vidente a recebe no lugar da imagem, e isso é tudo o que ele recebe. "Gráfico" não diz nada; "Receita trimestral por região, com a região leste mais alta em Q3" diz o que um leitor vidente tira da imagem em dois segundos

Tabelas: linhas de cabeçalho são declaradas, nunca adivinhadas

Com o bit de tabela ligado, o DrawTableRows escreve a tabela, as linhas e as células dela na árvore de estrutura, então um leitor consegue dizer em qual coluna um valor está em vez de ler a grade como texto sem relação. O SetTableHeaderRowCount diz quantas linhas iniciais são cabeçalhos. Essas linhas são escritas como células TH com escopo de coluna, que é o que deixa o leitor anunciar "Torque" antes do valor para o qual o usuário se moveu

A contagem de linhas de cabeçalho tem zero como padrão, e isso foi uma escolha. A primeira linha de uma tabela costuma ser o cabeçalho, então adivinhar acertaria na maior parte do tempo. O custo do palpite errado é desigual, porém: uma linha de dados tomada por cabeçalho é lida em voz alta na frente de todo valor da coluna dela, o que confunde muito mais que uma tabela sem cabeçalho nenhum. Uma linha de código sua é mais barata que esse modo de falha

Declarar linhas de cabeçalho e repeti-las em cada página são decisões separadas. O SetTableHeaderRowCount é semântico e não muda nada na página. A repetição é layout, e pertence ao argumento RepeatHeaderRows do DrawTaggedTableRows. Se os dois fossem uma configuração só, pedir tags de cabeçalho corretas também começaria a desenhar faixas de cabeçalho repetidas que você nunca pediu, e um layout que foi conferido página a página se deslocaria. Linhas passadas como RepeatHeaderRows também são marcadas como cabeçalhos, então você não precisa dizer a mesma coisa duas vezes

Diagrama do PDF Library for Delphi de células de cabeçalho de tabela carregando escopo de coluna na árvore de estrutura, enquanto RepeatHeaderRows mantém a repetição em quebras de página como uma decisão apenas de layout
As linhas de cabeçalho vivem uma única vez na árvore de estrutura, com o escopo de coluna anexado a cada célula TH
var
  TableID: Integer;
begin
  TableID := Lib.CreateTable(40, 3);
  Lib.SetTableHeaderRowCount(TableID, 1);       // a linha 1 é a faixa de cabeçalho
  Lib.SetTableCellContent(TableID, 1, 1, 'Part');
  Lib.SetTableCellContent(TableID, 1, 2, 'Torque');
  Lib.SetTableCellContent(TableID, 1, 3, 'Unit');
  // ... preenche as linhas de dados ...
  // Desenha as linhas 1..40 numa faixa de 600pt, repetindo uma linha de cabeçalho por página
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

Seções marcadas à mão dentro de um documento automático

A marcação automática se afasta enquanto uma tag aberta à mão por você segue aberta. Isso deixa o seu código descrever parte de um documento por conta própria e deixar o resto para a biblioteca sem que os dois se aninhem, que é o arranjo que a maioria dos documentos reais quer. A capa e o bloco de assinatura têm estrutura que só você entende. As duzentas páginas de texto do corpo entre eles não têm

Três regras mantêm a saída válida qualquer que seja a mistura. Nada é marcado dentro de um artefato, porque a sequência de marked-content de um artefato não pode também carregar um elemento de estrutura, e um arquivo que tenta falha na validação PDF/UA. Texto vazio não abre elemento, então um DrawText perdido com string vazia não consegue deixar para trás um elemento que um leitor anunciaria como em branco. E uma chamada da biblioteca que desenha através de outras chamadas de desenho internamente abre uma tag no nível externo, então as partes dela nunca acabam aninhadas dentro de um segundo elemento próprio

Depois de salvar, leia a árvore de volta antes de confiar nela. A API de leitura é 1-based, o que nos pegou: nosso primeiro loop de checagem ia de 0 a StructTreeItemCount - 1, pegava um registro vazio no começo, nunca chegava ao último elemento, e por um tempo ficamos convencidos de que um parágrafo tinha sumido. Nada tinha sumido. O loop estava errado em um nas duas pontas

var
  I: Integer;
begin
  // Uma pull quote marcada à mão; a marcação automática fica de fora
  Lib.BeginTag('BlockQuote', '', '');
  Lib.DrawWrappedText(72, 520, 450,
    'All readings were taken with the unit at operating temperature.');
  Lib.EndTag;
  Lib.SaveToFile('report.pdf');

  // Leia a árvore de volta: os índices vão de 1 a StructTreeItemCount
  if Lib.LoadFromFile('report.pdf', '') = 1 then
    for I := 1 to Lib.StructTreeItemCount do
      Writeln(Lib.GetStructTreeItemText(I, 2), '  ',    // tipo resolvido
              Lib.GetStructTreeItemText(I, 4));         // /Alt, se houver
end;

Uma listagem assim é um sanity check rápido, não um veredito. O que você quer ver é um H1 antes do primeiro parágrafo, cada figura carregando a descrição dela, e nenhuma figura com /Alt vazio

Quando ordem de desenho e ordem de leitura se separam

A marcação automática registra elementos na ordem em que o conteúdo é desenhado. Se o seu código de layout desenha a barra lateral antes do corpo, essa é a ordem que a árvore guarda, e essa é a ordem que o leitor de tela lê. Para documentos em que a ordem visual e a ordem de leitura genuinamente diferem, ou que precisam de outros tipos de estrutura ou de idioma por elemento, a API de marcação manual é o encaixe melhor, e o passo a passo de tagged PDF e estrutura de acessibilidade cobre papéis, escopos e vínculos de cabeçalho em detalhe

Para páginas que você não desenhou, como páginas importadas sem marked content, o AutoTagPages funciona depois do fato: ele toma o tamanho de texto mediano pelas páginas recebidas como texto de corpo, trata sequências marcadamente maiores como títulos e marca o resto da pintura como artefatos. É a ferramenta certa para conteúdo que você não pode regenerar. Para qualquer coisa que o seu próprio código desenha, preferimos marcar na hora do desenho, porque ali a biblioteca é informada do que é um título em vez de adivinhar pelo tamanho da fonte

Quando o documento estiver pronto, rode-o por um verificador em vez de supor: as notas sobre preflight de PDF/A e PDF/UA mostram como obter um veredito sobre a estrutura que você produziu, e o artigo sobre exportação de relatórios orientada por dataset mostra onde essas chamadas se situam num motor de relatórios que constrói o layout dele a partir dos dados

Como o PDF Library for Delphi é escrito em Pascal nativo para Delphi, C++Builder e Lazarus, sem runtime de PDF externo para distribuir, a saída tagged vem do mesmo código que desenha a página. A referência completa de API e a lista de plataformas estão na página do produto PDF Library for Delphi