Artículo técnico

Cachear subconjuntos de fuentes en disco con HotPDF

Dos facturas renderizadas desde la misma plantilla con la misma tipografía TrueType, que difieren solo en el apellido del cliente, dejan dos archivos separados en una caché de subconjuntos de fuentes. El apellido añade una Q mayúscula y una z minúscula que la primera factura nunca usó, así que el conjunto de glifos conservados cambia, y con él el subconjunto y la clave de caché. Una caché de subconjuntos persistente rinde cuando el repertorio de glifos de tus documentos se repite, y los contadores que expone te dicen en un solo lote si es tu caso

Diagrama de las cuatro claves de la caché de subsets de fuentes en HotPDF: contenido de la fuente, conjunto de glifos, modo de subset y versión de esquema, con dos facturas que difieren en un nombre aterrizando en entradas distintas
dos facturas que difieren en un solo nombre de cliente son dos entradas de caché — la caché solo rinde cuando los documentos comparten un repertorio de glifos

Activar la caché en disco en HotPDF

HotPDF mantiene la caché de subconjuntos apagada hasta que le das una carpeta. Dos propiedades de THotPDF la controlan: FontSubsetCacheFolder nombra el directorio y FontSubsetCacheMaxBytes limita cuánto puede ocupar, con 268435456 bytes (256 MiB) por defecto. La caché solo está activa mientras la carpeta no esté vacía y el presupuesto sea mayor que cero. Asignar la carpeta crea el directorio si falta y recorta al momento lo que ya hubiera dentro hasta el presupuesto

El subsetting merece caché porque es trabajo de verdad. Construir un subconjunto TrueType significa recorrer la clausura de glifos compuestos, reescribir loca y glyf, reconstruir las tablas que indexan glifos y producir un mapeo CID que el PDF pueda direccionar. En un documento el coste se pierde en el ruido. En un servidor de informes que renderiza miles de extractos en un bucle con las mismas tres fuentes, los mismos subconjuntos se construyen una y otra vez, y esa es la CPU que la caché te devuelve

var
  Pdf: THotPDF;
  Info: THPDFFontSubsetCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.EnableFontSubsetting := True;
    // ruta solo ASCII: la propiedad es un AnsiString
    Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
    Pdf.FontSubsetCacheMaxBytes := 64 * 1024 * 1024;   // 64 MiB en lugar de los 256 MiB por defecto
    // ... BeginDoc / dibujo / EndDoc para cada documento del lote ...
    Info := Pdf.GetFontSubsetCacheInfo;
    // LogFmt es tu propia rutina de logging
    LogFmt('subset cache: %d hits, %d misses, %d bytes in %d files',
      [Info.HitCount, Info.MissCount, Info.CurrentBytes, Info.FileCount]);
  finally
    Pdf.Free;
  end;
end;

La caché está detrás de las dos vías de subsetting: las fuentes se subdividen con EnableFontSubsetting, que cubre fuentes TrueType y OpenType-CFF, y la fuente registrada con RegisterUnicodeTTF. Nada más cambia en tu código de dibujo. La ruta de carpeta está declarada como AnsiString, así que un nombre de directorio con caracteres fuera de la página de códigos del sistema está pidiendo problemas; una ruta ASCII sencilla bajo ProgramData o en un directorio de datos de servicio esquiva la cuestión por completo

Qué entra en una clave de caché

Cuatro entradas deciden si un subconjunto almacenado se puede reutilizar: los bytes exactos de la fuente original, el conjunto de glifos que conserva el documento, el modo de subset y la versión del formato de caché. Cada clave se construye con un hash SHA-256 de los bytes de la fuente y un hash SHA-256 de un bitmap de los IDs de glifo usados, con el prefijo v1- y el sufijo -s para un subconjunto disperso o -c para uno compacto. Las entradas aterrizan en disco como <key>.hpdfsubset, así que un listado del directorio te muestra de un vistazo qué modo está produciendo tu lote

Hashear el contenido de la fuente en lugar de su nombre de archivo tiene una consecuencia práctica. Actualizar Arial o cambiar a una build más nueva de una fuente corporativa produce claves nuevas automáticamente, y las entradas viejas simplemente dejan de recibir aciertos hasta que la expulsión las elimina. No tienes que vaciar la caché tras una actualización de fuente para obtener una salida correcta. Vaciar solo reclama el espacio antes

El modo de subset es la entrada que la gente olvida. CompactFontSubsetting renumera los glifos conservados en un rango denso y escribe un /CIDToGIDMap explícito (ISO 32000-1 §9.7.4.2), y la fuente registrada recibe siempre un subconjunto compacto cuando PDFACompliance está activo. CompressDocument también activa los subconjuntos compactos mientras dura el documento. Así, un servicio que escribe el mismo extracto una vez como PDF normal y otra como PDF/A llena dos conjuntos de entradas para glifos idénticos, unos terminados en -s y otros en -c. Las fuentes OpenType-CFF toman siempre la vía dispersa

El conjunto de glifos es donde se decide la tasa de aciertos. Extractos de una plantilla fija, formularios cuyos datos variables son mayormente dígitos, catálogos sacados de una sola base de datos de productos: todos reutilizan los mismos glifos documento tras documento. Una carga donde cada documento tira de una porción distinta de una fuente CJK con decenas de miles de glifos falla casi siempre, porque el bitmap de casi cada documento es nuevo. Ejecuta un lote representativo y lee los contadores antes de decidir qué tipo de carga tienes

¿Cómo sabes si la caché está haciendo algo?

GetFontSubsetCacheInfo devuelve un registro THPDFFontSubsetCacheInfo con nueve campos, y comparar HitCount con MissCount responde la pregunta directamente. WriteCount y EvictionCount muestran si las entradas viven lo suficiente para reutilizarse o si un presupuesto demasiado justo las expulsa. CurrentBytes y FileCount describen la carpeta tal y como está ahora mismo

Panel de HotPDF de los nueve contadores de GetFontSubsetCacheInfo agrupados en tasa de acierto, supervivencia y recuentos de fallos, con tres lecturas que nombran el ajuste a cambiar
la proporción entre HitCount y MissCount responde si la caché está haciendo algo, y los demás contadores explican por qué no

Dos detalles importan cuando conectas esto a tu monitorización. Los siete contadores de eventos pertenecen a la instancia de THotPDF y arrancan a cero cuando se crea el componente, así que un servicio que crea un componente por documento ve, en cada lectura, la actividad de un solo documento. CurrentBytes y FileCount, en cambio, salen de enumerar la carpeta en el momento de la llamada, lo que hace que reflejen todos los procesos que usan esa carpeta y que cada llamada cueste un escaneo de directorio. Lee el registro una vez por lote, al final, y deja el bucle por documento libre de ello

Los tres contadores restantes son los que merecen una alerta. CorruptCount cuenta entradas que fallaron la validación al cargar y fueron borradas; cada una cuenta también como fallo. Un puñado tras un corte de luz es lo esperado, y un goteo constante apunta al almacenamiento. RejectedCount cuenta subconjuntos que la caché se negó a almacenar, normalmente porque un solo subconjunto es mayor que todo el presupuesto. WriteFailureCount cuenta escrituras que no terminaron, lo que en la práctica casi siempre es una carpeta en la que la cuenta del servicio no puede escribir. Ninguno de los tres interrumpe la generación de documentos, así que una caché con un problema de permisos de escritura produce exactamente los mismos PDFs que ninguna caché. Solo que nunca se vuelve más rápida

// SubsetCacheNeedsAttention es una ayuda que añades tú;
// los umbrales son ejemplos, ajústalos al tamaño de tu lote
function SubsetCacheNeedsAttention(const Info: THPDFFontSubsetCacheInfo;
  out Reason: string): Boolean;
begin
  Reason := '';
  if Info.WriteFailureCount > 0 then
    Reason := 'cache folder is not writable for this account'
  else if Info.RejectedCount > Info.WriteCount then
    Reason := 'subsets exceed FontSubsetCacheMaxBytes, raise the budget'
  else if (Info.EvictionCount > Info.WriteCount div 2) and (Info.HitCount < Info.MissCount) then
    Reason := 'entries are evicted before they are reused'
  else if Info.CorruptCount > 10 then
    Reason := 'repeated damaged entries, check the storage';
  Result := Reason <> '';
end;

Presupuestos, expulsión y el interruptor que vacía la carpeta

La expulsión es least-recently-used por fecha de modificación del archivo. Cada carga con éxito toca el timestamp de la entrada, cada escritura va seguida de un recorte, y los archivos más viejos se van primero hasta que la carpeta vuelve a caber en el presupuesto. Bajar FontSubsetCacheMaxBytes en tiempo de ejecución recorta en el acto en lugar de esperar a la siguiente escritura, así que un servicio que reacciona a una alerta de espacio en disco puede encoger la caché justo cuando decide hacerlo

Esa inmediatez tiene un filo peligroso. Poner el presupuesto a cero con una carpeta configurada recorta a cero, lo que borra cada archivo .hpdfsubset de la carpeta. Poner FontSubsetCacheFolder a una cadena vacía, en cambio, apaga la caché y deja los archivos exactamente donde están. Los dos interruptores desactivan la caché para el siguiente documento; solo uno de ellos conserva una semana de entradas calientes. Para diagnosticar, vacía la propiedad de carpeta y no toques el presupuesto

// Pausa la caché para una ejecución de diagnóstico, conserva cada entrada almacenada
Pdf.FontSubsetCacheFolder := '';

// Peligro: con carpeta configurada, un presupuesto cero recorta la carpeta a nada
// Pdf.FontSubsetCacheMaxBytes := 0;

// Reset deliberado: borra *.hpdfsubset y archivos temporales sobrantes,
// los contadores de eventos conservan sus valores
Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
Pdf.ClearFontSubsetCache;

Una ejecución de diagnóstico con la caché en pausa es la forma más limpia de descartarla. La etiqueta de subset de seis letras en /BaseFont se deriva del conjunto de glifos conservados, así que los mismos glifos producen la misma etiqueta con o sin caché, y los programas de fuente incrustados deberían coincidir. Los hashes del archivo completo seguirán siendo distintos por las fechas de creación y el ID del documento, así que compara los streams de fuente, no los archivos

¿Qué pasa con una entrada dañada o escrita a medias?

Una entrada dañada se borra y el subconjunto se construye con normalidad, antes de que nada de esa entrada llegue a un stream PDF. Al cargar, HotPDF comprueba la firma del archivo, la versión del formato, el flag de modo de subset, el recuento de glifos contra la fuente original, las longitudes declaradas contra el tamaño real del archivo y la cabecera sfnt del programa de fuente almacenado. Cualquier discrepancia descarta la entrada, incrementa CorruptCount y cae en el subsetting en memoria. Meter un programa de fuente roto en el PDF de un cliente aparecería semanas después en un visor de la máquina de otro, lejos de su causa, y ese es el fallo que esta validación existe para evitar

Diagrama de flujo del manejo de entradas de caché dañadas en HotPDF: validar antes de usar, rechazar y eliminar antes de cualquier stream PDF, recurrir al subsetting en memoria
las entradas dañadas fallan en abierto — el documento se produce igualmente, y la salida PDF/A mantiene la caché porque las entradas conservan el remapeo CID

Las entradas escritas a medias ni siquiera llegan a existir. Una escritura va a un archivo temporal cuyo nombre lleva el ID de proceso y la instancia del componente, y después lo mueve sobre el nombre final con un único rename de reemplazo y vaciado. Un cuelgue a mitad de escritura deja un archivo .tmp- suelto, que ClearFontSubsetCache barre, y la entrada real es o la versión vieja completa o la nueva completa. Las entradas compactas guardan además el mapa de glifos viejo-a-nuevo junto al programa de fuente, así que un subconjunto compacto cacheado reconstruye el mismo /CIDToGIDMap y la salida PDF/A puede usar la caché sin saltársela

¿Dónde debería vivir la carpeta?

Tres condiciones lo deciden: la cuenta del servicio debe poder escribir ahí, la carpeta debería estar en almacenamiento local, y ningún paso de despliegue debería borrarla. Un recurso de red convierte cada búsqueda en un viaje de ida y vuelta y pone la semántica de rename en manos del servidor de archivos. Una carpeta dentro del directorio de la aplicación que el instalador recrea significa que la caché arranca fría tras cada actualización, lo que borra silenciosamente el beneficio justo el día que más de cerca estás mirando el rendimiento

Varios componentes y varios procesos pueden compartir una carpeta local. Los nombres temporales nunca colisionan, el rename final es atómico, y perder una carrera no cuesta nada peor que un fallo o una escritura que haya que reintentar en el siguiente documento. Compartir es el valor por defecto recomendable en una misma máquina, porque cada instancia calienta la caché para las demás. El único efecto secundario visible es que los contadores de cada instancia describen solo su propio tráfico, mientras que CurrentBytes y FileCount describen el de todos, y un tick ocasional de WriteFailureCount puede venir de dos procesos reemplazando la misma entrada a la vez. Una carpeta de solo lectura sigue sirviendo las entradas existentes; solo no puede refrescar sus timestamps ni añadir nuevas

Cuándo la caché es la palanca equivocada

La caché elimina trabajo repetido. No hace nada por el primer documento de un lote y poco por cargas cuyos conjuntos de glifos nunca se repiten. Si la salida está dominada por una gran fuente CJK usada con texto impredecible, mira qué glifos acaban dentro de cada subconjunto y por qué, tratado en las notas sobre el cierre de subconjuntos y los glifos compuestos. Si el objetivo es el tamaño de archivo y no la CPU, el artículo sobre CompressDocument y los subconjuntos compactos explica qué cambia el modo compacto en la fuente incrustada. Cuando un lote resulta lento por motivos ajenos a las fuentes, el recorrido de la salida de informes con fuentes e imágenes muestra a dónde se va normalmente el resto del tiempo, y el análisis de el orden de subconjuntos en EndDoc cubre cuándo se construyen los subconjuntos durante la finalización del documento

Como la caché de subconjuntos vive dentro del componente, un servidor de informes en Delphi o C++Builder la obtiene con fijar una ruta de carpeta y un presupuesto, sin ningún servicio extra que desplegar ni monitorizar. La incrustación de fuentes, el subsetting y el resto de funciones de generación PDF están listados en la página del componente HotPDF para Delphi