wandres.dev
FUENTES Y RENDIMIENTO · El texto que aparece rápido

Subsetting y unicode-range: mandar solo los glifos que se usan

La diferencia entre recortar el fichero y decidir si se descarga, los comandos concretos que generan un subconjunto, la aritmética de la fuente variable frente a los pesos estáticos, y hasta dónde se puede recortar sin romper el texto.

⏱ 19 min

Una fuente completa moderna trae varios miles de glifos: alfabeto latino con todas sus extensiones, cirílico, griego, símbolos monetarios, flechas, fracciones. Un sitio en castellano usa alrededor de doscientos. Los dos mecanismos que existen para no pagar por lo que no se usa resuelven problemas distintos y se combinan: uno recorta el fichero, el otro decide si el fichero se pide siquiera. Confundirlos es la razón de que mucha gente aplique uno y crea que ha aplicado los dos.

🎯 Al terminar esta lección sabrás
  • Distinguir el recorte del fichero de la selección por rango de caracteres.
  • Generar un subconjunto WOFF2 con un comando reproducible.
  • Calcular si una fuente variable sale más barata que los pesos estáticos.
  • Detectar caracteres del documento que el subconjunto no cubre.

Dos mecanismos que no son el mismo

El recorte del fichero. Se hace en el proceso de compilación, con una herramienta, y produce un fichero nuevo con menos glifos. Es una transformación del binario: quitas los glifos, quitas sus contornos, quitas las entradas de la tabla de emparejamiento de caracteres, y el fichero pesa menos. Es la única de las dos que reduce bytes.

La selección por rango de caracteres. Se declara en el CSS con unicode-range, y no toca el fichero. Lo que hace es decirle al navegador para qué caracteres sirve esa cara, de modo que solo la pida si en la página hay al menos un carácter que caiga dentro. Es la única de las dos que evita peticiones enteras.

Las dos juntas son el patrón completo: partes la fuente en varios ficheros recortados, uno por bloque de escritura, y declaras el rango de cada uno. El navegador descarga exactamente los bloques que tu texto necesita.

@font-face {
  font-family: 'Cuerpo';
  src: url('/fuentes/cuerpo-latin.woff2') format('woff2');
  font-weight: 100 900;
  font-display: optional;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC, U+2122;
}

@font-face {
  font-family: 'Cuerpo';
  src: url('/fuentes/cuerpo-latin-ext.woff2') format('woff2');
  font-weight: 100 900;
  font-display: optional;
  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB;
}

Las dos reglas declaran la misma familia. Eso no es un error: es el mecanismo. El navegador las trata como dos caras de la misma familia que cubren rangos distintos, y elige la que corresponde a cada carácter que tiene que pintar. Una página sin ningún carácter del segundo rango no descarga el segundo fichero jamás.

Y el corolario que hay que tener presente: basta un carácter para descargar el fichero entero. Una comilla tipográfica, una raya de diálogo o un símbolo de moneda que caiga en un rango separado provoca la descarga completa de esa cara por diez glifos visibles. Fragmentar en exceso es contraproducente, y la partición sensata para un sitio occidental son dos o tres bloques, no doce.

Generar el subconjunto

La herramienta de referencia es la suite de tipografía de Python, que trae el recortador. La instalación necesita el soporte de compresión:

pip install "fonttools[woff]" brotli

Y el comando que produce un subconjunto latino equivalente al que sirven los proveedores habituales:

pyftsubset MiFuente-Variable.ttf \
  --output-file=cuerpo-latin.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga,calt,ccmp,locl,mark,mkmk,rlig' \
  --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"

Tres opciones merecen explicación.

--flavor=woff2 produce directamente el formato comprimido. WOFF2 usa el mismo compresor que Brotli más una transformación específica de tipografía, y suele quedar alrededor de un 30% por debajo del formato anterior y cerca de la mitad de un TrueType en crudo. No hay ninguna razón para servir otra cosa en 2026.

--layout-features es la que más daño hace por omisión. El recortador conserva por defecto un conjunto conservador de características de composición y descarta el resto, y entre lo que se puede perder está el interletraje, las ligaturas y las sustituciones contextuales. El resultado es una tipografía que se ve sutilmente peor y que nadie sabe por qué. La lista de arriba cubre lo que necesita un texto latino normal; si tu fuente tiene características que usas explícitamente —versalitas, cifras tabulares, alternativas estilísticas— hay que añadirlas por su etiqueta.

--unicodes acepta rangos y puntos de código sueltos. Copiar el rango de un proveedor conocido es una buena base porque está bien pensado, y conviene revisarlo contra tu contenido real.

Para saber qué caracteres usa un sitio de verdad existe una herramienta que lo recorre con un navegador sin interfaz y devuelve el conjunto:

npx glyphhanger https://mi-sitio.example --subset=fuentes/cuerpo.ttf --formats=woff2

Es cómoda y hay que usarla con criterio, por lo que se explica en el callout.

La aritmética de la fuente variable

Una fuente variable lleva un solo juego de contornos maestros más los datos de interpolación de cada eje, y produce cualquier peso intermedio. La pregunta práctica es cuándo sale más barata que llevar los pesos por separado.

Los órdenes de magnitud, para un subconjunto latino:

Configuración Peso aproximado
Un peso estático 15-25 KB
Cuatro pesos estáticos 60-100 KB
Variable con eje de peso completo 30-50 KB
Variable con eje de peso recortado a un rango 25-40 KB

El punto de equilibrio está entre dos y tres pesos. Con uno o dos, los estáticos ganan. Con tres o más, la variable gana y además da acceso a los intermedios gratis.

Recortar el rango del eje es un paso que casi nadie da y que ahorra unos pocos kilobytes más, además de reducir el riesgo de que alguien use un peso que no estaba en el diseño:

fonttools varLib.instancer MiFuente-Variable.ttf wght=300:700 -o MiFuente-300-700.ttf

Y la declaración correspondiente en el CSS declara el rango con la sintaxis de dos valores, que es lo que le dice al navegador que se trata de una fuente variable:

@font-face {
  font-family: 'Cuerpo';
  src: url('/fuentes/cuerpo-latin.woff2') format('woff2');
  font-weight: 300 700;      /* rango, no valor unico */
  font-style: normal;
  font-display: optional;
}

Una advertencia sobre la cursiva: en muchas familias variables la cursiva es un fichero aparte, no un eje. Si tu texto tiene cursivas y solo has declarado la normal, el navegador las sintetiza inclinando los glifos, y el resultado se distingue a simple vista de una cursiva real. Si te importa, hace falta el segundo fichero; si no te importa, al menos que sea una decisión y no un descuido.

Recortar por los caracteres que has visto es la forma más rápida de romper el texto de tus usuarios

La herramienta que recorre tu sitio y genera el subconjunto exacto de los caracteres que encuentra produce los ficheros más pequeños posibles, y es la causa de una categoría de fallo que aparece semanas después del despliegue, en producción, y que es dificilísima de reproducir.

El problema es que el conjunto de caracteres que tu sitio muestra hoy no es el conjunto de caracteres que va a mostrar. Cuatro fuentes de caracteres nuevos, todas inevitables:

Contenido de usuarios. Nombres, comentarios, direcciones. Un apellido con una letra que tu subconjunto no cubre, y el navegador tiene que buscar ese glifo en la fuente de reserva.

Contenido del gestor de contenidos. Un redactor pega un texto desde un procesador y trae comillas tipográficas, rayas, puntos suspensivos de un solo carácter, espacios finos. Ninguno de esos está en el latino básico.

Datos dinámicos. Símbolos de moneda de mercados nuevos, unidades, símbolos matemáticos, marcas de grados.

Traducciones. El día que el sitio se localiza, la mitad del texto usa caracteres que no existían.

Y ahora la parte que lo hace grave: la sustitución de glifos ocurre carácter a carácter, dentro de la misma palabra. Si a la fuente le falta la eñe, el navegador no cambia de fuente para toda la palabra: pinta las letras que tiene con tu fuente y la eñe con la de reserva. El resultado es una palabra con una letra de otra tipografía, de otro grosor y de otra anchura, en medio de una frase. Es feísimo y es exactamente el tipo de cosa que nadie ve en las pruebas porque el contenido de prueba está en inglés.

Tres reglas que evitan la categoría entera.

Uno: recorta por rangos de Unicode, no por caracteres observados. El coste de la diferencia es pequeño —un subconjunto latino completo pesa unos pocos kilobytes más que uno de doscientos caracteres exactos— y la robustez es total. El recorte por caracteres observados solo es defendible cuando el texto es literalmente estático y controlado por ti: un logotipo tipográfico, un titular fijo, una pantalla de arranque.

Dos: si aun así recortas fino, monitoriza los caracteres que se salen. La comprobación se puede ejecutar en producción y no cuesta nada:

function fueraDelSubconjunto(rangos) {
  const fuera = new Map();
  const paseante = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
  for (let n = paseante.nextNode(); n; n = paseante.nextNode()) {
    for (const ch of n.nodeValue) {
      const cp = ch.codePointAt(0);
      if (cp < 0x20) continue;
      if (!rangos.some(([a, b]) => cp >= a && cp <= b)) {
        fuera.set(ch, (fuera.get(ch) ?? 0) + 1);
      }
    }
  }
  return [...fuera].sort((a, b) => b[1] - a[1]);
}

const LATIN = [[0x20, 0xFF], [0x131, 0x131], [0x152, 0x153], [0x2000, 0x206F], [0x20AC, 0x20AC], [0x2122, 0x2122]];
const faltan = fueraDelSubconjunto(LATIN);
if (faltan.length) navigator.sendBeacon('/rum-glifos', JSON.stringify(faltan.slice(0, 20)));

Con eso en producción durante una semana tienes la lista exacta de lo que se está pintando con la fuente de reserva, ordenada por frecuencia. Casi siempre la lista es corta y casi siempre sorprende: comillas curvas, rayas, y dos o tres letras acentuadas que no estaban en el rango.

Tres: no recortes nunca el carácter de sustitución ni el marcador de orden de bytes. Están en los rangos que copian los proveedores por una razón. El primero es el rombo con la interrogación que el navegador pinta cuando no puede representar algo, y tenerlo en tu fuente hace que ese fallo se vea coherente en lugar de saltar con la tipografía del sistema.

Y una última consideración, sobre las escrituras que no son latinas: para chino, japonés y coreano el recorte por rangos no sirve, porque el rango es de decenas de miles de glifos y el fichero completo pesa varios megabytes. La técnica que se usa ahí es partir la fuente en cien o doscientos ficheros pequeños agrupando los caracteres por frecuencia de uso conjunto, con un unicode-range por trozo, de forma que una página concreta acabe descargando ocho o diez trozos. Es un problema de ingeniería propio y es la razón por la que los proveedores de fuentes tienen tanto valor en esos mercados.

Verificar el resultado

Dos comprobaciones cierran el trabajo.

Los bytes reales que cruzan. El panel de red y la entrada de recurso dan el tamaño codificado de cada fichero de fuente. La suma de todos ellos es tu presupuesto tipográfico, y en un sitio bien resuelto está entre 20 y 60 KB. Por encima de 150 KB hay algo que revisar; por encima de 300, hay una discusión de diseño pendiente.

Los ficheros que se descargan de verdad. Si has partido por rangos, cuenta cuántos trozos pide una página típica. Si pide todos los que declaraste, la partición no está sirviendo de nada y sería más barato un solo fichero, porque cada petición adicional tiene su propio coste incluso sobre una conexión ya abierta.

const fuentes = performance.getEntriesByType('resource').filter((r) => /\.woff2?(\?|$)/.test(r.name));
console.log('ficheros de fuente:', fuentes.length);
console.log('total KB:', Math.round(fuentes.reduce((s, r) => s + r.encodedBodySize, 0) / 1024));
⚔️ Reto práctico

Genera un subconjunto latino de tu fuente con el comando de esta lección y compara su peso con el del fichero que estás sirviendo ahora. Después ejecuta la comprobación de caracteres fuera del subconjunto sobre tus diez páginas más visitadas y sobre una página con contenido de usuarios. Si la lista tiene más de cinco entradas frecuentes, tu rango es demasiado estrecho, y ahora sabes exactamente qué añadirle.