Artigo Técnico

Renderizar glifos compostos e acentos seac de CFF em PDF

O HotPDF renderiza letras acentuadas de fontes incorporadas corretamente tratando dois mecanismos de fontes que constroem um glifo a partir de outros: glifos compostos TrueType, cujos offsets de componente têm de ser aplicados em unidades de glifo antes da matriz glifo-para-dispositivo, e a forma seac de endchar em CFF, que une uma letra base e um acento por código StandardEncoding. Errar qualquer um e é, Å, ü ou uma aspa dupla curva sai a faltar uma parte, enquanto as letras simples à volta parecem perfeitas

Esse último detalhe é o que torna estes bugs caros. Um relatório renderiza, o corpo do texto está bom, e só "München" tem um buraco, ou uma citação abre com uma aspa simples e um vazio. Ninguém desconfia do renderizador. A primeira teoria é sempre uma fonte danificada ou um produtor mau, e o bilhete anda às voltas durante uma semana. Este artigo percorre o que realmente falha em cada mecanismo, como o componente Delphi HotPDF o trata desde a v2.768.10 e v2.768.11, e a correção de subsetting relacionada na v2.770.83 que mantém os glifos compostos intactos quando o HotPDF escreve fontes em vez de as ler

Porque é que só o acento desaparece de um glifo PDF?

O acento desaparece porque é a única parte do glifo que traz um offset diferente de zero. Tanto fontes TrueType como CFF poupam espaço reutilizando contornos: é não é desenhado como uma forma nova mas como "o glifo e, mais o glifo agudo deslocado para cima e para a direita". A letra base assenta quase sempre no offset (0, 0), por isso qualquer erro na forma como os offsets são aplicados deixa-a intacta. O acento é o componente que se move, por isso é o componente que aterra no sítio errado ou nunca chega a ser desenhado

O padrão do sintoma é um diagnóstico por si só. Se as letras base de caracteres acentuados renderizam e os diacríticos não, ou se pontuação empar como “ e ” aparece como um só sinal em vez de dois, pare de olhar para o ficheiro da fonte. Uma fonte partida normalmente parte glifos inteiros, e parte-os em todos os visualizadores. Pedaços em falta de glifos compostos num só renderizador, enquanto o Acrobat mostra a página bem, apontam para o código que monta os componentes

Como é que glifos compostos TrueType colocam os seus componentes?

Um glifo composto TrueType coloca cada componente com a sua própria pequena transformação afim, expressa em unidades de fonte, e essa transformação tem de atuar sobre o contorno do componente antes da matriz glifo-para-dispositivo. Na tabela glyf do OpenType, um glifo composto tem uma contagem de contornos negativa, seguida de uma lista de registos de componente. Cada registo guarda uma palavra de flags, o índice de glifo do componente, dois argumentos que são bytes ou palavras (ARG_1_AND_2_ARE_WORDS, 0x0001), e uma escala opcional: um valor F2Dot14 para WE_HAVE_A_SCALE (0x0008), dois para WE_HAVE_AN_X_AND_Y_SCALE (0x0040), ou uma matriz 2x2 completa para WE_HAVE_A_TWO_BY_TWO (0x0080). Quando ARGS_ARE_XY_VALUES (0x0002) está ligado, os dois argumentos são um offset dx/dy em unidades de fonte. MORE_COMPONENTS (0x0020) diz que se segue outro registo

Por isso, para cada componente, constrói-se uma matriz de componente CM a partir da parte de escala e do offset, e depois combina-se com M, a matriz que mapeia unidades de fonte para pixéis de dispositivo. A ordem dessa combinação é todo o bug. A multiplicação de matrizes interna do HotPDF usa a convenção de vetor-coluna, por isso o produto de A e B aplica B primeiro e depois A. A composição correta aplica CM primeiro, em espaço de glifo, e M depois. Escrita ao contrário, M corre primeiro e o offset de CM é acrescentado depois, já em pixéis de dispositivo e nunca escalado

// Apenas ilustração: um record afim 2D com a convenção de vetor-coluna,
// em que Mul(A, B) mapeia um ponto primeiro por B e depois por A
type
  TAffine = record
    a, b, c, d, e, f: Double;
  end;

function Mul(const A, B: TAffine): TAffine;
begin
  Result.a := A.a * B.a + A.c * B.b;
  Result.b := A.b * B.a + A.d * B.b;
  Result.c := A.a * B.c + A.c * B.d;
  Result.d := A.b * B.c + A.d * B.d;
  Result.e := A.a * B.e + A.c * B.f + A.e;
  Result.f := A.b * B.e + A.d * B.f + A.f;
end;

// M  : unidades de fonte -> pixéis de dispositivo (escala minúscula, y invertido)
// CM : transformação do componente; e/f guardam dx/dy em unidades de fonte
Sub := Mul(M, CM);   // certo: offset escalado para baixo com o contorno
// Sub := Mul(CM, M); // errado: dx/dy caem como pixéis de dispositivo brutos

Os números tornam a falha óbvia mal a veja. A Arial usa um em de 2048 unidades, e o componente do acento de é traz o seu offset nessas unidades de fonte. Com a ordem correta esse offset encolhe junto com o contorno para uns pixéis ao zoom normal. Com a ordem invertida o valor bruto em unidades de fonte é acrescentado como pixéis de dispositivo, e na renderização de teste do HotPDF o acento do é da Arial acabou a 243 pixéis da sua letra, muito fora do glifo e normalmente fora de tudo o que fosse visível. A aspa dupla esquerda da Arial são duas cópias da forma de aspa simples, a segunda deslocada, por isso renderizava como uma ‘ solitária seguida de espaço vazio

A parte linear raramente expõe o bug. Componentes de acento normalmente não trazem escala nenhuma, e uma escala uniforme comuta com M de qualquer forma, por isso só a translação revela o produto invertido. É também por isso que um teste rápido com texto latino simples passa: a inversão tinha estado no código ao longo de muitas versões antes de a v2.768.10 a corrigir, e só componentes com offsets a mostravam

Recursão e ciclos

Glifos compostos podem referenciar outros glifos compostos, por isso um renderizador percorre-os recursivamente e tem de se defender. O HotPDF limita a profundidade de aninhamento a 5 níveis e a contagem total de componentes por glifo a 1024, salta um componente que nomeie o seu próprio índice de glifo, e trata o modo de correspondência de pontos raramente usado (argumentos que são números de pontos em vez de offsets) como um offset zero. Uma fonte hostil ou corrompida pode por isso perder um componente, mas não consegue pendurar a renderização da página

O que é a forma seac do endchar CFF?

A forma seac do endchar é uma forma descontinuada de uma charstring CFF Type 2 dizer "este glifo é uma letra base mais um acento": quando o endchar tem quatro argumentos, são adx ady bchar achar. A Adobe Technical Note #5177 descreve-a no seu apêndice sobre compatibilidade com Type 1. O nome vem do operador seac do Type 1 (standard encoding accented character), e subconjuntos CFF convertidos de fontes Type 1 ainda usam-no intensivamente para letras latinas acentuadas

Três detalhes fazem tropeçar os implementadores. Primeiro, bchar e achar não são glyph IDs. São códigos na Adobe StandardEncoding, por isso o renderizador mapeia cada código para um nome de glifo e depois procura esse nome através do charset da fonte para encontrar o GID. Segundo, o Type 2 não tem argumento asb, ao contrário do seac do Type 1, que leva asb adx ady bchar achar. No Type 2 a origem do acento vai diretamente para (adx, ady) relativo à origem do glifo base, sem correção de side-bearing. Terceiro, fontes CFF CID-keyed nem podem usar seac, porque não há charset de nomes de glifo através do qual resolver códigos StandardEncoding

Depois há a largura. Toda a charstring Type 2 pode começar o seu primeiro operador de limpeza da stack com um argumento opcional de largura de avanço, e o endchar é um desses operadores. Um endchar simples com um argumento traz uma largura; um endchar seac com cinco argumentos traz uma largura seguida dos quatro valores seac. A regra é contar argumentos contra a contagem fixa esperada, que é quatro para seac. Antes da v2.768.11, o HotPDF tratava qualquer endchar como "toma a largura e para", por isso glifos seac produziam um contorno vazio: sem base, sem acento, apenas um espaço com o tamanho certo na linha

// Ilustração da decisão do endchar num interpretador Type 2.
// A stack guarda os operandos que precedem endchar; Count é quantos.
if (Count >= 4) and not IsCIDFont then
begin
  // 5 operandos: o primeiro é a largura de avanço, o resto é seac
  if Count = 5 then
    TakeWidthAndShift;                 // deita fora a largura, guarda 4 operandos
  if StackDepth = 4 then
  begin
    BaseGID   := GIDForName(StdEncName(Round(Stack[2])));  // bchar
    AccentGID := GIDForName(StdEncName(Round(Stack[3])));  // achar
    DrawGlyph(BaseGID, Identity);
    DrawGlyph(AccentGID, Translate(Stack[0], Stack[1]));   // adx, ady
    Exit;
  end;
end;
// caso contrário: endchar comum, largura opcional, fechar o path

No interpretador real ambos os glifos de componente são executados com o mesmo motor de charstring, a matriz do acento é a matriz de glifo atual composta com uma translação para (adx, ady), e a matriz é reposta depois para os glifos seguintes na linha não serem afetados. Se qualquer um dos nomes não resolver para um GID, esse componente é saltado em vez de falhar o glifo inteiro, que é o comportamento indulgente que se quer quando um subconjunto foi aparado um pouco demasiado agressivamente

Verificar a correção a partir do seu próprio código Delphi

A forma mais rápida de verificar o tratamento de glifos compostos e seac é renderizar uma página com texto acentuado de fontes incorporadas por ambos os caminhos de output públicos: THotPDF.RenderLoadedPageToBitmap para output raster e THotPDF.BuildLoadedPageSVG com EmbedFonts em True para output vetorial. Ambos partilham o mesmo código de contorno de glifo, por isso ambos receberam as correções v2.768.10 e v2.768.11. O pipeline como um todo está coberto em como o HotPDF renderiza glifos TrueType, CFF e Type 3 incorporados, e as opções de bitmap em renderizar páginas PDF para bitmaps em Delphi

uses
  System.Classes, Vcl.Graphics, HPDFDoc;

procedure RenderAccentProbe(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  Svg: AnsiString;
  Lines: TStringList;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;

    // Caminho raster: 144 DPI torna acentos fora do sítio fáceis de detetar
    Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);
    if Bmp <> nil then
    try
      Bmp.SaveToFile('accent-probe.bmp');
    finally
      Bmp.Free;
    end;

    // Caminho vetorial: EmbedFonts = True escreve contornos de glifos, por isso a
    // montagem composite e seac fica visível nos próprios paths do SVG
    Svg := Pdf.BuildLoadedPageSVG(0, True);
    if Svg <> '' then
    begin
      Lines := TStringList.Create;
      try
        Lines.Text := string(Svg);
        Lines.SaveToFile('accent-probe.svg');
      finally
        Lines.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Escolha os documentos de teste com intenção. Quer um PDF com um subconjunto TrueType incorporado contendo é, Å e aspas duplas curvas, e um com um subconjunto CFF (FontFile3, Type1C) produzido a partir de uma fonte Type 1, que é onde o seac ainda vive, tipicamente em output de diagramação antigo e alguns workflows de impressão. Texto composto em fontes que o produtor não incorporou não lhe diz nada aqui, porque esses glifos vêm das fontes Windows instaladas e não do programa de fonte incorporado descrito na ISO 32000-1 §9.9

Porque é que os glifos compostos partiam durante o subsetting de fontes?

Os glifos compostos partiam durante o subsetting porque o subsetter percorria os registos de componente com o passo errado sempre que um componente trazia uma escala ou transformação 2x2, e depois lia mal o índice de glifo do componente seguinte. O HotPDF corrigiu isto na v2.770.83. O subsetting é o gémeo do lado da escrita do bug de renderização: quando o HotPDF incorpora um subconjunto de uma fonte TrueType instalada, tem de manter todos os glifos que um composto referencia e renumerar essas referências para os novos glyph IDs compactos

O antigo percorredor só olhava para a flag de tamanho dos argumentos e para a flag more-components, por isso avançava 6 ou 8 bytes por registo. Ignorava os 2, 4 ou 8 bytes de dados de transformação que as flags de escala acrescentam. Depois de um componente com escala, o próximo "índice de glifo" que lia eram na verdade bytes de transformação, por isso o componente real podia ficar de fora do subconjunto, e o passo de renumeração podia escrever um glyph ID novo diretamente na transformação. O resultado era uma letra acentuada que renderizava em alguns visualizadores com o acento errado, um acento distorcido ou nenhum, e apenas para fontes que escalam os seus componentes, razão pela qual a maioria dos testes nunca o apanhava

Mais dois problemas sentavam-se ao lado do bug do passo. Renumerar um índice de glifo não é idempotente, e compostos aninhados estavam a ser renumerados duas vezes: uma pela passagem sobre todos os compostos marcados e outra por uma descida recursiva para dentro deles. O HotPDF agora renumera cada composto exatamente uma vez e conduz o passo "que glifos precisa este composto" a partir de uma fila de trabalho, por isso um ciclo de compostos que se referenciem mutuamente termina, índices de glifo além da contagem de glifos do maxp da fonte são rejeitados, e glifos vazios nunca são analisados como compostos

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'accents-subset.pdf';
    Pdf.FontEmbedding := True;          // incorporar os glifos que são usados
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 14);
    // é, Å, ü e aspas curvas são todos glifos compostos em Arial
    Pdf.CurrentPage.UnicodeTextOut(50, 720, 0,
      'Café, Ångström, München, “quoted”');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Faça o round-trip desse ficheiro pela sonda acima e por um segundo visualizador. Desde a v2.770.83 todos os componentes de todos os glifos compostos sobrevivem ao subsetting, incluindo componentes atrás de uma escala ou transformação 2x2, por isso os acentos renderizam da mesma forma no HotPDF, no Acrobat e nos visualizadores de browser

O que o tratamento de glifos compostos não cobre

A correção dos glifos compostos cobre o posicionamento por offset, que é o que quase todas as fontes reais usam, e aproxima o resto de forma conservadora. Compostos TrueType de correspondência de pontos, em que os dois argumentos nomeiam pontos a alinhar em vez de dar um offset, são desenhados com offset zero. Seac em fontes CFF CID-keyed é ignorado por design porque a especificação o proíbe ali. Um seac cujo código de base ou de acento não tem glifo num subconjunto aparado desenha o componente que conseguir encontrar. Nenhum destes casos rebenta o renderizador, mas se produz PDFs para uso de arquivo ou PDF/A, o movimento mais seguro é incorporar fontes que usem compostos por offset e charstrings Type 2 sem seac, para todos os consumidores, e não só um renderizador, verem as mesmas letras

FAQ

Porque é que letras acentuadas renderizam sem os acentos no meu renderizador PDF?

Letras acentuadas são normalmente glifos compostos ou glifos seac CFF, e o acento é o único componente com offset. Um renderizador que aplique o offset do componente depois da matriz de dispositivo, ou que ignore o seac no endchar, deita fora ou desloca o acento enquanto a letra base renderiza normalmente

O HotPDF suporta a forma seac de endchar CFF?

Sim. Desde a v2.768.11 o HotPDF desenha um endchar com quatro argumentos, ou cinco incluindo a largura, como um glifo base mais um acento deslocado para (adx, ady), resolvendo ambos através de nomes StandardEncoding e do charset da fonte, tanto para renderização de bitmap como para exportação SVG com fontes incorporadas

Os glifos compostos preservam-se quando o HotPDF faz subsetting de fontes TrueType?

Sim. Desde a v2.770.83 o HotPDF salta corretamente transformações de componente de 2, 4 ou 8 bytes, mantém todos os componentes referenciados no subconjunto, renumera compostos aninhados uma vez, e para em segurança em referências de glifo cíclicas ou fora do intervalo

Renderização de glifos compostos, suporte a seac CFF e subsetting TrueType seguro vêm como parte do HotPDF Delphi PDF Component, uma biblioteca VCL nativa para Delphi e C++Builder que cria, edita, extrai e renderiza documentos PDF sem DLLs externas