Artículo técnico

Incrustar fuentes variables CFF2 en PDF con HotPDF

HotPDF incrusta fuentes variables OpenType con contornos CFF2 convirtiéndolas, en los valores de eje seleccionados, en un programa de fuente CFF1 con claves CID que los lectores PDF ya entienden. El texto nativo corriente registrado vía RegisterUnicodeTTF usa las tablas cmap y hmtx de la propia fuente, y el writer incrusta el programa convertido bajo /FontFile3 con /Subtype /CIDFontType0C, manteniendo cada CID igual al glyph ID original

Pipeline de HotPDF para incrustar una fuente variable CFF2 en Delphi como CFF1 con claves CID bajo FontFile3, con el desvío de sustitución GDI silenciosa que evita la ruta de bytes registrados
HotPDF congela la variación en los valores de eje elegidos y escribe un programa CFF1 con claves CID donde cada CID iguala el glyph ID original, así que ninguna capa traduce entre numeraciones

La ruta obvia merece describirse, porque falla sin avisar a nadie. Parsea y cachea las tablas CFF2, y luego deja que la ruta de texto plano resuelva la fuente a través del GDI de Windows con un match solo TrueType: GDI no tiene idea de qué hacer con contornos CFF2 y sustituye en silencio por ArialUnicodeMS. Todo lo que venga después y lea el sustituto recibe los contornos equivocados, los anchos equivocados y una extracción equivocada para caracteres del plano suplementario. El PDF abre bien. Simplemente no está compuesto con la fuente que pediste, y por eso la ruta de texto tiene que trabajar desde los bytes registrados en vez de una búsqueda GDI

¿Por qué un PDF no puede incrustar una fuente CFF2 directamente?

PDF no tiene hueco para un programa CFF2. ISO 32000-2 §9.9 lista los tipos de programa de fuente incrustada que un lector debe manejar: Type 1 bajo /FontFile, TrueType bajo /FontFile2, y bajo /FontFile3 un programa CFF pelado (Type1C o CIDFontType0C) o un envoltorio OpenType alrededor de contornos TrueType o CFF. Todos los sabores CFF de esa lista significan CFF versión 1, el formato que los lectores llevan dos décadas parseando

Los tres slots de fuentes incrustadas de PDF, todos limitados a CFF versión 1, y el slot CFF2 ausente que obliga a HotPDF a convertir antes de incrustar en Delphi
todos los sabores CFF que los lectores PDF deben manejar son CFF versión 1, así que una fuente variable CFF2 no tiene slot hasta que se convierte

CFF2 es un formato distinto que comparte nombre por casualidad. Suelta el INDEX de Name y String, suelta el charset, saca los avances de glyph de los CharStrings y los deja a hmtx, y añade operadores blend para que un solo contorno describa todo el espacio de diseño. Un parser CFF1 que se encuentra una tabla CFF2 no tiene base para adivinar, y nada en la especificación PDF le obliga a intentarlo. Envolver la fuente CFF2 en un stream OpenType y cruzar los dedos es exactamente el tipo de archivo que renderiza en una máquina y falla en la siguiente, así que HotPDF no lo hace

La conversión además tiene que ser estática. Como explica el artículo sobre instanciación estática de fuentes variables, una fuente PDF es un conjunto fijo de contornos con métricas fijas, y ningún lector está obligado a aplicar coordenadas de eje. HotPDF por eso resuelve primero la variación y solo entonces escribe CFF1

Escribir texto nativo en una fuente CFF2

Para el código de aplicación, una fuente CFF2 se comporta como cualquier otra fuente Unicode registrada: selecciona valores de eje, registra el archivo, selecciona la familia por nombre y dibuja. RegisterUnicodeTTF guarda una copia aparte de la tabla name original, así que SetFont iguala el nombre de familia, el nombre completo, el nombre PostScript o el nombre de familia tipográfica y selecciona los bytes de fuente estática congelados, sin instalación en Windows ni búsqueda GDI de por medio

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'brochure.pdf';
    Pdf.BeginDoc;
    // Los valores de eje aplican a fuentes registradas tras esta llamada
    Pdf.SetVariableFontAxis('wght', 650);
    Pdf.RegisterUnicodeTTF('C:\Fonts\AcmeSerifVF.otf');
    // Nombre de familia, completo, PostScript o de familia tipográfica de la tabla name
    Pdf.CurrentPage.SetFont('Acme Serif VF', [], 12, DEFAULT_CHARSET);
    Pdf.CurrentPage.UnicodeTextOut(72, 100, 0, 'Variable weight 650, CFF2 outlines');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Los glyph IDs vienen del cmap registrado y los avances del hmtx registrado. Un glyph cuyo avance es genuinamente cero se queda en cero en vez de recurrir a una medida GDI, y un par sustituto UTF-16 produce exactamente un avance, no dos. Los caracteres BMP, los del plano suplementario y los portadores sintéticos de shaping conservan mapeos Unicode por fuente, así que cambiar a otra fuente incrustada o empezar una página nueva no deja que esa otra fuente actualice el estado de subset de esta fuente ni le pida prestados sus glyph IDs

La medición sigue los mismos bytes. THPDFPage.GetWideTextWidth y THPDFPage.GetWideCharAdvances leen las métricas de la fuente seleccionada respaldada por bytes, así que el salto de línea y la alineación a la derecha coinciden con lo que se dibuja. Un detalle de orden caza a más de uno: los cambios de SetVariableFontAxis hechos tras el registro afectan a la siguiente fuente que registres, no a la instancia ya congelada. Para conseguir un segundo peso, pon el valor nuevo y registra otra vez

¿Cómo construye HPDFConvertCFF2ToCFF el programa con claves CID?

HPDFConvertCFF2ToCFF produce un programa CFF1 crudo con claves CID y un único Font DICT, en el que el CID iguala el glyph ID original en cada slot, incluidos los vacíos y sin usar. Esa identidad es todo el diseño. El nombre de fuente, los anchos /W del PDF, los avances CFF y la CMap ToUnicode se refieren todos a la misma numeración, así que ninguna capa tiene que traducir entre glyph IDs y CIDs, y nada puede desincronizarse

En el lado PDF, el descendiente es una fuente CIDFontType0 con encoding Identity-H o Identity-V, y el stream del programa queda bajo /FontFile3 con /Subtype /CIDFontType0C. Como el CID ya iguala el GID, el descendiente no lleva /CIDToGIDMap, y el stream queda excluido del hook posterior de reemplazo de streams usado para fuentes TrueType registradas. Al contrario que el envoltorio /Subtype /OpenType, el stream convertido no sube el requisito de versión PDF 1.6

Los anchos necesitaron un pequeño truco. Un CharString Type 2 de CFF1 guarda el avance como un primer operando opcional relativo a nominalWidthX, y los operandos enteros están limitados al rango con signo de 16 bits. Un avance OpenType es un valor sin signo de 16 bits, hasta 65535. HotPDF pone nominalWidthX a 32768 y defaultWidthX a 0, y luego escribe cada avance explícitamente como advance - 32768. Un avance cero se vuelve -32768 y un avance 65535 se vuelve 32767, así que todo el rango uint16 es representable sin recortar ni un glyph

Cómo mapea HotPDF los avances sin signo de 16 bits a operandos CFF1 con signo mediante nominalWidthX 32768, manteniendo cada avance de fuente en la salida PDF de Delphi sin recortes
nominalWidthX 32768 desplaza la ventana de operandos para que un avance cero se almacene como -32768 y 65535 se almacene como 32767 sin recortar ningún glyph

La geometría recibe el mismo cuidado. La FontMatrix es [1/UnitsPerEm 0 0 1/UnitsPerEm 0 0], así que una fuente de 2048 unidades conserva sus coordenadas nativas. Los movimientos y líneas relativos demasiado grandes para un operando se parten en pasos representables, y las curvas cúbicas se subdividen de forma que preservan su forma. Los registros originales de copyright y licencia, la tabla name original y las banderas de incrustación viajan dentro de la cadena Notice de CFF

Llamar al conversor fuera de un documento

El conversor es una función pública de la unidad HPDFOpenTypeAdvanced, escrita en Pascal, sin dependencia de HarfBuzz ni paso de instalación de fuentes del sistema. Eso lo hace usable por sí solo, por ejemplo para preconvertir una fuente en tiempo de build o para inspeccionar qué incrustará el writer del PDF

uses
  System.SysUtils, System.IOUtils, HPDFOpenTypeAdvanced;

procedure ConvertWeight650;
var
  Source, Cff: TBytes;
  Axes: THPDFVariableFontAxisValueArray;
begin
  Source := TFile.ReadAllBytes('C:\Fonts\AcmeSerifVF.otf');
  SetLength(Axes, 1);
  Axes[0].Tag := 'wght';
  Axes[0].Value := 650;
  Cff := HPDFConvertCFF2ToCFF(Source, Axes);
  if Length(Cff) = 0 then
    raise Exception.Create('Not convertible: not CFF2, embedding denied or bad axes');
  TFile.WriteAllBytes('AcmeSerif-650.cff', Cff);
end;

La entrada variable pasa por instanciación estática solo de geometría en las coordenadas dadas, cubriendo contornos y avances. Una fuente CFF2 estática solo acepta una selección vacía; pasarle valores de eje se trata como error en vez de ignorarse. El resultado es un programa CFF1 pelado, no un contenedor sfnt, así que no se lo entregues a código que espera un archivo OpenType

El fallo es un TBytes vacío, no una excepción, para los casos esperados: una entrada que no es CFF2, una selección de ejes que el instanciador no acepta, valores no finitos como NaN, y fuentes cuyo fsType de OS/2 marca incrustación de licencia restringida (0x0002) o incrustación solo de bitmaps (0x0200). Comprueba siempre la longitud. Dentro de EndDoc el writer lo hace por ti y lanza «StoreFont: CFF2 outline conversion failed», que es la decisión correcta: un programa de fuente en blanco en un PDF ya enviado al cliente es mucho peor que un error visible en tu log

Presupuestos y cancelación para cargas de servidor

El tercer parámetro opcional, THPDFVariableFontControl, acota la memoria y permite cancelar una conversión larga. Sin él el conversor usa sus valores por defecto: un límite de 128 MiB en entrada y salida de fuente, un presupuesto de trabajo acumulativo de 1 GiB, y el límite CFF1 de 65535 bytes por CharString

type
  TFontJob = class
  private
    FCancelled: Boolean;
    procedure CheckCancel;
  public
    function Convert(const Source: TBytes): TBytes;
    property Cancelled: Boolean read FCancelled write FCancelled;
  end;

procedure TFontJob.CheckCancel;
begin
  if FCancelled then
    Abort;  // se propaga fuera de HPDFConvertCFF2ToCFF
end;

function TFontJob.Convert(const Source: TBytes): TBytes;
var
  Control: THPDFVariableFontControl;
begin
  Control := THPDFVariableFontControl.Create(
    32 * 1024 * 1024,           // bytes máximos de salida
    Int64(256) * 1024 * 1024,   // presupuesto de trabajo acumulativo
    CheckCancel);
  try
    // Selección nil: instancia por defecto de una fuente variable, o una fuente CFF2 estática
    Result := HPDFConvertCFF2ToCFF(Source, nil, Control);
  finally
    Control.Free;
  end;
end;

Los excesos de presupuesto y la cancelación se reportan como excepciones, deliberadamente distintas de la ruta de resultado vacío. Un array vacío significa que la fuente no se puede convertir; una excepción significa que este intento se detuvo. Mantén ambos separados en tu manejo de errores, porque reintentar una fuente no convertible con un presupuesto mayor no ayuda

¿Cuánto pesa una fuente CFF2 incrustada?

Lo principal que hay que planificar es el tamaño de archivo: HotPDF incrusta el conjunto completo de glyphs convertidos para fuentes CFF2, esté EnableFontSubsetting activado o no. Una fuente con varios miles de glyphs añade todos a cada documento que la usa, aunque la página muestre un solo titular. Para un folleto es aceptable. Para un servidor de informes que escribe miles de PDF pequeños es un coste real que conviene medir antes de comprometerse

Las funciones de tamaño relacionadas siguen la misma regla. Las fuentes CFF2 convertidas se incrustan enteras, así que ni los subsets compactos de fuentes que activa CompressDocument ni la caché persistente de subsets de fuentes les afectan. Las fuentes TrueType del mismo documento siguen subsetándose y cacheándose como siempre. La única ventaja de conservar cada glyph es que las fuentes cuya licencia prohíbe el subsetting se incrustan en una forma que lo respeta

Los límites de recursos son límites finitos de conversión, y el propio CFF1 acota lo que un solo CharString puede contener. Un glyph cuyo CharString convertido pasa de 65535 bytes, o una fuente que reventa el presupuesto de trabajo, falla visiblemente en vez de escribirse con un contorno vacío. Ese es el trade-off buscado: un fallo ruidoso en una fuente exótica le gana a un documento con glyphs ausentes en silencio

El texto CFF2 nativo y el conversor portable a CFF1 se distribuyen con el componente PDF HotPDF para Delphi para Delphi y C++Builder, junto a las funciones de instanciación de fuentes variables, glyphs de color y subsetting de fuentes cubiertas en otros artículos de este blog