unicode-range y el subsetting
La diferencia entre aplazar una descarga y reducir un archivo, cómo se construye un subconjunto real con fonttools, y los riesgos de recortar de más.
unicode-range y el subsetting resuelven el mismo problema por vías opuestas y se confunden constantemente. El primero no toca el archivo: le dice al navegador qué caracteres cubre para que decida si lo pide. El segundo genera un archivo más pequeño. Combinarlos es lo que hace que un sitio en español no descargue los glifos cirílicos; usar solo el primero sobre un archivo completo no ahorra ni un byte cuando la página tiene texto latino.
- Explicar qué decide
unicode-rangey qué no cambia. - Escribir rangos con la sintaxis de puntos de código y comodines.
- Generar un subconjunto con
pyftsubsety verificar el resultado. - Anticipar los fallos que produce recortar de más.
Qué hace unicode-range de verdad
@font-face {
font-family: 'Texto';
src: url('/f/texto-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+2013-2014, U+2018-201A, U+201C-201E, U+2026, U+20AC;
}
El descriptor afirma: esta cara cubre estos puntos de código. El navegador, cuando resuelve estilos, comprueba si el texto que va a componer con esa familia contiene algún carácter del rango. Si lo contiene, pide el archivo; si no, ni lo intenta.
Dos consecuencias.
No reduce el archivo. Si declaras un rango latino sobre un archivo que contiene además cirílico y griego, y la página tiene texto latino, se descarga el archivo entero con todo dentro. El descriptor solo condiciona si se pide, no qué llega.
El corte es por carácter, no por proporción. Basta un único carácter del rango en toda la página para que el archivo se descargue completo. Ese es el fallo que aparece en producción con las comillas tipográficas, la raya de diálogo, el símbolo de grado o una letra griega perdida en una fórmula.
El patrón de subconjuntos
La técnica real combina las dos cosas: se generan varios archivos, cada uno con los glifos de un bloque de escritura, y cada uno se declara con su unicode-range.
@font-face {
font-family: 'Texto';
src: url('/f/texto-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+2000-206F, U+20AC;
}
@font-face {
font-family: 'Texto';
src: url('/f/texto-latin-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+1E00-1EFF, U+2C60-2C7F;
}
@font-face {
font-family: 'Texto';
src: url('/f/texto-greek.woff2') format('woff2');
unicode-range: U+0370-03FF;
}
Las tres declaran la misma familia. No es una errata: el navegador las trata como una sola cara repartida en varios archivos y descarga solo los que necesita. Es el mecanismo que usan los servicios de fuentes para que una página en español no arrastre los cinco mil glifos del vietnamita.
La sintaxis admite tres formas: un punto de código suelto U+41, un intervalo U+0100-024F, y un comodín U+04?? que cubre los 256 puntos que empiezan por esos dígitos. Los rangos se separan por comas y pueden solaparse; si dos caras cubren el mismo carácter, gana la declarada más tarde.
Construir el tuyo
La herramienta estándar es pyftsubset, que viene con fonttools:
pip install fonttools brotli
pyftsubset texto.ttf \
--unicodes="U+0000-00FF,U+0131,U+2000-206F,U+20AC" \
--layout-features="kern,liga,clig,calt,ccmp,mark,mkmk" \
--flavor=woff2 \
--output-file=texto-latin.woff2
Tres opciones merecen atención.
--unicodes acepta la misma sintaxis de rangos que CSS, lo cual permite copiar y pegar entre el comando y la hoja de estilos. La alternativa --text-file recorta a los caracteres que aparecen en un archivo concreto, y es lo que se usa para fuentes de titular con texto fijo.
--layout-features decide qué características de OpenType sobreviven. El valor por defecto conserva un conjunto conservador; quitarlas todas adelgaza el archivo y rompe el kerning y las ligaduras, que es un daño mucho mayor que los kilobytes ahorrados. La lista de arriba es un mínimo sensato: kerning, ligaduras comunes y contextuales, composición y posicionamiento de marcas diacríticas.
--flavor=woff2 comprime con Brotli. El archivo resultante ya está comprimido, así que no lo vuelvas a comprimir en el servidor: activar gzip sobre woff2 gasta CPU y suele añadir bytes.
Para verificar, ttx lista lo que quedó dentro:
python3 -m fontTools.ttx -t cmap -o - texto-latin.woff2 | head -40
Los riesgos y los rendimientos decrecientes
El contenido no es el que tú escribes. Un nombre propio con diacrítico raro, un comentario de usuario, una cita en otro idioma, un producto con un símbolo. Si el glifo no está en ningún subconjunto declarado, el navegador cae a la siguiente fuente de la familia para ese carácter suelto, y el resultado es una letra con otra tipografía en mitad de una palabra. Es feo y es difícil de reproducir.
text-transform genera caracteres que no estaban. Poner en mayúsculas un texto con eñes y acentos requiere las mayúsculas correspondientes, que en un subconjunto agresivo pueden faltar.
Las tablas de OpenType no encogen linealmente. Al bajar de cierto número de glifos, lo que queda es sobre todo estructura: cabeceras, tablas de posicionamiento, hinting. Pasar de 300 glifos a 120 no reduce el archivo a la mitad; lo reduce mucho menos. Por eso el subsetting tiene un punto de rendimientos decrecientes claro, y perseguir los últimos kilobytes suele salir caro en robustez.
El orden correcto de ataque, por retorno decreciente: primero quitar pesos y estilos que no se usan, que ahorra archivos enteros; después recortar por bloques de escritura, que ahorra la mayoría de los glifos; y solo entonces, si de verdad hace falta, afinar la lista de caracteres.
La asimetría importante entre las dos técnicas es de modo de fallo, y es lo que debería decidir cuál usas hasta dónde. Si te pasas con unicode-range y declaras un rango más estrecho de lo que el archivo cubre, el peor caso es que se descargue un archivo que no hacía falta: pierdes bytes y no pierdes nada visible. Si te pasas con el subsetting y eliminas un glifo que aparece en producción, el peor caso es una letra de otra fuente en mitad de una palabra, en una página que tú no vas a mirar nunca porque el contenido lo escribió un usuario. Una de las dos técnicas falla hacia el desperdicio y la otra hacia lo roto, y esa diferencia debería traducirse directamente en cuánta agresividad te permites con cada una: con los rangos, todo lo que quieras, porque el coste del error es medible y acotado; con el recorte de glifos, conservador por defecto y con un procedimiento de verificación sobre contenido real, no sobre la maqueta. Es la misma regla que aplicarías a cualquier optimización: la agresividad admisible es inversamente proporcional a la gravedad de su modo de fallo, y no al ahorro que promete.