Artículo técnico

Insertar 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 ordinario registrado mediante 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 ID de glifo original

La ruta obvia vale la pena describirla, porque falla sin avisarle a nadie. Parsee y guarde en caché las tablas CFF2, y luego deje que la ruta de texto plano resuelva la fuente por Windows GDI con una coincidencia solo TrueType: GDI no tiene idea de qué hacer con contornos CFF2 y sustituye ArialUnicodeMS silenciosamente. Todo lo que lea la sustituta aguas abajo recibe los contornos equivocados, los anchos equivocados y la extracción equivocada para caracteres del plano suplementario. El PDF abre bien. Simplemente no está compuesto en la fuente que pidió, y por eso la ruta de texto tiene que trabajar desde los bytes registrados en vez de una búsqueda GDI

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

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

El PDF no tiene un slot para un programa CFF2. ISO 32000-2 §9.9 lista los tipos de programas de fuente incrustada que un lector debe manejar: Type 1 bajo /FontFile, TrueType bajo /FontFile2, y bajo /FontFile3 un programa CFF desnudo (Type1C o CIDFontType0C) o un wrapper OpenType alrededor de contornos TrueType o CFF. Cada sabor CFF de esa lista significa CFF versión 1, el formato que los lectores han parseado por dos décadas

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

CFF2 es un formato distinto que casualmente comparte un nombre. Elimina los INDEX de Name y String, elimina el charset, saca los avances de glifo de los CharStrings y los deja a hmtx, y agrega 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 lo obliga a intentarlo. Envolver la fuente CFF2 en un stream OpenType y esperar lo mejor 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 la variación primero y solo entonces escribe CFF1

Escribir texto nativo en una fuente CFF2

Para el código de la aplicación, una fuente CFF2 se comporta como cualquier otra fuente Unicode registrada: seleccione valores de eje, registre el archivo, seleccione la familia por nombre y dibuje. RegisterUnicodeTTF conserva una copia aparte de la tabla name original, así que SetFont coincide con el nombre de familia, nombre completo, nombre PostScript o 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 después de esta llamada
    Pdf.SetVariableFontAxis('wght', 650);
    Pdf.RegisterUnicodeTTF('C:\Fonts\AcmeSerifVF.otf');
    // Nombre de familia, completo, PostScript o 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 IDs de glifo vienen del cmap registrado y los avances del hmtx registrado. Un glifo cuyo avance es genuinamente cero se queda en cero en vez de recurrir a una medición GDI, y un par surrogate UTF-16 produce exactamente un avance, no dos. Los caracteres BMP, los caracteres suplementarios y los carriers 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 tome prestados sus IDs de glifo

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 quiebre de líneas y la alineación a la derecha concuerdan con lo que se dibuja. Un detalle de orden atrapa a la gente: los cambios de SetVariableFontAxis hechos tras el registro afectan la siguiente fuente que registre, no la instancia ya congelada. Para obtener un segundo weight, ponga el nuevo valor y registre de nuevo

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

HPDFConvertCFF2ToCFF produce un programa CFF1 crudo con claves CID y un solo Font DICT, en el que el CID iguala el ID de glifo original en cada slot, incluidos los vacíos y sin usar. Esa identidad es todo el diseño. El nombre de la 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 IDs de glifo y CIDs, y nada puede desincronizarse

Del 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. A diferencia del wrapper /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 en 32768 y defaultWidthX en 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 hacer clamp a un solo glifo

Cómo HotPDF mapea avances sin signo de 16 bits a operandos con signo de CFF1 con nominalWidthX 32768, manteniendo cada avance de fuente en la salida PDF de Delphi sin clamp
nominalWidthX 32768 desplaza la ventana del operador para que un avance cero se guarde como -32768 y 65535 se guarde como 32767 sin hacer clamp a ningún glifo

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 dividen en pasos representables, y las curvas cúbicas se subdividen de una forma que preserva su forma. Los registros originales de copyright y licencia, la tabla name original y las banderas de embedding viajan en la cadena Notice del 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 utilizable 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 acepta solo una selección vacía; pasarle valores de eje se trata como error en vez de ignorarse. El resultado es un programa CFF1 desnudo, no un contenedor sfnt, así que no se lo entregue 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 pone embedding de licencia restringida (0x0002) o embedding solo bitmap (0x0200). Pruebe siempre la longitud. Dentro de EndDoc el writer lo hace por usted y lanza "StoreFont: CFF2 outline conversion failed", que es la llamada correcta: un programa de fuente en blanco en un PDF distribuido es mucho peor que un error visible en su log

Presupuestos y cancelación para cargas de servidor

El tercer parámetro opcional, THPDFVariableFontControl, acota la memoria y le permite cancelar una conversión larga. Sin él el conversor usa sus valores por defecto: un límite de 128 MiB sobre 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 arreglo vacío significa que la fuente no puede convertirse; una excepción significa que este intento fue detenido. Mantenga esos dos separados en su manejo de errores, porque reintentar una fuente no convertible con un presupuesto mayor no va a ayudar

¿Cuánto pesa una fuente CFF2 incrustada?

Lo principal que hay que planear es el tamaño de archivo: HotPDF incrusta el conjunto completo de glifos convertido para fuentes CFF2, esté EnableFontSubsetting activado o no. Una fuente con varios miles de glifos agrega todos a cada documento que la usa, incluso si la página muestra un solo titular. Para un folleto es aceptable. Para un servidor de reportes que escribe miles de PDF pequeños es un costo real que debería medir antes de comprometerse

Las funciones de tamaño relacionadas siguen la misma regla. Las fuentes CFF2 convertidas se incrustan completas, así que ni los subsets compactos de fuentes que habilita CompressDocument ni la caché persistente de subsets de fuentes les aplican. Las fuentes TrueType del mismo documento siguen subconjuntándose y guardándose en caché como siempre. El único beneficio de conservar cada glifo es que las fuentes cuya licencia prohíbe subsetting se incrustan en una forma que lo respeta

Los límites de recursos son límites de conversión finitos, y el propio CFF1 acota lo que un solo CharString puede contener. Un glifo cuyo CharString convertido excede 65535 bytes, o una fuente que se pasa del 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 glifos que faltan 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 con las funciones de instanciación de fuentes variables, glifos a color y subsetting de fuentes cubiertas en otros artículos de este blog