La primera parte de esta serie termina con una pregunta: si se puede escalar
el glifo sin tocar el elemento que lo contiene, evitando que el 100%
incrustado en una frase árabe crezca con el resto del texto. La respuesta es
la técnica que documenté en el
artículo de julio: una
familia @font-face por script, acotada con unicode-range y calibrada con
size-adjust, resuelta contra fuentes de sistema con local(). Funciona
carácter a carácter porque el font matching de CSS opera así: el texto
latino atraviesa la familia no latina como si no existiera y aterriza en la
fuente de marca, sin que nadie tenga que envolver el 100% en ningún span.
Ese artículo cubre el cómo con detalle, los nombres PostScript con los que
resuelve cada navegador, la tabla de fuentes por sistema operativo con su
size-adjust calibrado script a script. Este artículo no repite nada de
eso: cubre lo que aquel no medía todavía cuando lo publiqué en julio, porque
entonces no tenía instrumentación para medirlo. size-adjust resuelve el
tamaño del glifo y deja sin resolver la caja que lo contiene, y esa caja es
la que decide, al final, si el texto se ve completo o recortado.
La invariante que decide si el texto cabe
Todo el problema del texto recortado se reduce a un cociente por estilo tipográfico: el interlineado dividido por el tamaño. Ni los píxeles absolutos ni el idioma entran en la condición, solo esa proporción.
line-height ÷ font-size ≥ R
R es una constante que depende de la geometría de las fuentes en juego, no
del producto ni de la pantalla. Un badge de 12/12 px tiene cociente 1,00; si
R para el script que contiene es 1,86, ese badge recorta el texto sin
excepción, sea cual sea el dispositivo o el tamaño de fuente base. De dónde
sale R es lo que explica por qué size-adjust no lo cambia: la caja de
línea reparte su altura entre lo que queda por encima y por debajo de la
línea base, y ese reparto lo decide la asimetría de la cara que posee la
línea, Δ = ascent − descent de esa cara concreta.
R = max(2·bboxAsc − Δ, 2·bboxDesc + Δ)
Si Δ queda al azar, R sube. Si Δ se declara a medida con
ascent-override y descent-override, R baja hasta su mínimo teórico, que
es la altura real de la tinta dibujada. size-adjust no toca ninguno de los
dos términos de esa fórmula: es un descriptor de @font-face que escala el
glifo antes de que el navegador calcule el resto, no una propiedad de
maquetación que participe en el cálculo de la caja. Multiplicar el tamaño de
la letra y dejar el interlineado quieto es, en términos del cociente, subir
el denominador sin tocar el numerador: R efectivo baja, y con R bajo la
tinta ya no cabe.
Dos puertas para la misma métrica
ascent-override y descent-override sí participan en la maquetación,
pero solo llegan a la caja de línea por una de dos puertas, y cuál de las dos
está abierta depende de una propiedad que nada tiene que ver con la
tipografía a simple vista: cómo se declaró line-height.
Con line-height numérico o en una unidad de longitud, que es como cualquier
token de espaciado se declara en un sistema de diseño calculable, la altura
de la caja cuenta solo las métricas de la first available font: la primera
cara de la pila de font-family que tenga un glifo para el carácter que se
está dibujando. Con line-height: normal, en cambio, cuentan las métricas de
todas las caras que intervienen en la línea, y la caja crece hasta la mayor
de todas ellas.
La consecuencia práctica es que un sistema de tokens que use interlineados calculables, que es la base de cualquier ritmo vertical predecible entre dispositivos, vive siempre detrás de la primera puerta. Y esa puerta se abre o se cierra en función de qué cara del stack dibuje el carácter U+0020, el espacio, no en función de qué cara dibuje el texto visible.
La diferencia entre las dos puertas se puede medir en píxeles, no solo
describir. Sobre esa misma pila a 100 px de font-size y line-height: 1.5,
es decir 150 px de caja, la cara árabe con ascent-override: 105% y
descent-override: 45% sitúa la línea base a 105 px del borde superior de la
caja cuando es ella quien la posee. Si en cambio la posee la cara latina, sin
ningún override propio para el árabe, la baseline cae a 109,5 px: un
desplazamiento de 4,5 px que no depende de ningún cambio de fuente ni de
tamaño, solo de qué cara reclamó el espacio. Ese desplazamiento es
exactamente el margen que le falta a la tinta árabe para caber dentro de la
caja cuando el override correcto no llega a aplicarse.
Una cara sin glifos visibles puede poseer la línea
Aquí está el hallazgo contraintuitivo del que salió el
contrato de arquitectura tipográfica
que acompaña a esta serie: una cara puede no dibujar ni un solo glifo visible
en una línea y aun así ser la que decide la altura de esa línea, por el
único hecho de reclamar el espacio. Cuatro medidas sobre una pila «árabe,
latina» a 100 px con line-height: 1.5 y la cara árabe pidiendo un ascent
del 105 % y un descent del 45 % lo dejan sin ambigüedad: si la cara árabe no
declara U+0020 en su unicode-range, la baseline la coloca la cara latina
aunque el texto entero, sin un solo carácter latino, esté escrito en árabe.
Solo cuando la cara árabe reclama explícitamente el espacio pasa a ser la
first available font y su override llega a la caja de línea. Y en el caso
extremo, una tercera cara cuyo único rango sea U+0020 se lleva la geometría
completa de la línea sin dibujar nada, ni una letra.
La implicación para cualquier arquitectura de fuentes es directa: declarar
ascent-override y descent-override en la cara del script no basta. Hace
falta una segunda declaración de esa misma cara, sin size-adjust distinto
pero con U+0020 en su unicode-range, para que la puerta se abra. Sin esa
reclamación explícita, los overrides existen en la hoja de estilos y no
llegan nunca a la línea, sin ningún error en consola que lo señale.
El mínimo por script, medido
El interlineado mínimo que evita el recorte no es un número único para todo
lo no latino, igual que el size-adjust de cada script no lo era en el
artículo de julio. Medido sobre el banco de pruebas que acompaña a esta
serie, con la estrategia que hoy está en producción, el cociente mínimo por
script es este:
| Script | line-height ÷ font-size mínimo |
|---|---|
| Latín | 1,14 |
| Chino | 1,03 |
| Japonés | 1,12 |
| Coreano | 1,16 |
| Hebreo | 1,30 |
| Devanagari | 1,50 |
| Tailandés | 1,78 |
| Árabe | 1,86 |
| Árabe vocalizado | 2,05 |
Frente a esa tabla, la escala real de tokens tiene estilos que se quedan
cortos: las sondas del banco de pruebas, con nombres de patrón habitual como
badge o counter, replican a propósito componentes de cociente 1,00, un
número y su interlineado exactamente iguales. No forman parte hoy del
catálogo de tokens de p4, pero son el patrón de componente, un contador, una
insignia de estado, donde el contrato se rompe primero en cuanto alguien lo
añada sin conocer esta tabla. El árabe vocalizado, con harakat, es el caso
límite de toda la tabla y el que menos margen deja: ningún interlineado
razonable en una escala de producto llega a 2,05 sin desentonar visualmente
con el resto de idiomas. Puedes reproducir esta medición sobre tus propias
fuentes y tu propio dispositivo en el
banco de pruebas:
las cifras cambian con la fuente instalada, el procedimiento no.
El sitio donde este mínimo se paga en producción no es el texto suelto, es el
componente que lo envuelve. Una insignia de estado con altura fija y
overflow: hidden, un contador de notificaciones, una etiqueta de talla
dentro de una tarjeta de producto: cualquier contenedor que fije su propia
altura en vez de dejar que la marque el contenido recorta en cuanto el
idioma cambia a uno de cociente alto, aunque el mismo componente lleve meses
en producción sin un solo defecto reportado en español. El defecto no está
en el componente, está en la suposición de que la altura del texto es una
constante del sistema quien puede fijarla, y no del script quien la impone.
El error que me costó entender la unión de dos alfabetos
Elegí mantener los tokens de interlineado del sistema en valores numéricos
calculables, en píxeles derivados de una escala modular, y descarté declarar
line-height: normal como atajo para heredar automáticamente la métrica de
todas las caras implicadas. Con normal el problema de la puerta cerrada
desaparecería solo, porque cada cara que interviene en la línea aportaría su
propia geometría sin que nadie tuviera que reclamar U+0020 a mano. Lo
descarté porque los valores numéricos son la base del ritmo vertical de todo
el sistema, y normal depende del valor por defecto de cada fuente en cada
sistema operativo: ese ritmo dejaría de ser predecible entre dispositivos,
que es justo la propiedad que un sistema de tokens tiene que sostener. El
coste que asumo por mantener valores numéricos es tener que declarar
explícitamente, cara por cara, quién posee la línea.
Ese coste es el que no pagué bien la primera vez. La primera versión del
banco de pruebas de esta serie calculaba ascent-override y
descent-override con la tinta del script solamente, bboxAsc y bboxDesc
de la cara árabe o tailandesa medidos en aislamiento. Es un error razonable
sobre el papel, porque parece que la cara que hay que ajustar es la del
script que se está corrigiendo. Es incorrecto en la práctica porque la cara
que posee la línea tiene que cubrir la tinta más alta que pueda aparecer en
ella, y en una línea mixta esa tinta puede ser la latina incrustada, no la
del script. Corregido, el cálculo usa la unión de los dos alfabetos: el
máximo ascendente entre script y latino, el máximo descendente entre los dos.
La cifra que expone el error es esta: en seis de los nueve idiomas de la
tabla anterior, el ascendente que hay que cubrir es el latino, no el del
script, porque los dígitos y las siglas incrustadas dibujan más alto que los
propios ideogramas o el propio alifato. El límite de validez de esta
corrección es el mismo que el de toda la capa: solo se sostiene mientras cada
cara nueva que se añada al stack declare a conciencia si reclama el espacio o
lo cede, porque en cuanto una se añade sin decidirlo, el contrato deja de
cumplirse en silencio, sin un solo error, hasta que alguien vuelve a medir.