size-adjust y los overrides: que la reserva ocupe lo mismo
Los cuatro descriptores de ajuste del @font-face, la fórmula que calcula sus valores a partir de las métricas de las dos fuentes, y el patrón de familia de reserva propia que funciona en todas las plataformas.
Si el problema es que la fuente de reserva ocupa un espacio distinto del de la fuente real, la solución evidente es hacer que ocupe el mismo. Eso es exactamente lo que permiten cuatro descriptores del @font-face que llevan disponibles en los tres motores desde 2023 y que siguen siendo desconocidos para la mayoría: reescriben las métricas de una fuente sin tocar el fichero, de modo que se puede tomar una fuente instalada en el sistema del usuario y obligarla a comportarse geométricamente como la tuya.
- Describir qué hace cada uno de los cuatro descriptores de ajuste y su valor por defecto.
- Calcular los valores concretos a partir de las métricas de las dos fuentes.
- Montar el patrón de familia de reserva propia con una entrada por plataforma.
- Verificar que el desplazamiento del intercambio ha bajado a cifras despreciables.
Los cuatro descriptores
Van dentro de una regla @font-face y afectan solo a esa cara de fuente.
size-adjust: <porcentaje>. Multiplica los contornos de los glifos y todas las métricas de la fuente por ese porcentaje. Su valor por defecto es 100%. Es el descriptor que iguala la anchura, y por tanto el que evita que cambien los saltos de línea.
ascent-override: normal | <porcentaje>. Sustituye el ascenso de la fuente por ese porcentaje del tamaño de fuente. Por defecto normal, que significa usar el del fichero.
descent-override: normal | <porcentaje>. Igual para el descenso.
line-gap-override: normal | <porcentaje>. Igual para el hueco de línea.
El orden de aplicación importa para la aritmética y es el que sugiere la definición de size-adjust: los tres overrides definen las métricas, y size-adjust escala el resultado. Es decir, el ascenso efectivo en píxeles es ascent-override * size-adjust * font-size. De ahí sale la fórmula.
La fórmula
Quieres que la reserva, ajustada, produzca las mismas medidas que la fuente real. Dos condiciones y tres incógnitas.
Condición de anchura: la anchura media de carácter de la reserva escalada tiene que igualar la de la fuente real.
size-adjust = anchoMedio(real) / anchoMedio(reserva)
Condición de altura: el ascenso y el descenso efectivos tienen que igualar los de la fuente real. Como size-adjust ya está multiplicando, hay que dividir por él:
ascent-override = ascenso(real) / size-adjust
descent-override = descenso(real) / size-adjust
line-gap-override = huecoLinea(real) / size-adjust
Fíjate en que los tres overrides toman las métricas de la fuente real, no de la reserva. La reserva solo aporta su anchura media, que es lo que se usa para calcular el factor de escala.
Con la función metricas de la lección anterior, el cálculo completo son diez líneas:
async function calcularAjustes(fuenteReal, fuenteReserva) {
const real = await metricas(fuenteReal);
const res = await metricas(fuenteReserva);
const escala = real.anchoMedio / res.anchoMedio;
const pc = (n) => `${(n * 100).toFixed(2)}%`;
return {
'size-adjust': pc(escala),
'ascent-override': pc(real.ascenso / escala),
'descent-override': pc(real.descenso / escala),
'line-gap-override': '0%',
};
}
console.log(await calcularAjustes('MiFuente', 'Arial'));
Un ejemplo con cifras, para que la aritmética sea concreta. Supón que tu fuente mide 0,0521 em de anchura media, 0,9678 de ascenso y 0,2451 de descenso, y que Arial mide 0,0489 de anchura media. Entonces:
size-adjust = 0,0521 / 0,0489 = 1,0654 -> 106,54%
ascent-override = 0,9678 / 1,0654 = 0,9084 -> 90,84%
descent-override = 0,2451 / 1,0654 = 0,2301 -> 23,01%
Un apunte de precisión: las métricas que devuelve el contexto de dibujo son una aproximación muy buena de las tablas del fichero, y no siempre son idénticas, porque los motores no eligen la misma tabla vertical en todas las plataformas. Para un cálculo exacto y reproducible conviene leer las tablas directamente del fichero en el proceso de compilación, con una biblioteca de tipografía, o usar un paquete de métricas precalculadas. Herramientas como el complemento de compilación que genera estas reglas automáticamente, o la integración de fuentes de algunos marcos de trabajo, hacen exactamente esta cuenta por ti y son la opción sensata si tu pila lo permite.
El patrón de familia de reserva propia
Aquí está el detalle que hace que esto funcione de verdad y que se salta la mayoría de los tutoriales: cada plataforma tiene una fuente de reserva distinta, y cada una necesita su propio ajuste. Un solo @font-face con varias fuentes locales no sirve, porque size-adjust es único para toda la regla.
La estructura correcta es una regla por fuente de reserva, cada una con su nombre de familia propio y sus ajustes, y todas listadas en la pila:
/* La fuente real */
@font-face {
font-family: 'Cuerpo';
src: url('/fuentes/cuerpo.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
/* Reserva ajustada para sistemas con Arial: Windows y macOS */
@font-face {
font-family: 'Cuerpo reserva A';
src: local('Arial');
size-adjust: 106.54%;
ascent-override: 90.84%;
descent-override: 23.01%;
line-gap-override: 0%;
}
/* Reserva ajustada para Android */
@font-face {
font-family: 'Cuerpo reserva B';
src: local('Roboto');
size-adjust: 103.11%;
ascent-override: 93.86%;
descent-override: 23.77%;
line-gap-override: 0%;
}
body {
font-family: 'Cuerpo', 'Cuerpo reserva A', 'Cuerpo reserva B', sans-serif;
line-height: 1.5;
}
El mecanismo por el que esto elige bien: si Arial no está instalada, la regla Cuerpo reserva A no consigue cargar ninguna fuente, esa familia no existe a efectos de emparejamiento, y el navegador pasa a la siguiente de la pila. Cada sistema acaba usando la entrada calibrada para su fuente, y la genérica del final queda como red de seguridad para los casos que no previste.
Dos detalles de sintaxis que hacen fallar el patrón en silencio.
El nombre de familia de la reserva tiene que ser distinto del de la fuente real. Si le pones el mismo, el navegador lo interpreta como otra fuente de la misma familia, no como una alternativa, y el ajuste no se aplica donde esperas.
local() empareja contra el nombre completo y el nombre PostScript de la fuente, no contra el nombre de familia. local('Arial') encuentra la regular porque ese es su nombre completo; para la negrita hace falta local('Arial Bold'). Si tu texto tiene negritas y solo declaraste la regular, las negritas caen a la genérica sin ajustar y siguen desplazando.
Cuando montas esto por primera vez y verificas, el resultado típico es pasar de un desplazamiento agregado de ochenta o cien píxeles a uno de dos o cuatro. Es una mejora enorme y no es cero, y he visto a gente perder días persiguiendo esos tres píxeles. No se pueden eliminar, y la razón es estructural.
size-adjust iguala la anchura media, no la anchura de cada cadena. Dos fuentes con idéntica anchura media pueden tener anchuras muy distintas glifo a glifo: una tiene la eme estrecha y la ese ancha, la otra al revés. En un párrafo largo las diferencias se compensan y la media manda; en una cadena corta y concreta —un titular de cuatro palabras, la etiqueta de un botón, un elemento de navegación— no hay ninguna compensación, y esa cadena concreta puede seguir cruzando el umbral de salto de línea.
De ahí salen tres cosas prácticas.
La primera: mide el residuo, no lo asumas. El bloque de verificación es el mismo de la lección anterior, con una precaución imprescindible que casi nadie toma: hay que desactivar la caché, porque con la fuente ya en caché el intercambio no ocurre y la medición sale perfecta por el motivo equivocado.
async function verificarResiduo() {
const bloques = [...document.querySelectorAll('main, article, h1, h2, .tarjeta, nav')];
const antes = bloques.map((b) => b.getBoundingClientRect().height);
await document.fonts.ready;
const filas = bloques
.map((b, i) => ({
elemento: b.tagName + '.' + b.className.slice(0, 20),
delta: +(b.getBoundingClientRect().height - antes[i]).toFixed(1),
}))
.filter((f) => Math.abs(f.delta) > 0.5)
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
console.table(filas);
console.log('residuo total', filas.reduce((s, f) => s + Math.abs(f.delta), 0).toFixed(1), 'px');
}Si el residuo total está por debajo de unos cinco píxeles, la aportación al CLS es de milésimas y el trabajo está terminado. Perseguir más es tiempo mal invertido.
La segunda: si el residuo se concentra en dos o tres elementos, atácalos individualmente. Casi siempre son titulares o navegación, y casi siempre el arreglo no es tipográfico sino de maquetación: fijar una altura mínima al titular, o evitar que la navegación dependa del ancho del texto. Cuando el elemento tiene una altura reservada, da igual si el texto dentro cambia de anchura.
La tercera, y es la única garantía real de cero: font-display: optional. Con ese valor el navegador se da un plazo cortísimo para tener la fuente, y si no llega a tiempo renderiza toda la página con la reserva y no intercambia en absoluto en esa carga; la fuente queda en caché y se usa en la siguiente. Cero intercambio significa cero desplazamiento por definición, sin cálculos ni descriptores.
El precio es que en la primera visita con conexión lenta el usuario ve la reserva durante toda la sesión. Y aquí es donde los descriptores de ajuste dejan de ser una alternativa a optional y pasan a ser su complemento perfecto: con la reserva ajustada, esa primera visita se ve prácticamente igual que con la fuente real, con la misma métrica y el mismo ritmo de línea. La combinación de reserva ajustada más font-display: optional es la configuración que da un CLS de fuentes exactamente cero sin sacrificar la identidad visual en la práctica.
Y una advertencia de mantenimiento que se olvida siempre: los valores calculados dependen del fichero de fuente concreto. Si cambias de proveedor, actualizas la versión, añades un peso o recortas el subconjunto de caracteres, las métricas pueden moverse y los descriptores quedan mal calibrados sin que nada avise. La forma de que eso no se pudra es generar los valores en el proceso de compilación a partir del fichero, no escribirlos a mano en la hoja de estilos.
Una nota sobre las negritas y las cursivas
El cálculo hay que repetirlo por cada combinación de peso y estilo que uses de verdad, porque la anchura media de la negrita no es la de la regular.
En la práctica, con dos o tres pesos, el enfoque proporcionado es calibrar la regular con precisión y declarar las demás con los mismos descriptores. El error residual de aplicar el ajuste de la regular a la negrita suele ser de un uno o dos por ciento, muy por debajo de lo que mueve el problema original, y evita multiplicar por seis el número de reglas.
Si usas una fuente variable con un rango de pesos, el asunto se simplifica: hay un solo fichero y un solo conjunto de métricas base, y basta un juego de descriptores.
Calcula los ajustes para tu fuente contra Arial y contra Roboto, monta el patrón de las tres reglas, y ejecuta la verificación de residuo con la caché desactivada y la red estrangulada. Anota el residuo total antes y después. Si baja de más de cincuenta píxeles a menos de cinco, has eliminado tu principal fuente de CLS con veinte líneas de CSS y cero bytes adicionales de red.