Arquitectura · sistema de diseño p4

Contrato tipográfico multiscript

Un sistema no se valida estilo a estilo, porque el estilo que rompe todavía no está escrito. Se define una invariante que ningún estilo pueda violar, se hace que las fuentes la abaraten y se comprueba sola.

01 La invariante

Todo el problema del texto cortado se reduce a un número por estilo: el cociente entre el interlineado y el tamaño. Ni los píxeles ni el idioma entran en la condición.

Contrato

line-height ÷ font-size ≥ R

R es una constante del producto. Todo lo demás que hay en este documento existe para una sola cosa: bajar R hasta un valor que la escala tipográfica pueda cumplir sin deformarse.

La consecuencia inmediata es que multiplicar por idioma no arregla nada. Multiplicar font-size y line-height por el mismo factor es una semejanza: conserva el cociente. Un badge de 12/12 sigue en 1,00 después de multiplicarlo por 1,4, y se sigue cortando. El multiplicador cambia el tamaño aparente, no el contrato.

De dónde sale R

La caja de línea reparte su altura entre lo que queda encima y lo que queda debajo de la baseline, y ese reparto lo decide la asimetría de la cara que posee la línea. Con Δ = ascent − descent de esa cara:

R_script = max( 2·bboxAsc − Δ ,  2·bboxDesc + Δ )

Si Δ se deja al azar, R sube. Si Δ se declara a medida, R baja hasta su mínimo teórico, que es la altura de la tinta. Ese es todo el margen de maniobra que hay, y la capa 2 lo gasta entero.

02 Las cuatro palancas, medidas

Qué le hace cada mecanismo a R, en árabe con Noto Sans Arabic sobre Chromium 148:

Mecanismo Efecto sobre R R resultante ¿Toca el latino incrustado?
Fallback del sistema, sin nadaindeterminadono
size-adjust: 140% · lo de hoymultiplica la tinta por 1,41,86no
Multiplicador [lang] sin compensarninguno, conserva el cociente1,53sí, lo agranda ×1,4
Multiplicador + latino a 1/Msaca el 1,4 de la tinta1,33no
+ overrides y U+0020coloca la baseline en su óptimo1,32no

Medido con «الموديل ترتدي مقاس 34 وطولها 180 سم.» sobre Noto Sans Arabic.

Conviene no exagerar la última fila: en árabe los overrides bajan R de 1,33 a 1,32, porque la cara latina compensada ya cae casi en el reparto óptimo. Donde sí pagan es en los scripts de tinta desequilibrada. En tailandés, con Noto Sans Thai, R baja de 1,38 a 1,22. La capa 1 se justifica por el tailandés y el devanagari, no por el árabe.

Los dos descriptores no viajan por la misma puerta

size-adjust actúa por glifo: escala todo lo que dibuje esa cara, siempre. ascent-override y descent-override actúan por caja de línea, y solo llegan a la maquetación por una de dos puertas.

  • Con line-height numérico o en longitud, solo cuentan las métricas de la first available font: la primera cara de la pila con glifo para U+0020.
  • Con line-height: normal cuentan las de todas las caras que intervienen en la línea, y la caja crece hasta la mayor.

Como el sistema usa interlineados calculables y no normal, en la práctica rige la primera puerta. Cuatro medidas sobre una pila «árabe, latina» a 100 px y line-height: 1.5, con la cara árabe pidiendo 105 % / 45 %:

CasoMandaBaseline
Árabe sin U+0020, texto con espaciosla latina109,5 px
Árabe sin U+0020, texto sin un solo espaciola latina109,5 px
Árabe con U+0020 en su rangola árabe105,0 px
Árabe sin U+0020, más una cara cuyo rango es solo U+0020la del espacio131,0 px

La segunda fila descarta la explicación intuitiva: no es que mande la cara del primer carácter, porque ahí la cara árabe dibuja el texto entero y aun así no coloca la baseline. La cuarta lo confirma desde el otro lado: una cara que no dibuja ni un glifo visible se lleva la geometría de la línea por el solo hecho de aportar el espacio.

Un apunte de cálculo: los porcentajes del override se aplican sobre el em ya escalado por size-adjust, así que van divididos por ese factor.

03 Las cinco capas

Inventario de carascapa 0
Una cara por script, elegida por tinta vertical, no por gusto. Autoalojada, para que las métricas no dependan del sistema operativo del usuario. La tabla de la capa 0 es la que decide, y se mide, no se hereda del catálogo.
Normalización métricacapa 1
Cada cara declara size-adjust, ascent-override, descent-override y line-gap-override: 0%, y reclama U+0020 bajo su :lang(). Sin esa reclamación los overrides no llegan a la caja de línea y la capa entera no hace nada.
Contrato del tokencapa 2
Todo token tipográfico es un par (tamaño, interlineado) que debe cumplir el contrato. Se garantiza con max() en el propio token, de modo que un valor que lo viole se corrige solo en vez de romperse.
Escotillacapa 3
Una regla que aplica el suelo a lo que no pasa por los tokens. Es la capa que da cobertura a lo que todavía no existe y a lo que alguien escribió a mano.
La caja, no la letracapa 4
Alturas fijas y overflow: hidden sobre texto quedan prohibidos por contrato. Un componente con texto se dimensiona con min-height y padding-block. Esto no es tipografía y es donde reaparece el recorte.

04 Capa 0 · elección de caras

Altura de tinta por em, medida sobre cadenas de ficha de producto. Menos tinta es menos R, y R es global: el peor script fija el contrato de todo el producto, también del latino.

ScriptFamiliabbox ↑bbox ↓Tinta total
LatinoRoboto0,8960,2101,107
ÁrabeNoto Sans Arabic0,7300,4211,151
ÁrabeNoto Naskh Arabic0,6710,4341,105
ÁrabeIBM Plex Sans Arabic0,7700,4401,210
ÁrabeCairo0,8070,4301,237
TailandésNoto Sans Thai0,9500,2651,215
TailandésPrompt0,9320,2711,203
TailandésIBM Plex Sans Thai1,0070,3501,357
TailandésSarabun1,1680,3321,500
DevanagariNoto Sans Devanagari0,8960,2741,170
DevanagariMukta0,9150,2531,168
BengalíNoto Sans Bengali0,9120,2281,140
BengalíHind Siliguri0,8800,2331,113
TamilNoto Sans Tamil0,8430,3211,164
TamilMukta Malar0,7890,2981,087
HebreoNoto Sans Hebrew0,7600,1910,951
HebreoRubik0,7100,1240,834
ChinoNoto Sans SC0,7960,1720,968
JaponésNoto Sans JP0,8430,1711,014
CoreanoNoto Sans KR0,8330,1230,956

Las marcadas son la elección propuesta, y no siempre es la de menos tinta. Noto Naskh Arabic gasta 0,046 em menos que Noto Sans Arabic, pero es naskh y no palo seco: rompe el criterio de parecido con Roboto. Ese es el precio explícito de la coherencia de estilo, y conviene pagarlo a sabiendas en vez de descubrirlo al medir.

En la otra dirección, elegir Sarabun en vez de Noto Sans Thai sube la tinta tailandesa de 1,215 a 1,500 y con ella el interlineado mínimo de todas las pantallas del producto, en todos los idiomas. Elegir la fuente de un script minoritario es una decisión de arquitectura con factura global.

05 El valor de R

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. Por eso R no sale de la tinta del script, sino de la unión de los dos:

R = max(bboxAsc_script, bboxAsc_latino) + max(bboxDesc_script, bboxDesc_latino)
Idiomaascent que hay que cubrirdescent que hay que cubrirR del idioma
Chino · japonés · coreano · hebreo0,896 lat0,210 lat1,106
Bengalí0,9120,2281,140
Devanagari0,896 lat0,2741,170
Tamil0,896 lat0,3211,217
Tailandés0,9500,2651,215
Árabe0,896 lat0,4211,317

Fíjate en qué columna manda: en seis de los nueve idiomas el ascendente que hay que cubrir es el latino, no el del script. La tinta que obliga a subir el interlineado de una pantalla en chino es la de los dígitos y las siglas, no la de los ideogramas.

Valor propuesto

R = 1,35

Cubre los nueve scripts y el latino, con 0,033 em de holgura sobre el peor caso, que es el árabe. Es un número del producto, no del idioma: el mismo badge mide lo mismo en español y en tailandés, y la maqueta no se mueve al cambiar de idioma.

La alternativa: R por idioma

R puede declararse por idioma en vez de global, con la misma mecánica de un token por lengua. Sale más barato en interlineado (el chino se queda en 1,11 en vez de subir a 1,35) y más caro en estabilidad: el badge deja de medir lo mismo en todos los idiomas y la maqueta se mueve al cambiar de lengua. Yo elegiría el global mientras el producto comparta maquetación entre idiomas, y solo bajaría a R por idioma si el interlineado del chino o del japonés se considerase inaceptablemente holgado.

06 El CSS

Capa 1 · las caras

Dos declaraciones por script, idénticas salvo por U+0020: la que lo reclama manda en la caja de línea, la que no lo reclama cede. El ascent y el descent se reparten la altura R según la tinta que hay que cubrir.

/* A = maxAsc + holgura/2   D = maxDesc + holgura/2   A + D = R = 1,35
   árabe: maxAsc 0,896 (latino) · maxDesc 0,421 (árabe) · holgura 0,033 */

@font-face {
  font-family: 'p4-ar-manda';
  src: url('/fonts/noto-sans-arabic.woff2') format('woff2');
  size-adjust: 100%;        /* el tamaño aparente lo da el multiplicador */
  ascent-override: 91.25%;
  descent-override: 43.75%;
  line-gap-override: 0%;
  unicode-range: U+0020, U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFF;
}

@font-face {
  font-family: 'p4-ar-cede';
  /* misma src y mismos descriptores, sin U+0020 */
  unicode-range: U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFF;
}

/* Una cara latina por multiplicador: size-adjust no lee var() */
@font-face {
  font-family: 'p4-latin-140';
  src: url('/fonts/roboto.woff2') format('woff2');
  size-adjust: 71.43%;        /* 1 ÷ 1,40 */
  unicode-range: U+0000-024F, U+2000-206F;
}

El size-adjust de la cara del script vuelve al 100 %. El tamaño aparente lo pone el multiplicador del idioma sobre font-size, y la cara latina lo deshace con 1/M. Ese reparto es lo que mantiene R bajo: si la paridad de tamaño se hiciera con size-adjust en la cara del script, la tinta se multiplicaría por 1,4 y R volvería a 1,86.

Capa 1 · la pila por idioma

:root {
  --p4-font-stack: 'p4-latin-100', 'p4-ar-cede', 'p4-th-cede', 'p4-zh-cede', …;
  --p4-typo-scale: 1;
}
:lang(ar) { --p4-font-stack: 'p4-ar-manda', 'p4-latin-140'; --p4-typo-scale: 1.40; }
:lang(th) { --p4-font-stack: 'p4-th-manda', 'p4-latin-130'; --p4-typo-scale: 1.30; }
:lang(zh) { --p4-font-stack: 'p4-zh-manda', 'p4-latin-110'; --p4-typo-scale: 1.10; }

Como :lang() alcanza también a un span incrustado, una palabra árabe dentro de prosa española recibe su propia cara primaria y su línea crece sola.

Capa 2 · el contrato en el token

:root { --p4-typo-ratio-min: 1.35; }

/* se escribe una vez por token y ya no puede violarse */
--p4-typo-body-m-font-size: calc(var(--p4-typo-font-size-12) * var(--p4-typo-scale));

--p4-typo-body-m-line-height: max(
    calc(var(--p4-typo-line-height-18) * var(--p4-typo-scale)),
    calc(var(--p4-typo-body-m-font-size) * var(--p4-typo-ratio-min))
);

El max() es lo que salva a los estilos de cociente ajustado. Un badge de 12/12 tiene cociente 1,00 y el multiplicador lo deja igual: es el suelo, no el multiplicador, quien lo sube a 1,35 y evita el recorte.

Capa 3 · la escotilla

Para lo que no pasa por los tokens. Se apoya en que 1em siempre vale el font-size del propio elemento, sea cual sea:

/* cualquier elemento con texto que declare su propio interlineado */
[class*="p4-typo-"],
.p4-escape-typo {
  line-height: max(var(--p4-lh, normal), calc(1em * var(--p4-typo-ratio-min)));
}

No es elegante y es deliberado: la capa 3 existe para que el estilo escrito a mano por alguien que no leyó este documento tampoco pueda romper el contrato.

07 Cómo se comprueba sola

Enumerar los estilos no cubre los que aún no existen; comprobar la invariante sí. El motor de medición del banco de pruebas se extrae a un script que, en integración continua:

  1. Lee los tokens y calcula el cociente de cada par (tamaño, interlineado).
  2. Mide actualBoundingBox de cada cara contra las cadenas reales de contenido, no contra saludos.
  3. Recalcula R por la fórmula de la capa 5.
  4. Falla si algún token queda por debajo, o si R subió porque alguien cambió una fuente.

El cuarto punto es el que más rinde a medio plazo: convierte «cambiar la fuente tailandesa» en una decisión con un coste visible en el momento de tomarla, en vez de en un recorte que aparece meses después en una tarjeta con overflow: hidden.

08 Lo que esta arquitectura no cubre