Artículo técnico

Detectar glifos PDF ausentes al dibujar en Delphi

Un glifo ausente en un PDF no es un error. El productor pide un carácter que la fuente seleccionada no puede mapear, la fuente devuelve el índice de glifo cero, y el fichero que sale es estructuralmente válido, se abre en todas partes y muestra una caja vacía donde debería ir un nombre o un importe. Nadie en el pipeline generador se entera. El destinatario sí. HotPDF cierra ese círculo con TrackUnresolvedGlyphs: actívalo y la ruta de dibujo de texto registra cada punto de código cuya búsqueda de glifo resuelve al índice cero, disparando OnUnresolvedGlyph una vez por hallazgo único con el punto de código, la fuente en la que falló, la escritura a la que pertenece y una sugerencia de fuentes que lo cubrirían

La detección es la mitad de la respuesta. La otra mitad es SetFontFallbackChain, que registra una lista ordenada de fuentes por escritura, de modo que los casos comunes se resuelven solos y solo los huecos genuinos llegan a vuestro manejador. Juntos convierten una clase de defecto que antes la reportaban los clientes en una comprobación en tiempo de compilación

Por qué un glifo ausente no levanta nada?

Porque ISO 32000 no obliga a un productor a verificar la cobertura, y el índice de glifo cero es un glifo legítimo. Es .notdef, cuyo contorno elige el diseñador de la fuente: normalmente un rectángulo vacío o hueco, a veces nada en absoluto. Un visor que lo dibuja se comporta correctamente. La extracción de texto puede incluso devolver los caracteres correctos, porque el mapeo /ToUnicode se escribe desde el texto fuente y no desde los contornos, así que una comprobación automática de ida y vuelta pasará felizmente un documento cuyo texto visible tiene agujeros

Diagrama de por qué un glifo PDF ausente permanece silencioso mientras el glifo cero dibuja una caja vacía y la extracción ToUnicode supera las comprobaciones de ida y vuelta
El glifo cero es una respuesta .notdef legítima y /ToUnicode se escribe desde el texto fuente, así que nada en el pipeline se entera del hueco

La consecuencia práctica es que la cobertura hay que comprobarla en el momento del dibujo, cuando la biblioteca aún sabe qué punto de código se pidió y qué glifo ofreció realmente la fuente. Después la información ya se ha ido

El detector debe vigilar el estado de subconjunto, no el contexto de dispositivo

Aquí es donde la primera implementación se equivocó, y el motivo merece entenderse porque aplica a cualquier comprobación de cobertura acoplada a un pipeline de texto. HotPDF tiene dos rutas de texto. Una emite a través de una fuente TrueType Unicode registrada con un mapa de caracteres en memoria construido al registrarla. La otra es una ruta GDI legada que crea un contexto de dispositivo y un handle de fuente frescos por cada pasada de caracteres

Juzgar la cobertura desde la ruta GDI es desesperado. Su mapeo no es el mapeo que acaba en el content stream emitido, y ambos no están sincronizados, así que un detector que lee resultados GDI informa de todo el rango ASCII imprimible como no resuelto. La respuesta con autoridad vive en la fuente registrada: el mapa de caracteres que RegisterUnicodeTTF analiza, consultado a través de GetUnicodeGlyphForCodepoint. El detector queda por tanto condicionado al estado de subconjunto preparado, no a condición GDI alguna, y sencillamente no se ejecuta en documentos que nunca registraron una fuente Unicode, lo cual es correcto porque esos documentos están restringidos a las codificaciones estándar de todos modos

Una segunda trampa se sienta al lado. El nombre de familia GDI de una fuente y el nombre PostScript extraído del binario de la fuente al registrarla son cadenas distintas, y no de una forma que podáis normalizar: una familia llamada Arial Unicode MS lleva el nombre PostScript ArialMT. Cualquier puerta escrita como «la fuente seleccionada actualmente es la que registramos», comparando por nombre, es código muerto que nunca se dispara. Condicionad al estado, nunca a nombres de fuentes

Flujo de detección de glifos no resueltos de HotPDF mostrando la puerta de estado de subconjunto, la consulta GetUnicodeGlyphForCodepoint y el cableado del evento OnUnresolvedGlyph
La cobertura se juzga desde el mapa de la fuente Unicode registrada y no desde GDI, y cada punto de código único dispara un evento con una sugerencia de fuente

No probéis un detector de glifos con emoji

El caso de prueba obvio es una carita sonriente, y os convencerá de que el detector está roto. Los puntos de código emoji comunes en los planos astrales resuelven por una ruta de síntesis de uso privado que los mapea directamente a un índice de glifo, así que nunca llegan a la rama general de cobertura. El detector se comporta correctamente y la prueba está midiendo la ruta equivocada

Usad en cambio un punto de código sin asignar. U+0378 está permanentemente sin asignar en Unicode, así que ninguna fuente puede mapearlo legítimamente, y ejercita exactamente la rama que queréis verificar. Esa distinción entre «la función está rota» y «la prueba eligió una entrada que esquiva la función» cuesta horas reales, y los puntos de código sin asignar son la forma más barata de evitarlo

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
  // Se dispara una vez por punto de código único, no una por aparición
  FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
    [Info.CodePoint, String(Info.FontName), Ord(Info.Script),
     String(Info.SuggestedFonts)]));
end;

// Cableándolo en un trabajo de generación
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
    // Hacer fallar el trabajo en lugar de enviar una página con cajas
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

Las cadenas de fallback son por escritura, no por fuente

La razón de que el fallback se scope por escritura y no por fuente de origen es que los huecos de cobertura se agrupan por sistema de escritura. Una fuente de texto latina carece de devanágari, tailandés, han y emoji, todo a la vez, y el sustituto de cada uno es una fuente distinta. Declarar una cadena por escritura describe, por tanto, el despliegue real: una fuente latina para el cuerpo, una fuente CJK, una fuente emoji, una comodín

Diagrama de fallback de fuentes por escritura que mapea las escrituras hfsCJK, hfsArabic, hfsEmoji y hfsOther a cadenas ordenadas de fuentes sustitutas en HotPDF
Cada escritura recibe su propia cadena ordenada, así que una fuente latina de cuerpo sin han, árabe o emoji cae a una fuente que la cubre
// THPDFFontScript cubre hfsCommon, hfsLatin, hfsGreek, hfsCyrillic,
// hfsHebrew, hfsArabic, hfsIndic, hfsSoutheastAsian, hfsCJK, hfsKana,
// hfsHangul, hfsEmoji y 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']);

El fallback y la detección son complementarios, no alternativos. Las cadenas manejan la cobertura que anticipasteis; el detector informa de la que no, que en un sistema que procesa datos de clientes arbitrarios es la mitad interesante. Nótese que sustituir una fuente cambia las métricas, así que un párrafo que cae al fallback puede refluir; si la maquetación importa, el comportamiento de cierre y subsetting de la fuente sustituida merece leerse en el artículo de cierre de subconjuntos de fuentes, y las escrituras que necesitan reordenación o unión las maneja la etapa de shaping descrita en el shaping de texto de escrituras complejas

Cómo añadir comportamiento retroactivo sin arriesgar la ruta existente

La misma versión añadió un fallback de tabla kern legada para el espaciado de pares, y la forma de acotarlo es un patrón digno de copiar. En lugar de añadir un nuevo punto de decisión a la lógica de kerning, el fallback cuelga de la rama de salida temprana que ya existía para fuentes sin tabla GPOS. Una fuente moderna con GPOS nunca llega a ella, así que su comportamiento queda sin cambios por construcción y no por pruebas. Las rutas que no registran una fuente Unicode producen dos offsets cero, así que también quedan sin cambios

Esa es la forma general de una adición retroactiva de bajo riesgo en una biblioteca de renderizado madura: encontrar la rama que actualmente no produce nada y poner ahí el nuevo comportamiento. Convierte «creemos que esto no regresó nada» en «esto no puede haber regresado nada», que es mucho mejor cosa que decir de un motor de texto por el que pasan las facturas de otros

Hacedlo una puerta, no un log

Los hallazgos de cobertura solo son útiles si algo falla por ellos. En un servicio de generación de documentos el arreglo productivo es mantener el tracking activado en el trabajo nocturno de regresión contra un corpus de nombres de clientes, direcciones y descripciones de producto reales, y hacer fallar el trabajo ante cualquier hallazgo. Como el evento se dispara una vez por punto de código único y no una por aparición, la salida se queda lo bastante pequeña para leerse incluso cuando falta una escritura entera

En producción el mismo manejador funciona mejor como telemetría: registrar el punto de código y la fuente, seguir sirviendo el documento y dejar que el agregado os diga qué escritura añadir al conjunto de fuentes del despliegue a continuación. El comportamiento de renderizado para fuentes incrustadas y sustituidas se cubre más a fondo en el renderizado de glifos de fuentes incrustadas, y la lista completa de propiedades incluido TrackUnresolvedGlyphs está documentada en la página de producto de HotPDF Delphi PDF component