Artigo Técnico

Detectando glifos PDF ausentes ao desenhar no Delphi

Um glifo ausente em um PDF não é um erro. O produtor pede um caractere que a fonte selecionada não consegue mapear, a fonte retorna o índice de glifo zero, e o arquivo que sai é estruturalmente válido, abre em todo lugar e mostra uma caixa vazia onde um nome ou um valor deveria estar. Ninguém no pipeline gerador fica sabendo. O destinatário fica. O HotPDF fecha esse ciclo com TrackUnresolvedGlyphs: ligue-o e o caminho de desenho de texto registra todo code point cuja busca de glifo resolve para o índice zero, disparando OnUnresolvedGlyph uma vez por finding único com o code point, a fonte em que falhou, o script a que pertence e uma sugestão de fontes que o cobririam

Detecção é metade da resposta. A outra metade é o SetFontFallbackChain, que registra uma lista ordenada de fontes por script, para que os casos comuns se resolvam sozinhos e só lacunas genuínas cheguem ao seu handler. Juntos, eles transformam uma classe de defeito que costumava ser reportada por clientes em uma verificação em tempo de build

Por que um glifo ausente não levanta nada?

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

Diagrama de por que um glifo PDF ausente fica em silêncio enquanto o glifo zero desenha uma caixa vazia e a extração ToUnicode passa nas verificações de ida e volta
O glifo zero é uma resposta .notdef legítima e o /ToUnicode é escrito do texto de origem, então nada no pipeline é avisado sobre a lacuna

A consequência prática é que a cobertura precisa ser verificada no momento do desenho, quando a biblioteca ainda sabe qual code point foi pedido e qual glifo a fonte de fato ofereceu. Depois a informação se foi

O detector precisa vigiar o estado de subset, não o device context

É aqui que a primeira implementação errou, e a razão vale entender porque se aplica a qualquer verificação de cobertura presa a um pipeline de texto. O HotPDF tem dois caminhos de texto. Um emite por uma fonte TrueType Unicode registrada com um mapa de caracteres em memória construído no registro. O outro é um caminho GDI legado que cria um device context e handle de fonte novos a cada run de caracteres

Julgar a cobertura pelo caminho GDI é sem esperança. Seu mapeamento não é o mapeamento que acaba no content stream emitido, e os dois não estão sincronizados, então um detector que lê resultados GDI reporta todo o intervalo ASCII imprimível como não resolvido. A resposta autoritativa vive na fonte registrada: o mapa de caracteres que o RegisterUnicodeTTF faz o parse, consultado por GetUnicodeGlyphForCodepoint. O detector é portanto portado no estado pronto de subset, não em qualquer condição GDI, e simplesmente não roda em documentos que nunca registraram uma fonte Unicode, o que está certo porque esses documentos estão restritos às codificações padrão de qualquer forma

Uma segunda armadilha fica ao lado. O nome de família GDI de uma fonte e o nome PostScript extraído do binário da fonte no registro são strings diferentes, e não de um jeito que se possa normalizar: uma família chamada Arial Unicode MS carrega o nome PostScript ArialMT. Qualquer gate escrito como "a fonte selecionada atualmente é a que registramos", comparado por nome, é código morto que nunca dispara. Porte o gate no estado, nunca em nomes de fontes

Fluxo de detecção de glifo não resolvido do HotPDF mostrando o gate de estado de subset, a busca GetUnicodeGlyphForCodepoint e a ligação do evento OnUnresolvedGlyph
A cobertura é julgada pelo mapa da fonte Unicode registrada em vez do GDI, e cada code point único dispara um evento com uma sugestão de fonte

Não teste um detector de glifos com emoji

O caso de teste óbvio é um rosto sorridente, e ele vai convencer você de que o detector está quebrado. Code points comuns de emoji nos planos astrais resolvem por um caminho de síntese de private use que os mapeia direto para um índice de glifo, então nunca chegam ao ramo geral de cobertura. O detector está se comportando corretamente e o teste está medindo o caminho errado

Use um code point não atribuído em vez disso. U+0378 é permanentemente não alocado no Unicode, então nenhuma fonte pode legitimamente mapeá-lo, e ele exerce exatamente o ramo que você quer verificar. Essa distinção entre "o recurso está quebrado" e "o teste escolheu uma entrada que contorna o recurso" custa horas de verdade, e code points não atribuídos são o jeito mais barato de evitá-la

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 code point ú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 isso a um job gerador
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
    // Faça o job falhar em vez de entregar uma página com caixas
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

Cadeias de fallback são por script, não por fonte

A razão de o fallback ter escopo por script e não por fonte de origem é que as lacunas de cobertura se agrupam 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 script portanto descreve a implantação real: uma fonte latina para o corpo de texto, uma fonte CJK, uma fonte de emoji, uma pega-tudo

Diagrama de fallback de fontes por script mapeando os scripts hfsCJK, hfsArabic, hfsEmoji e hfsOther a cadeias ordenadas de fontes substitutas no HotPDF
Cada script ganha sua própria cadeia ordenada, então uma fonte de corpo latina sem Han, árabe ou emoji recua para uma fonte que o cobre
// 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']);

Fallback e detecção são complementares em vez de alternativos. As cadeias tratam a cobertura que você antecipou; o detector reporta a cobertura que você não antecipou, que em um sistema processando dados arbitrários de clientes é a metade interessante. Note que substituir uma fonte muda as métricas, então um parágrafo que recuar ao fallback pode refluir; se o layout importa, o comportamento de closure e subsetting da fonte substituída vale a leitura em o artigo de closure de subset de fontes, e scripts que precisam de reordenação ou junção são tratados pelo estágio de shaping descrito em shaping de texto de scripts complexos

Como fazer retrofit de comportamento sem arriscar o caminho existente

O mesmo release adicionou um fallback de tabela kern legada para espaçamento de pares, e o modo como foi escopado é um padrão que vale copiar. Em vez de adicionar um novo ponto de decisão à lógica de kerning, o fallback pende do ramo de saída antecipada que já existia para fontes sem tabela GPOS. Uma fonte moderna com GPOS nunca o alcança, então seu comportamento não muda por construção em vez de por teste. Caminhos que não registram uma fonte Unicode produzem dois offsets zero, então também não mudam

Essa é a forma geral de um retrofit de baixo risco em uma biblioteca de renderização madura: encontre o ramo que hoje não produz nada e coloque o novo comportamento ali. Isso converte "acreditamos que isso não regrediu nada" em "isso não pode ter regredido nada", que é algo muito melhor de dizer sobre um motor de texto por que passam as faturas de outras pessoas

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

Findings de cobertura só são úteis se algo falhar por causa deles. Em um serviço gerador de documentos o arranjo produtivo é manter o rastreamento ligado no job de regresso noturno contra um corpus de nomes de clientes, endereços e descrições de produtos reais, e falhar o job com qualquer finding. Como o evento dispara uma vez por code point único em vez de uma vez por ocorrência, a saída continua pequena o suficiente para ler mesmo quando um script inteiro está ausente

Em produção o mesmo handler é mais bem usado como telemetria: registre o code point e a fonte, continue servindo o documento, e deixe o agregado dizer qual script adicionar ao conjunto de fontes da implantação em seguida. O comportamento de renderização para fontes embutidas e substituídas é coberto com mais profundidade em renderizar glifos de fontes embutidas, e a lista completa de propriedades incluindo TrackUnresolvedGlyphs está documentada na página de produto do HotPDF Delphi PDF component