Artigo Técnico

Validar a árvore de estrutura PDF/UA no Delphi com PDFium

O relatório de preflight diz que o ficheiro está limpo em PDF/UA. O veraPDF abre o mesmo ficheiro e assinala uma Figure sem texto alternativo ao abrigo da cláusula 7.3. Os dois estão certos, e a diferença entre eles é precisamente o problema de verificar acessibilidade apenas por varrimento

PDFium Component é uma biblioteca PDF nativa VCL para Delphi e C++Builder, e o seu ValidatePdfUa faz as duas passagens. A passagem ao nível dos bytes trata dos marcadores de formato. Em cima dela existe uma passagem pela árvore de estrutura que carrega a árvore etiquetada viva, percorre cada página e valida o conteúdo contra as regras PDF/UA que um simples scan não consegue ver. Este artigo explica o que essa segunda passagem vê de facto, por que razão a validação em duas fases é diferente de um preflight completo, e onde a implementação deliberadamente se fica por três regras

Porque um scan de bytes não vê um Alt em falta

ISO 14289-1 (PDF/UA-1) é uma camada de requisitos sobre o ISO 32000. Alguns desses requisitos são estruturais e visíveis no ficheiro bruto: o catálogo tem de declarar uma árvore de estrutura, as preferências do visualizador têm de definir DisplayDocTitle, as fontes têm de ser incorporadas. Outros vivem acima da sintaxe. A alternância correcta de texto e estrutura significa que um scan de bytes pode confirmar a presença de uma árvore etiquetada, mas não pode provar que o conteúdo daquela árvore faz sentido

Mas «cada Figure tem texto alternativo» não é uma propriedade da sintaxe do ficheiro. É uma propriedade da estrutura lógica, a árvore de elementos etiquetados que mapeia conteúdo para significado. A entrada Alt de uma Figure pode estar no dicionário do elemento de estrutura ou, nalguns casos, o elemento pode fornecer ActualText como alternativa. Só uma leitura da árvore viva consegue distinguir um destes casos de um objecto quebrado que apenas parece aceitável no disco

Ler a árvore de etiquetas viva

A matéria-prima é TPdf.GetStructureElements (também exposta pela propriedade StructureElements), que devolve um TPdfStructureElements, um array plano de registos TPdfStructureElement em ordem do documento. Cada registo é a projecção de um elemento de estrutura vivo com profundidade, tipo, título, texto alternativo e relação pai-filho já resolvidos

type
  TPdfStructureElement = record
    Level: Integer;            // depth in the tag tree
    ParentIndex: Integer;      // index of parent element, or -1
    TypeName: WString;         // standard /S name: Figure, Formula, Note...
    Title: WString;            // /T
    AlternateText: WString;    // /Alt   (FPDF_StructElement_GetAltText)
    ActualText: WString;       // /ActualText
    Expansion: WString;        // /E
    ID: WString;               // /ID    (FPDF_StructElement_GetID)
    Language: WString;         // /Lang
    MarkedContentIDs: TPdfIntegerArray;
    // ... child bookkeeping fields
  end;

O campo TypeName é o ponto em que o validador se apoia. Vem de FPDF_StructElement_GetType, que devolve o tipo de estrutura standard do elemento, o nome /S, depois de o PDFium resolver o role map. AlternateText vem de FPDF_StructElement_GetAltText. Se o PDFium lhe devolver texto alternativo vazio e o elemento for uma Figure, o caso já está encaminhado

O verificador é uma função pura, e isso é intencional

A lógica das regras não vive no método que fala com a DLL. É uma função pública, autónoma e pura:

function ValidatePdfUaStructureElements(
  const Elements: TPdfStructureElements): TPdfUaValidationIssues;

Recebe um array plano de elementos e devolve um conjunto de problemas. Não chama nenhuma função do PDFium, não abre nenhum documento, não toca em estado global. Essa separação é deliberada, e compensa em dois pontos. Primeiro, testabilidade: pode construir um TPdfStructureElements sintético em memória, alimentá-lo à função e verificar o conjunto de avisos sem qualquer I/O

Segundo, clareza de responsabilidade. TPdf.ValidatePdfUa trata da parte suja - carregar cada página, extrair os seus elementos, agregá-los - e depois entrega um array limpo ao verificador puro. «Obter os dados» (DLL, efeitos secundários, ciclo de vida) e «julgar os dados» (regras, avisos, resultados) ficam em caixas separadas

O que as três regras realmente verificam

A passagem pela árvore de estrutura gera três valores de problema, acrescentados ao fim de TPdfUaValidationIssues para que o enum permaneça ABI-stable para os chamadores existentes: pvuaiFigureMissingAlt, pvuaiFormulaMissingAlt e pvuaiNoteMissingId. O corpo é pequeno, mas a ordem é importante porque os consumidores existentes já dependem desses valores

for I := 0 to High(Elements) do
begin
  T := string(Elements[I].TypeName);
  if T = 'Figure' then
  begin
    // §7.3 — a Figure needs an alternate representation:
    // an Alt entry OR ActualText. Flag only when BOTH are empty.
    if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
      Include(Result, pvuaiFigureMissingAlt);
  end
  else if T = 'Formula' then
  begin
    // §7.7 — same rule as Figure: Alt OR ActualText.
    if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
      Include(Result, pvuaiFormulaMissingAlt);
  end
  else if T = 'Note' then
  begin
    // §7.9 — every Note must have a unique ID.
    NoteId := string(Elements[I].ID);
    if NoteId = '' then
      Include(Result, pvuaiNoteMissingId)
    else
      for J := 0 to I - 1 do
        if (string(Elements[J].TypeName) = 'Note') and
           (string(Elements[J].ID) = NoteId) then
        begin
          Include(Result, pvuaiNoteMissingId);
          Break;
        end;
  end;
end;

A cláusula 7.3 rege as Figures: um elemento Figure tem de fornecer uma alternativa textual. A primeira versão desta verificação olhava apenas para a entrada Alt, o que a tornava mais restritiva do que os validadores de referência. O PDF/UA aceita uma Figure cujo texto acessível venha de Alt ou de ActualText, por isso a validação tem de aceitar ambas as formas. É um bom exemplo de como uma regra aparentemente simples pode ficar errada quando a implementação assume apenas um caminho

A cláusula 7.9 é diferente na natureza. Uma Note tem de ter um /ID, e esse ID tem de ser único em todo o documento. Um ID em falta é uma falha por elemento. Um ID duplicado é uma relação entre dois elementos, razão pela qual o array plano é importante: para detectar colisões globais, o verificador precisa de ver todos os elementos de uma vez

Agregação por páginas para que a unicidade seja global

O PDFium expõe elementos de estrutura por página, não por documento, por isso a orquestração em ValidatePdfUa tem de os reunir antes de correr as regras. O método percorre cada página com FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage, independentemente da página, e concatena tudo num único array

// inside TPdf.ValidatePdfUa, after the byte-level pass
if (FDocument <> nil) and
   (not (pvuaiMissingStructTreeRoot in Result.Issues)) then
begin
  AllElems := nil;
  PageTotal := FPDF_GetPageCount(FDocument);
  for I := 0 to PageTotal - 1 do
  begin
    Page := FPDF_LoadPage(FDocument, I);
    if Page = nil then Continue;
    try
      PageElems := GetStructureElementsForPage(Page);
    finally
      FPDF_ClosePage(Page);
    end;
    // append PageElems into AllElems ...
  end;
  Result.Issues := Result.Issues + ValidatePdfUaStructureElements(AllElems);
end;

A agregação é o que torna correcta a verificação de unicidade da cláusula 7.9. Duas Notes em páginas diferentes podem partilhar um ID; se validasse página a página, nunca veria a colisão, porque o conjunto de elementos de cada página parece internamente consistente. Construir o array completo antes de chamar o verificador é o detalhe que transforma uma verificação local numa verificação global real

Conservador por desenho: falha em silêncio, nunca assinala falso positivo

A propriedade mais importante deste verificador é aquilo que ele se recusa a fazer. Só corresponde aos nomes de tipo /S standard que FPDF_StructElement_GetType devolve directamente - Figure, Formula, Note. Um documento que defina um tipo personalizado e o mapeie através do role map para um dos casos standards fica no lado conservador: se o PDFium lhe apresentar o nome standard, a regra apanha-o; se lhe apresentar outro nome, o verificador não inventa semântica

É também por isso que o âmbito se mantém em três regras. Aninhamento por nível de título (cláusula 7.4), âmbito de cabeçalhos de tabela (7.5) e detecção de ciclos no role map (7.1) são requisitos legítimos de PDF/UA, mas verificá-los bem exige análise de grafo e contexto que um checker de primeira linha não deve fingir que tem. Melhor ser exacto no que cobre do que vasto e impreciso no que promete

A fronteira, dita de forma clara

Há dois limites que convém conhecer antes de integrar isto num gate de release. Primeiro, o verificador só é tão bom quanto aquilo que o PDFium consegue ler do elemento de estrutura. Um punhado de ficheiros de corpus de conformidade que os validadores de referência aceitam usam construção algébrica que o PDFium não expõe totalmente, por isso existem casos de sintaxe legal que ficam fora do alcance do nivel Tier-1

Segundo, isto é um preflight, não uma certificação. O Tier-1 apanha os erros de conteúdo de alta confiança que um scan de bytes estruturalmente não consegue ver, e fá-lo sem falsos alarmes, mas a conformidade completa com PDF/UA, incluindo semântica de headings, âmbito de tabelas e ciclos de role map, continua a precisar de um validador completo. O valor aqui é alinhar o primeiro filtro com o que a árvore de estrutura realmente diz, não substituir todo o ecossistema de conformidade

As APIs da árvore de estrutura e o validador ValidatePdfUa mostrados aqui fazem parte do PDFium Component para Delphi e C++Builder (VCL) e Lazarus/FPC (LCL). A página do produto liga para a referência completa da API, incluindo o TPdfStructureElement inteiro e o conjunto de problemas que o verificador devolve