Los glifos con shaping se renderizan como recuadros .notdef cuando un generador de subconjuntos de fuente conserva solo los glifos alcanzables desde los puntos de código emitidos. HotPDF, el componente PDF VCL nativo para Delphi y C++Builder, arrastró exactamente ese defecto hasta la versión 2.435.0: la salida de GSUB de OpenType se registraba en un mapa de bits de uso interno que el generador de subconjuntos declaraba que iba a respetar y que después nunca llegaba a leer
Este es un fallo distinto al descrito en el error de EndDoc que deshabilitaba en silencio el subconjunto de fuentes. Aquel error trataba sobre cuándo se ejecutaba el subconjunto en relación con la serialización, y deshabilitaba el subconjunto por completo. Este trata sobre qué contiene el subconjunto cuando el proceso se ejecuta perfectamente y a tiempo. La canalización se dispara en el momento correcto, el prefijo de subconjunto de seis letras aparece en /BaseFont exactamente como exige ISO 32000-1 §9.6.4, el archivo se hace más pequeño, cada página en latín se revisa limpia, y una página en árabe sale como una fila de rectángulos vacíos. Los errores de orden son ruidosos en cuanto los miras. Los errores de cierre permanecen silenciosos para siempre, porque el subconjunto es estructuralmente válido y solo se equivoca respecto a su propia lista de pertenencia
Por qué los glifos con shaping se renderizan como .notdef
Porque el conjunto de puntos de código que emite un documento no es el conjunto de glifos que el documento dibuja, y un generador de subconjuntos que confunde ambos descarta cada glifo producido por el shaping. El shaping de texto convierte una secuencia lógica de caracteres en una secuencia de glifos posicionados, y todo su propósito es producir glifos que ningún carácter de entrada individual mapea: una heh medial árabe, una ligadura fi, un conjunto devanagari, una alternativa contextual seleccionada por la característica rclt. Cada uno de esos es un ID de glifo que una búsqueda de GSUB fabricó, no uno que la tabla cmap te entrega para ningún carácter de tu cadena. Un generador de subconjuntos impulsado únicamente por el cmap está, por lo tanto, recorriendo el índice equivocado. Conserva fielmente cada glifo que el texto podría haber usado antes del shaping y descarta precisamente los glifos que el texto sí usa después del shaping. El renderizador entonces le pide a la fuente incrustada el GID 1847, el subconjunto puso a cero esa entrada en loca, y en su lugar regresa el índice de glifo 0. El índice de glifo 0 es .notdef por definición de OpenType, que es por qué la firma del fallo es un recuadro vacío en lugar de una letra equivocada o un fallo. Nada en el PDF está mal formado; la fuente simplemente no contiene el glifo que el flujo de contenido solicitó
Los puntos de código no son glifos: las tres fuentes de un subconjunto
Un cierre de subconjunto correcto tiene que unir tres fuentes independientes, cada una con su propio acumulador. La primera es el conjunto derivado de puntos de código: HotPDF acumula FUnicodeUsedCps a medida que se emiten caracteres del BMP y FUnicodeSmpUsed para caracteres del plano suplementario alcanzados mediante pares sustitutos (surrogate pairs), y luego mapea cada uno a través de FUnicodeCpToGid hacia un ID de glifo. La segunda es el conjunto derivado del shaping, los ID de glifo que una sustitución GSUB produjo, registrados mediante MarkUnicodeGlyphUsed y EnableShapingFeatureForSubset dentro de FUnicodeExtraUsedGlyphs. La tercera es el cierre de compuestos: un glifo cuyo numberOfContours es -1 en glyf se ensambla a partir de ID de glifo componentes, y conservar el compuesto mientras se descartan sus componentes produce un contorno vacío en lugar de un .notdef, lo cual es discutiblemente peor porque se lee como un error de espaciado
HotPDF siempre manejó la primera y la tercera. BuildAndApplyUnicodeFontSubset, el punto de entrada del subconjunto que EndDoc llama antes de la serialización, siembra el arreglo de glifos usados con el GID 0, recorre los puntos de código del BMP, recorre la lista de uso del SMP, y entrega el arreglo a un constructor de subconjuntos que resuelve los componentes compuestos internamente. La segunda fuente estaba escrita pero nunca se consumía, y como las tres fuentes fallan con contenido distinto, la brecha puede ocultarse durante años en una base de código cuyo corpus de regresión es mayormente en latín
El arreglo que se escribía y nunca se leía
El contrato estaba documentado en tres lugares y no se respetaba en ninguno de ellos. La declaración de FUnicodeExtraUsedGlyphs establecía que el generador de subconjuntos de EndDoc lo une con el uso derivado de puntos de código; el comentario de encabezado de ApplyArabicGSUBRefinement prometía que cada GID sustituto emitido pasa por MarkUnicodeGlyphUsed para que el generador de subconjuntos incorpore el glifo a la fuente incrustada; la misma promesa aparece textualmente en ApplyArabicGSUBContextualRefinement para la ruta rclt. Ambas partes que invocaban la función cumplieron su mitad. Un grep sobre cada referencia al campo resolvió la otra mitad en unos noventa segundos: una declaración, una asignación con SetLength dentro de RegisterUnicodeTTF, y escrituras en las dos rutinas de marcado. Ni una sola lectura. Ese es el diagnóstico que vale la pena interiorizar, porque se generaliza bien más allá de las fuentes. Cuando un campo se escribe desde varios puntos de la llamada y no se lee desde ninguno, la característica que representa no existe, por muy exhaustivamente comentada que esté. El paso 1 del generador de subconjuntos es lo bastante pequeño para leerse en una sola pantalla, y la brecha es obvia en cuanto sabes qué buscar
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
La corrección de un solo bucle, y cómo marcar los glifos tú mismo
La corrección es una unión, y su argumento de seguridad proviene de la dirección de la operación: solo activa bits, nunca los limpia, así que ningún glifo que antes sobrevivía al subconjunto puede empezar a descartarse
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
Tres propiedades hacen de esto un cambio de bajo riesgo en lugar de una reescritura del motor de fuentes. Es monótono, como se explicó arriba. Es un no-op en fuentes que nunca aplicaron shaping a nada, ya que FUnicodeExtraUsedGlyphs permanece todo en falso y la salida en bytes para un documento solo en latín no cambia. Y se aplica antes del paso 2, así que ambos constructores de subconjuntos lo heredan: el constructor disperso que preserva la numeración de GID original, y el constructor compacto _BuildCompactSubsetTTF que HotPDF selecciona bajo PDF/A para renumerar los glifos conservados en un rango denso, reducir maxp.numGlyphs, y emitir el mapeo de antiguo a nuevo como el flujo /CIDToGIDMap que exige ISO 32000-1 §9.7.4.2. Ambos llaman internamente a _TTFWalkCompositeClosure, así que un glifo con shaping que resulta ser compuesto ahora también arrastra a sus componentes. El cierre de compuestos nunca estuvo roto; simplemente nunca se alcanzaba para estos ID de glifo, porque los ID de glifo no estaban en el conjunto que recorre. Si manejas el motor de GSUB directamente en lugar de depender de los pasos de refinamiento incorporados, el cierre pasa a ser tu responsabilidad, y cada ID de glifo sustituto que emitas debe marcarse antes de que EndDoc congele el conjunto de glifos usados
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset es la contraparte por lotes de la llamada de un solo GID, y es deliberadamente conservadora. Recorre la lista de búsquedas de GSUB en busca de las búsquedas conectadas a una etiqueta de característica de cuatro bytes bajo la ruta de escritura e idioma actualmente seleccionada, y marca los ID de glifo sustitutos que esas búsquedas pueden producir. Es un no-op defensivo cuando la fuente no lleva tabla GSUB o cuando la característica está ausente en esa ruta, así que llamarla incondicionalmente es seguro. También es una sobreaproximación por diseño: puede conservar glifos que un documento dado nunca dibuja. Para el subconjunto, la sobreinclusión cuesta bytes y la subinclusión cuesta corrección, lo que hace que ese trueque sea una decisión fácil. La estructura de estas búsquedas, y las tablas de cobertura que deciden qué glifos participan, se cubre en el recorrido por las alternativas estilísticas de GSUB en Delphi puro
Cómo demostrar que el glifo realmente está en el subconjunto
Leyendo la fuente emitida, no observando la página a simple vista en un visor que puede estar sustituyendo una fuente del sistema a tus espaldas. La comprobación que atrapa toda esta clase de error es mecánica: extrae el flujo /FontFile2 del PDF de salida, analiza loca, y confirma que el ID de glifo que esperas lleva una entrada no vacía, es decir, que sus desplazamientos de inicio y fin difieren. Una entrada vacía es el generador de subconjuntos habiendo decidido que el glifo no se usa. Dos hábitos hacen entonces mucho más difícil volver a enviar el fallo. Mantén una página con escritura shaped en el corpus de pruebas de humo automatizadas en lugar de solo en el conjunto de revisión manual, porque el árabe, el devanagari y el jemer ejercitan rutas de cierre que ninguna cantidad de cobertura en latín llegará a tocar. Y siempre que exista un acumulador, comprueba que algo lo consuma, ya que un campo de solo escritura es una característica que compila, pasa las pruebas en el corpus equivocado, y no hace nada
Dónde se detiene la corrección
El cierre del subconjunto es necesario para que un glifo con shaping se renderice, y no es suficiente. El glifo también tiene que ser direccionable desde el flujo de contenido, que es un problema aparte con su propio límite. Los pasos de refinamiento árabe incorporados de HotPDF confirman una sustitución solo cuando cada ID de glifo sustituto es alcanzable a través de un punto de código de forma de presentación Unicode mediante un escaneo inverso de cmap sobre aproximadamente 690 puntos de código entre U+FB50 y U+FDFF y U+FE70 y U+FEFF. Cuando un sustituto cae en un ID de glifo fuera de ese rango, la ventana de entrada pasa sin cambios en lugar de emitir algo que el lector no pueda direccionar; las alternativas específicas de fuente en ID de glifo arbitrarios necesitan un punto de código de uso privado sintético asignado en U+E000 a U+F8FF para transportarlas a través de la ruta de emisión. Así que el resumen honesto es que la corrección de 2.435.0 eliminó un bloqueador duro en lugar de completar la historia. Antes de ella, un glifo podía tener shaping correcto, emitirse correctamente, y aun así desaparecer al momento del subconjunto, lo que significaba que no se podía confiar en el motor de shaping de extremo a extremo por muy buenas que fueran sus búsquedas. Lo que queda es la direccionabilidad, y esa restricción al menos falla de forma visible en el punto de emisión en lugar de en silencio en un paso de compilación que se ejecuta después de todo lo que estabas observando. Para el lado de emisión de la misma canalización, ve la guía sobre shaping de texto árabe y RTL en PDF con Delphi
El subconjunto de fuentes, el motor GSUB y el shaping de escrituras complejas descritos aquí se incluyen en el HotPDF Component estándar para Delphi y C++Builder; la página de producto incluye la referencia completa de la API para las llamadas de fuentes Unicode y shaping mencionadas arriba