Hay un bug que no sale en ningún dashboard. No lanza excepciones ni rompe el build. Se activa el día que tu producto entra en un mercado que no escribe en alfabeto latino, y lo que degrada no es un endpoint: es la marca.
El mecanismo es este. Casi todas las fuentes de marca (Roboto, Suisse Intl, cualquier grotesca corporativa) cubren latín y poco más. Cuando el algoritmo de font matching del navegador se topa con un carácter árabe, chino o hebreo sin glifo en la fuente declarada, desciende por el stack, no encuentra nada y acaba en el fallback del sistema que le parezca: otra personalidad tipográfica, otro tamaño óptico, otra altura-x. El árabe renderiza minúsculo al lado del latín. El CJK cae en lo que haya. Todo «funciona». Y la interfaz transmite descuido justo en el mercado donde más cuesta ganarse la confianza.
Las respuestas habituales cuestan dinero y calendario. Esta es la que implanté en mi propio design system, y su balance cabe en una línea: nueve sistemas de escritura cubiertos con cero kilobytes de descarga, cero licencias nuevas y ningún cambio en los componentes.
El resultado, antes de la técnica
La misma pantalla de bienvenida, con la fuente de marca latina intacta. A la izquierda, lo que entrega el navegador por defecto; a la derecha, lo que entrega el sistema de diseño:
En el «antes» no hay ningún error: es el comportamiento estándar de cualquier web
cuya fuente de marca solo cubre latín. Cada script cae en un fallback distinto y
renderiza más pequeño que el latín al mismo font-size, porque cada sistema de
escritura dibuja sus glifos con una proporción diferente del em square. En el
«después», cada script usa la grotesca nativa de su plataforma, calibrada
ópticamente para igualar la altura visual del latín.
El efecto se ve mejor donde de verdad duele, en componentes de producto. Dos notificaciones, en árabe y en japonés:
Y en el caso más traicionero, el que casi nadie prueba: texto no latino incrustado dentro de una frase latina. Nombres de ciudades, de personas, de marcas.
Aquí ninguna solución basada en :lang() sirve: el matching por idioma opera a
nivel de elemento, y estos glifos conviven con el latín dentro del mismo nodo de
texto. La solución tiene que operar donde opera el problema, carácter a carácter.
Lo que cuesta resolverlo por los caminos habituales
Cuando una organización se topa con esto, sobre la mesa suele haber tres opciones:
| Opción | Coste directo | Coste oculto | Plazo |
|---|---|---|---|
| Encargar o licenciar cortes árabes/CJK de la fuente de marca | Licencias por script y por peso; a menudo cinco cifras | Gestión de contratos por mercado | Meses |
| Cargar webfonts (Noto y similares) por idioma | Hosting/CDN | Megabytes por script CJK, peor LCP/CLS justo en los mercados nuevos; pipeline de subsetting | Semanas |
| No hacer nada | 0 € | Marca rota e ilegibilidad relativa en cada mercado no latino | — |
La cuarta opción es la que documenta este artículo: ingeniería sobre lo que cada sistema operativo ya trae instalado. macOS, Windows, iOS y Android incluyen grotescas nativas muy buenas para cada sistema de escritura (PingFang, Geeza Pro, Yu Gothic, Nirmala UI, las Noto), dibujadas y mantenidas por las propias plataformas. No hay que comprarlas ni descargarlas. Hay que saber invocarlas con criterio tipográfico. Y eso sale en la misma release, sin presupuesto nuevo y sin penalizar el rendimiento.
La solución: tres decisiones de arquitectura
1. Una familia dedicada, delante de la fuente de marca
Declaro una única familia, p4-typo-font-family-non-latin, compuesta por una cara
@font-face por script. Cada cara acota su territorio con unicode-range y
resuelve contra fuentes del sistema con local():
@font-face {
font-family: 'p4-typo-font-family-non-latin';
src: local('PingFang SC Regular'), local('PingFangSC-Regular'),
local('Microsoft YaHei'), local('NotoSansSC-Regular'), local('Noto Sans SC');
size-adjust: 110%;
unicode-range: U+2E80-2EFF, U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FF00-FFEF;
}
@font-face {
font-family: 'p4-typo-font-family-non-latin';
src: local('Geeza Pro Regular'), local('GeezaPro'),
local('Segoe UI'), local('Tahoma'),
local('NotoSansArabic-Regular'), local('Noto Sans Arabic');
size-adjust: 140%;
unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF, U+FB50-FDFF, U+FE70-FEFF;
}
Y la antepongo a la fuente latina en los design tokens de familia:
--p4-typo-font-family-base: 'p4-typo-font-family-non-latin', var(--p4-typo-font-family-roboto);
--p4-typo-font-family-alt: 'p4-typo-font-family-non-latin', var(--p4-typo-font-family-quicksand);
Lo que hace funcionar esto es pura mecánica de la especificación de fuentes de
CSS. El font matching se ejecuta por carácter: para cada glifo, el navegador
recorre el stack y solo considera una cara si su unicode-range contiene ese
carácter. Como la familia no latina declara exclusivamente rangos no latinos, el
texto latino la atraviesa como si no existiera y aterriza en la fuente de marca.
Un carácter árabe o Han, en cambio, casa con su cara dedicada y gana porque va
primera. El reparto de glifos es automático, uno a uno, sin una sola línea de
lógica.
La consecuencia operativa es la que importa: el resto del CSS no se entera.
Cientos de componentes siguen consumiendo el mismo token, sin ramas por idioma,
sin selectores :lang() desperdigados, sin coste de adopción para los equipos.
Toda la integración fueron dos tokens.
2. local() en lugar de webfonts: el coste desaparece
src usa local(), no url(): el navegador resuelve contra las fuentes ya
instaladas en el dispositivo. No hay descarga, no hay licencia, no hay CDN, no
hay FOUT ni layout shift por fuentes tardías. Cada cara lista una cascada de
candidatas por sistema operativo y el navegador activa la primera que exista:
| Script | macOS · iOS | Windows | Android · web | size-adjust |
|---|---|---|---|---|
| Chino | PingFang SC | Microsoft YaHei | Noto Sans SC | 110 % |
| Japonés (kana) | Hiragino Sans | Yu Gothic | Noto Sans JP | 115 % |
| Coreano | Apple SD Gothic Neo | Malgun Gothic | Noto Sans KR | 115 % |
| Árabe | Geeza Pro | Segoe UI · Tahoma | Noto Sans Arabic | 140 % |
| Hebreo | Arial Hebrew | Segoe UI | Noto Sans Hebrew | 125 % |
| Tailandés | Thonburi | Leelawadee UI | Noto Sans Thai | 130 % |
| Devanagari | Kohinoor Devanagari | Nirmala UI | Noto Sans Devanagari | 130 % |
| Bengalí | Bangla Sangam MN | Nirmala UI | Noto Sans Bengali | 125 % |
| Tamil | Tamil Sangam MN | Nirmala UI | Noto Sans Tamil | 125 % |
Todas son grotescas neutras de sistema, elegidas para rimar con una fuente de
marca de palo seco. Nueve bloques de unicode-range cubren desde el CJK hasta los
scripts índicos.
3. Calibración óptica por script, no un número mágico
size-adjust multiplica las métricas de la cara, el escalado del em, sin tocar
el font-size computado: los componentes, los rem y los espaciados quedan
intactos. Es el mismo mecanismo de normalización para fallbacks que
ascent-override o descent-override, aplicado al tamaño óptico.
La tentación es poner un valor único para todo lo no latino. La tipografía lo desmiente: cada sistema de escritura ocupa una proporción distinta del em square. El árabe de Geeza Pro dibuja bajo, con ascendentes tímidos, y necesita un 140 % para plantarse a la altura del latín. Los ideogramas Han llenan casi todo el em y con un 110 % ya quedan parejos. Un valor uniforme que salvara el árabe dejaría el chino desproporcionado en mitad del párrafo.
Cada valor de la tabla sale de una comparación visual directa contra el latín, script a script, valor a valor:
La propiedad CSS es la misma en los dos casos. Lo que cambia es que aquí cada
valor está decidido mirando el resultado y anotado para que otra persona lo pueda
repetir. Un matiz de implementación condiciona toda la arquitectura: size-adjust
es un descriptor de @font-face, no una propiedad, y solo existe dentro de una
cara con src que resuelva. Este escalado por script es literalmente imposible
apilando familias en font-family.
Los detalles que separan «funciona en mi máquina» de «funciona»
Los nombres con los que cada navegador resuelve local()
Aquí es donde la técnica se rompe en silencio, y casi nadie documenta por qué. Una
fuente instalada expone varios registros en su tabla name de OpenType: el
nombre de familia (PingFang SC), el nombre completo (PingFang SC Regular) y el
nombre PostScript (PingFangSC-Regular, único, sin espacios, atado a un corte
concreto). La especificación permite resolver local() por nombre completo o
PostScript; lo que hace cada motor lo tuve que verificar probándolo:
| Navegador | local() resuelve por… |
|---|---|
| Chrome · Edge | solo nombre completo o PostScript |
| Safari · Firefox | también por nombre de familia |
local('Geeza Pro') funciona en Safari y falla en Chrome, justo el navegador
mayoritario. Y el modo de fallo es venenoso: sin resolución no hay cara, sin cara
no hay size-adjust, y todo el sistema degrada al fallback por defecto sin un
solo error en consola. La única defensa es listar las tres formas del nombre en
cada src:
src: local('PingFang SC Regular'), /* Chrome/Edge: nombre completo */
local('PingFangSC-Regular'), /* Chrome/Edge: PostScript */
local('PingFang SC'), /* Safari/Firefox: familia */
El navegador recorre la lista en orden y salta los nombres que no resuelven: añadir variantes no tiene riesgo, solo amplía compatibilidad.
Una cara por script, no una cara con todo
Un único @font-face con todos los local() y todos los rangos parece más
compacto, pero esconde una trampa: una cara activa una sola fuente física, la
primera candidata que resuelva, para la totalidad de su unicode-range. Si
PingFang gana la carrera, el hangul y el árabe quedan huérfanos y caen al fallback
sin escalar. Cada script necesita su cara, con su fuente y su rango.
Una familia weight-agnostic
Las caras no declaran font-weight y anclan al corte Regular del sistema. Los
scripts no latinos no ofrecen la gradación fina de una grotesca latina (pedir
font-weight: 100 sobre CJK produce un emborronado sintético) y una sola familia
mantiene todo en un único archivo. El compromiso, explícito: la negrita no latina
es sintética sobre la Regular. Si un mercado lo pidiera, se añadirían caras 700 con
los nombres PostScript de los cortes Bold. Hasta entonces, es complejidad que no
se paga.
El límite conocido, por escrito
Ninguna técnica cubre todos los casos, y documentar la frontera es parte del
entregable. Chrome en Android restringe local() sobre buena parte de las fuentes
del sistema como medida anti-fingerprinting. En CJK las Noto suelen resolver
porque son familias con nombre direccionable; el árabe es más frágil, porque en
algunos dispositivos vive como familia de fallback anónima en fonts.xml,
inalcanzable para cualquier alias. Cuando pasa eso, las salidas son
:lang(ar) { font-size: 1.4em }, que no depende de local(), o un webfont
quirúrgico solo para ese script y ese segmento de tráfico, decisión que ya puede
tomarse con datos de mercado en la mano. Tener el límite escrito evita que un
equipo persiga durante días un «bug» que en realidad es una restricción de
plataforma.
Llevarlo a otro producto
La técnica se apoya en tres primitivas estándar de CSS (unicode-range,
size-adjust y local()) y no depende de mi marca ni de mi stack: para
trasladarla basta con cambiar la fuente latina del final del token.
Lo que se gana es fácil de contar. Un lanzamiento en árabe, chino o tailandés no espera a una licencia ni a montar un pipeline de webfonts, porque la cobertura tipográfica ya está en producción. No se pagan licencias nuevas ni hosting de fuentes, y no hay megabytes de CJK castigando el LCP en los mercados que se quieren abrir. La adopción fueron dos tokens: ningún equipo tocó un componente ni escribió CSS por idioma. Y los límites conocidos están escritos, que es la única forma de que nadie los descubra a base de perder una tarde.
El árabe es el primer script por el que conviene empezar cualquier prueba: es
donde más se nota la calibración y el que antes revela si local() está
resolviendo de verdad en Chrome.