Los glifos con shaping aplicado se renderizan como cajas .notdef cuando un generador de subconjuntos de fuente conserva solo los glifos alcanzables desde los puntos de código emitidos. HotPDF, el componente VCL nativo de PDF para Delphi y C++Builder, arrastraba exactamente ese defecto hasta la versión 2.435.0: la salida de GSUB de OpenType se registraba en un bitmap interno de uso que el generador de subconjuntos declaraba que iba a respetar y que en realidad nunca llegaba a leer
Este es un fallo distinto del descrito en el bug de EndDoc que desactivaba silenciosamente el subsetting de fuentes. Aquel bug tenía que ver con cuándo se ejecutaba el subsetting respecto a la serialización, y lo desactivaba por completo. Este tiene que ver con qué contiene el subconjunto cuando el subsetting se ejecuta perfectamente en el momento previsto. El pipeline se dispara en el instante correcto, el prefijo de seis letras del subconjunto aparece en /BaseFont exactamente como exige ISO 32000-1 §9.6.4, el fichero se reduce, cada página en latín pasa la revisión sin problemas, y una página en árabe sale como una fila de rectángulos vacíos. Los bugs de ordenación son ruidosos en cuanto los miras. Los bugs de closure permanecen silenciosos para siempre, porque el subconjunto es estructuralmente válido y solo se equivoca en 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 ambas cosas descarta todos los glifos producidos por el shaping. El shaping de texto convierte una secuencia lógica de caracteres en una secuencia de glifos posicionados, y su propósito completo es producir glifos que ningún carácter de entrada individual mapea: una heh medial árabe, una ligadura fi, un conjunto devanagari, un alternativo contextual seleccionado por la característica rclt. Cada uno de esos es un ID de glifo fabricado por una búsqueda GSUB, no uno que la tabla cmap te entrega para ningún carácter de tu cadena. Un generador de subconjuntos guiado únicamente por el cmap está, por 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 usa después del shaping. El renderizador entonces le pide a la fuente incrustada el GID 1847, el subconjunto ha puesto a cero esa entrada en loca, y en su lugar vuelve el índice de glifo 0. El índice de glifo 0 es .notdef por definición de OpenType, razón por la cual la firma del fallo es una caja vacía en lugar de una letra incorrecta o un cuelgue. Nada en el PDF está mal formado; la fuente simplemente no contiene el glifo que el content stream solicitó
Los puntos de código no son glifos: las tres fuentes de un subconjunto
Un closure de subconjunto correcto tiene que unificar 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 BMP y FUnicodeSmpUsed para caracteres del plano suplementario alcanzados mediante pares subrogados, y después mapea cada uno a través de FUnicodeCpToGid a un ID de glifo. La segunda es el conjunto derivado del shaping, los IDs de glifo que produjo una sustitución GSUB, registrados mediante MarkUnicodeGlyphUsed y EnableShapingFeatureForSubset en FUnicodeExtraUsedGlyphs. La tercera es el closure de compuestos: un glifo cuyo numberOfContours es -1 en glyf se ensambla a partir de IDs de glifo de componente, 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 interpreta como un bug de espaciado
HotPDF siempre había gestionado correctamente la primera y la tercera fuente. BuildAndApplyUnicodeFontSubset, el punto de entrada del subsetting que EndDoc invoca antes de la serialización, inicializa el array de glifos usados con el GID 0, recorre los puntos de código BMP, recorre la lista de uso SMP, y entrega el array a un generador de subconjuntos que resuelve internamente los componentes compuestos. La segunda fuente estaba escrita pero nunca se consumía, y como las tres fuentes fallan con contenidos distintos, la brecha puede permanecer oculta durante años en una base de código cuyo corpus de regresión es mayoritariamente latino
El array que se escribía y nunca se leía
El contrato estaba documentado en tres sitios y no se respetaba en ninguno de ellos. La declaración de FUnicodeExtraUsedGlyphs afirmaba que el subsetter de EndDoc lo unificaba con el uso derivado de puntos de código; el comentario de cabecera de ApplyArabicGSUBRefinement prometía que cada GID sustituto emitido pasaba por MarkUnicodeGlyphUsed para que el generador de subconjuntos incorporase el glifo a la fuente incrustada; la misma promesa aparece literalmente en ApplyArabicGSUBContextualRefinement para la ruta rclt. Ambos llamantes cumplían su mitad. Un grep sobre cada referencia al campo resolvió la otra mitad en unos noventa segundos: una declaración, una asignación SetLength dentro de RegisterUnicodeTTF, y escrituras en las dos rutinas de marcado. Ni una sola lectura. Ese es el diagnóstico que merece la pena interiorizar, porque generaliza muy bien más allá de las fuentes. Cuando un campo lo escriben varios puntos de llamada y no lo lee 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 como para leerlo en una sola pantalla, y la brecha resulta obvia en cuanto sabes dónde buscarla
// 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
El único bucle de corrección, y cómo marcar los glifos tú mismo
La reparación es una unión, y su garantía de seguridad procede de la dirección de la operación: solo activa bits, nunca los desactiva, 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 convierten esto en un cambio de bajo riesgo en lugar de una reescritura del motor de fuentes. Es monótono, como se ha explicado. Es un no-op en fuentes que nunca aplicaron shaping, ya que FUnicodeExtraUsedGlyphs permanece todo en False y la salida en bytes de un documento solo en latín no cambia. Y se sitúa antes del paso 2, de modo que ambos generadores de subconjuntos lo heredan: el generador disperso que conserva la numeración original de GID, y el generador 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 stream /CIDToGIDMap que exige ISO 32000-1 §9.7.4.2. Ambos invocan internamente _TTFWalkCompositeClosure, así que un glifo con shaping que resulta ser compuesto ahora también arrastra consigo sus componentes. El closure de compuestos nunca estuvo roto; simplemente nunca se alcanzaba para estos IDs de glifo, porque los IDs de glifo no estaban en el conjunto que recorre. Si manejas el motor GSUB directamente en lugar de apoyarte en los pases de refinamiento integrados, el closure pasa a ser responsabilidad tuya, 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 contrapartida por lotes de la llamada de GID individual, y es deliberadamente conservadora. Recorre la lista de búsquedas GSUB en busca de las búsquedas vinculadas a una etiqueta de característica de cuatro bytes bajo la ruta de script e idioma actualmente seleccionada, y marca los IDs de glifo sustituto 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 de esa ruta, así que invocarla incondicionalmente es seguro. También es una sobreaproximación por diseño: puede conservar glifos que un documento dado nunca llega a dibujar. Para el subsetting, la sobreinclusión cuesta bytes y la infrainclusión cuesta corrección, lo que convierte esa disyuntiva en 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 los alternativos estilísticos de GSUB en Delphi puro
¿Cómo se demuestra que el glifo está realmente en el subconjunto?
Leyendo la fuente emitida, no observando la página de un vistazo en un visor que puede estar sustituyendo una fuente del sistema a tus espaldas. La comprobación que atrapa toda esta clase de bug es mecánica: extraer el stream /FontFile2 del PDF de salida, analizar loca, y confirmar que el ID de glifo que esperas lleva una entrada no vacía, es decir, que sus offsets de inicio y fin difieren. Una entrada vacía significa que el generador de subconjuntos decidió que el glifo no se usaba. Dos hábitos hacen entonces mucho más difícil volver a enviar el fallo. Mantener una página de script con shaping en el corpus de smoke tests automatizado en lugar de solo en el conjunto de revisión manual, porque el árabe, el devanagari y el jemer ejercitan rutas de closure que ninguna cobertura latina llegará a tocar. Y siempre que exista un acumulador, comprobar que algo lo consume, ya que un campo de solo escritura es una característica que compila, pasa los tests en verde sobre el corpus equivocado, y no hace nada
Dónde se detiene la corrección
El closure 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 content stream, lo cual es un problema aparte con su propio límite. Los pases de refinamiento árabe integrados de HotPDF solo confirman una sustitución 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 barrido inverso de cmap sobre aproximadamente 690 puntos de código en U+FB50 a U+FDFF y U+FE70 a U+FEFF. Cuando un sustituto recae 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; los alternativos específicos de fuente en IDs de glifo arbitrarios necesitan un punto de código de uso privado sintético asignado en U+E000 a U+F8FF para transportarlos 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 bloqueo duro en lugar de completar la historia. Antes de ella, un glifo podía tener shaping correcto, emitirse correctamente, y aun así desaparecer en el momento del subsetting, lo cual significaba que no se podía confiar de extremo a extremo en el motor de shaping 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 silenciosamente en un paso de compilación que se ejecuta después de todo lo que estabas observando. Para el lado de emisión del mismo pipeline, véase la guía de shaping de texto árabe y RTL en PDFs Delphi
El subsetting de fuentes, el motor GSUB y el shaping de scripts complejos descritos aquí se incluyen en el HotPDF Component estándar para Delphi y C++Builder; la página del producto incluye la referencia completa de la API para las llamadas de fuente Unicode y shaping mencionadas más arriba