Artigo Técnico

Detetar glifos PDF em falta no momento do desenho

Um glifo em falta num PDF não é um erro. O produtor pede um carácter que a fonte selecionada não consegue mapear, a fonte devolve o índice de glifo zero, e o ficheiro que sai é estruturalmente válido, abre em todo o lado, e mostra uma caixa vazia onde deveria estar um nome ou uma quantia. Ninguém na pipeline de geração descobre. O destinatário descobre. O HotPDF fecha esse ciclo com TrackUnresolvedGlyphs: ligue-o e o caminho de desenho de texto regista cada ponto de código cuja pesquisa de glifo resolve para o índice zero, disparando OnUnresolvedGlyph uma vez por descoberta única com o ponto de código, a fonte em que falhou, o sistema de escrita a que pertence, e uma sugestão de fontes que o cobririam

A deteção é metade da resposta. A outra metade é SetFontFallbackChain, que regista uma lista ordenada de fontes por sistema de escrita, para que os casos comuns se resolvam sozinhos e só buracos genuínos cheguem ao seu tratador. Juntos transformam uma classe de defeito que antes era reportada por clientes numa verificação em tempo de compilação

Porque é que um glifo em falta não lança nada?

Porque a ISO 32000 não impõe obrigação nenhuma a um produtor de verificar a cobertura, e o índice de glifo zero é um glifo legítimo. É o .notdef, cujo contorno o designer da fonte escolhe: normalmente um retângulo vazio ou oco, às vezes nada de todo. Um visualizador que o desenha está a comportar-se corretamente. A extração de texto pode até devolver os caracteres certos, porque o mapeamento /ToUnicode é escrito a partir do texto de origem e não dos contornos, pelo que uma verificação automatizada de ida e volta passará contenta um documento cujo texto visível tem buracos

Diagrama de porque um glifo PDF em falta se mantém em silêncio enquanto o glifo zero desenha uma caixa vazia e a extração ToUnicode passa verificações de ida e volta
O glifo zero é uma resposta .notdef legítima e o /ToUnicode é escrito a partir do texto de origem, pelo que nada na pipeline é avisado do buraco

A consequência prática é que a cobertura tem de ser verificada no momento do desenho, quando a biblioteca ainda sabe que ponto de código foi pedido e que glifo a fonte realmente ofereceu. Depois a informação já se perdeu

O detetor tem de vigiar o estado de subset, não o contexto de dispositivo

Foi aqui que a primeira implementação errou, e a razão vale a compreensão porque se aplica a qualquer verificação de cobertura aparafusada a uma pipeline de texto. O HotPDF tem dois caminhos de texto. Um emite através de uma fonte TrueType Unicode registada com um mapa de caracteres em memória construído no momento do registo. O outro é um caminho GDI antigo que cria um contexto de dispositivo e handle de fonte frescos por sequência de caracteres

Julgar a cobertura pelo caminho GDI é desesperante. O seu mapeamento não é o mapeamento que acaba no content stream emitido, e os dois não estão sincronizados, pelo que um detetor que leia resultados GDI reporta todo o intervalo ASCII imprimível como não resolvido. A resposta autoritativa vive na fonte registada: o mapa de caracteres que o RegisterUnicodeTTF analisa, consultado através de GetUnicodeGlyphForCodepoint. O detetor portanto é condicionado pelo estado de subset pronto, não por condição GDI nenhuma, e simplesmente não corre em documentos que nunca registaram uma fonte Unicode, o que está certo porque esses documentos estão de qualquer forma restringidos às codificações padrão

Uma segunda armadilha senta-se ao lado. O nome de família GDI de uma fonte e o nome PostScript extraído do binário da fonte no registo são strings diferentes, e não de uma forma que se possa normalizar: uma família chamada Arial Unicode MS transporta o nome PostScript ArialMT. Qualquer bloqueio escrito como "a fonte atualmente selecionada é a que registámos", comparado por nome, é código morto que nunca dispara. Condicione pelo estado, nunca por nomes de fontes

Fluxo de deteção de glifos não resolvidos do HotPDF mostrando o bloqueio por estado de subset, a consulta GetUnicodeGlyphForCodepoint e a ligação do evento OnUnresolvedGlyph
A cobertura é julgada pelo mapa da fonte Unicode registada e não pelo GDI, e cada ponto de código único dispara um evento com uma sugestão de fonte

Não teste um detetor de glifos com emoji

O caso de teste óbvio é uma cara sorridente, e ele vai convencê-lo de que o detetor está avariado. Os pontos de código de emoji comuns nos planos astrais resolvem através de um caminho de síntese de uso privado que os mapeia diretamente para um índice de glifo, pelo que nunca chegam ao ramo geral de cobertura. O detetor está a comportar-se corretamente e o teste está a medir o caminho errado

Use em vez disso um ponto de código não atribuído. U+0378 está permanentemente por atribuir em Unicode, pelo que nenhuma fonte o pode mapear legitimamente, e exercita exatamente o ramo que quer verificar. Essa distinção entre "a funcionalidade está avariada" e "o teste escolheu uma entrada que contorna a funcionalidade" custa horas reais, e os pontos de código não atribuídos são a forma mais barata de a evitar

type
  TCoverageAudit = class
  private
    FFindings: TStringList;
  public
    procedure Handle(Sender: TObject;
      const Info: THPDFUnresolvedGlyphInfo);
    property Findings: TStringList read FFindings;
  end;

procedure TCoverageAudit.Handle(Sender: TObject;
  const Info: THPDFUnresolvedGlyphInfo);
begin
  // Dispara uma vez por ponto de código único, não uma vez por ocorrência
  FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
    [Info.CodePoint, String(Info.FontName), Ord(Info.Script),
     String(Info.SuggestedFonts)]));
end;

// Ligando-o a um trabalho de geração
Pdf := THotPDF.Create(nil);
try
  Pdf.TrackUnresolvedGlyphs := True;
  Pdf.OnUnresolvedGlyph := Audit.Handle;
  Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\arial.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(50, 720, 0, CustomerName);
  Pdf.EndDoc;
  if Audit.Findings.Count > 0 then
    // Fazer o trabalho falhar em vez de enviar uma página com caixas
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

Cadeias de fallback são por sistema de escrita, não por fonte

A razão pela qual o fallback é limitado por sistema de escrita em vez de por fonte de origem é que os buracos de cobertura agrupam-se por sistema de escrita. Uma fonte de texto latina não tem Devanagari, tailandês, Han e emoji, tudo de uma vez, e o substituto de cada um é uma fonte diferente. Declarar uma cadeia por sistema de escrita descreve portanto a implantação real: uma fonte latina para o corpo de texto, uma fonte CJK, uma fonte emoji, uma generalista

Diagrama de fallback de fontes por sistema de escrita mapeando os sistemas hfsCJK, hfsArabic, hfsEmoji e hfsOther a cadeias ordenadas de fontes substitutas no HotPDF
Cada sistema de escrita recebe a sua própria cadeia ordenada, pelo que uma fonte latina de corpo sem Han, árabe ou emoji recai numa fonte que o cubra
// THPDFFontScript cobre hfsCommon, hfsLatin, hfsGreek, hfsCyrillic,
// hfsHebrew, hfsArabic, hfsIndic, hfsSoutheastAsian, hfsCJK, hfsKana,
// hfsHangul, hfsEmoji e hfsOther
Pdf.SetFontFallbackChain(hfsCJK,
  ['Microsoft YaHei', 'SimSun', 'Yu Gothic']);
Pdf.SetFontFallbackChain(hfsArabic, ['Segoe UI', 'Arial']);
Pdf.SetFontFallbackChain(hfsEmoji, ['Segoe UI Emoji']);
Pdf.SetFontFallbackChain(hfsOther, ['Arial Unicode MS']);

O fallback e a deteção são complementares em vez de alternativos. As cadeias tratam da cobertura que antecipou; o detetor reporta a cobertura que não antecipou, que num sistema que processa dados arbitrários de clientes é a metade interessante. Note que substituir uma fonte muda as métricas, pelo que um parágrafo que recaia pode refluir; se o layout importa, o comportamento de fecho e subsetting da fonte substituída merece ser lido no artigo sobre fecho de subset de fontes, e os sistemas de escrita que precisam de reordenação ou junção são tratados pela etapa de shaping descrita em shaping de texto de sistemas complexos

Como adaptar comportamento sem arriscar o caminho existente

O mesmo lançamento acrescentou um fallback de tabela kern antigo para espaçamento de pares, e a forma como foi limitado é um padrão que vale a pena copiar. Em vez de acrescentar um novo ponto de decisão à lógica de kerning, o fallback pendura-se no ramo de saída antecipada que já existia para fontes sem tabela GPOS. Uma fonte moderna com GPOS nunca o alcança, pelo que o seu comportamento fica inalterado por construção em vez de por testes. Os caminhos que não registam uma fonte Unicode produzem dois desvios zero, pelo que também ficam inalterados

Essa é a forma geral de uma adaptação de baixo risco numa biblioteca de renderização madura: encontrar o ramo que atualmente não produz nada e pôr lá o novo comportamento. Converte "acreditamos que isto não regrediu nada" em "isto não pode ter regressado nada", que é uma coisa muito melhor de dizer sobre um motor de texto por que passam as faturas dos outros

Faça dele um bloqueio, não um log

As descobertas de cobertura só são úteis se algo falhar com elas. Num serviço de geração de documentos o arranjo produtivo é manter o seguimento ligado no trabalho noturno de regressão contra um corpus de nomes, endereços e descrições de produtos reais de clientes, e fazer o trabalho falhar com qualquer descoberta. Como o evento dispara uma vez por ponto de código único em vez de uma vez por ocorrência, a saída se mantém pequena o suficiente para ler mesmo quando um sistema de escrita inteiro falta

Em produção o mesmo tratador é melhor usado como telemetria: registar o ponto de código e a fonte, continuar a servir o documento, e deixar o agregado dizer-lhe que sistema de escrita acrescentar ao conjunto de fontes de implantação a seguir. O comportamento de renderização para fontes embutidas e substituídas está coberto mais adiante em renderização de glifos de fontes embutidas, e a lista completa de propriedades incluindo TrackUnresolvedGlyphs está documentada na página de produto do HotPDF Delphi PDF component