Artículo técnico

Detectar glifos PDF faltantes al dibujar en Delphi

Un glifo faltante 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 archivo que sale es estructuralmente válido, se abre en todas partes y muestra una caja vacía donde debería estar un nombre o un importe. Nadie en el pipeline de generación se entera. El destinatario sí. HotPDF cierra ese círculo con TrackUnresolvedGlyphs: actívenlo y la vía de dibujo de texto registra cada punto de código cuya búsqueda de glifo se 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 su manejador. Juntas convierten una clase de defecto que antes reportaban los clientes en una comprobación en tiempo de compilación

Por qué un glifo faltante no levanta nada

Porque ISO 32000 no impone ninguna obligación al productor de 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. 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 de origen y no desde los contornos, así que una comprobación automática de ida y vuelta pasará felizmente un documento cuyo texto visible tiene huecos

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

La consecuencia práctica es que la cobertura tiene que comprobarse en el momento del dibujo, cuando la biblioteca todavía sabe qué punto de código se pidió y qué glifo ofreció realmente la fuente. Después la información ya no existe

El detector debe vigilar el estado del subset, no el contexto de dispositivo

Aquí fue donde la primera implementación se equivocó, y la razón vale entenderla porque aplica a cualquier comprobación de cobertura acoplada a un pipeline de texto. HotPDF tiene dos vías 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 vía GDI antigua que crea un contexto de dispositivo y un handle de fuente nuevos por cada corrida de caracteres

Juzgar la cobertura desde la vía GDI no tiene esperanza. Su mapeo no es el mapeo que acaba en el flujo de contenido emitido, y ambos no están sincronizados, así que un detector que lee resultados GDI reporta todo el rango ASCII imprimible como no resuelto. La respuesta autoritativa vive en la fuente registrada: el mapa de caracteres que RegisterUnicodeTTF analiza, consultado a través de GetUnicodeGlyphForCodepoint. El detector queda entonces condicionado al estado de subset listo, no a ninguna condición GDI, y simplemente no corre sobre documentos que nunca registraron una fuente Unicode, lo que es correcto porque esos documentos están limitados de todos modos a las codificaciones estándar

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 puedan normalizar: una familia llamada Arial Unicode MS lleva el nombre PostScript ArialMT. Toda guarda escrita como "la fuente seleccionada actualmente es la que registramos", comparada por nombre, es código muerto que nunca se dispara. Condicionen al estado, nunca a los nombres de fuente

Flujo de detección de glifos no resueltos de HotPDF que muestra la guarda por estado de subset, 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 prueben un detector de glifos con emoji

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

Usen 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 quieren 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 manera 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 vez 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;

// Conectándolo a 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
    // Fallar el trabajo en lugar de entregar una página con cajas
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

Las cadenas de respaldo son por escritura, no por fuente

La razón por la que el respaldo se acota por escritura y no por fuente de origen es que los huecos de cobertura se agrupan por sistema de escritura. A una fuente de texto latina le faltan devanagari, 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 respaldo 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 de cuerpo latina sin han, árabe o emoji cae a una fuente que sí los 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 respaldo y la detección son complementarios y no alternativos. Las cadenas atienden la cobertura que anticiparon; el detector reporta la cobertura que no, que en un sistema que procesa datos arbitrarios de clientes es la mitad interesante. Nótese que sustituir una fuente cambia las métricas, así que un párrafo con respaldo puede refluir; si la maquetación importa, el comportamiento de cierre y de subsetting de la fuente sustituta vale la pena estudiarlo en el artículo de cierre de subconjuntos de fuentes, y las escrituras que necesitan reordenamiento o unión las maneja la etapa de shaping descrita en el shaping de texto de escrituras complejas

Cómo añadir comportamiento retroactivamente sin arriesgar la vía existente

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

Esa es la forma general de una mejora retroactiva de bajo riesgo en una biblioteca de renderizado madura: encontrar la rama que hoy no produce nada y poner el nuevo comportamiento ahí. Convierte "creemos que esto no hizo regresiones" en "esto no puede haber hecho regresiones", que es algo mucho mejor que decir de un motor de texto por el que pasan las facturas de otros

Háganlo una compuerta, no un registro

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 rastreo encendido en el trabajo nocturno de regresión contra un corpus de nombres, direcciones y descripciones de producto de clientes reales, y fallar el trabajo ante cualquier hallazgo. Como el evento se dispara una vez por punto de código único y no una vez por aparición, la salida se mantiene 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 les diga qué escritura añadir al conjunto de fuentes del despliegue. 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 incluida TrackUnresolvedGlyphs está documentada en la página del producto HotPDF Delphi PDF component