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:

Comparativa antes y después de una lista de saludos en español, árabe, chino, japonés, coreano, hebreo, tailandés e hindi. En el antes, los textos no latinos se ven notablemente más pequeños que el español; en el después, todos los idiomas presentan el mismo tamaño visual.

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:

Comparativa antes y después de dos tarjetas de notificación, una en árabe y otra en japonés. En el antes, el texto se ve pequeño respecto a los elementos de la interfaz; en el después, títulos, cuerpo y botones presentan una jerarquía tipográfica equilibrada.

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.

Comparativa antes y después de un párrafo en español que incluye topónimos en árabe, chino, japonés, coreano, hebreo y tailandés entre paréntesis. En el antes, los topónimos se ven más pequeños que el texto; en el después, se integran al mismo tamaño visual.

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ónCoste directoCoste ocultoPlazo
Encargar o licenciar cortes árabes/CJK de la fuente de marcaLicencias por script y por peso; a menudo cinco cifrasGestión de contratos por mercadoMeses
Cargar webfonts (Noto y similares) por idiomaHosting/CDNMegabytes por script CJK, peor LCP/CLS justo en los mercados nuevos; pipeline de subsettingSemanas
No hacer nada0 €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:

ScriptmacOS · iOSWindowsAndroid · websize-adjust
ChinoPingFang SCMicrosoft YaHeiNoto Sans SC110 %
Japonés (kana)Hiragino SansYu GothicNoto Sans JP115 %
CoreanoApple SD Gothic NeoMalgun GothicNoto Sans KR115 %
ÁrabeGeeza ProSegoe UI · TahomaNoto Sans Arabic140 %
HebreoArial HebrewSegoe UINoto Sans Hebrew125 %
TailandésThonburiLeelawadee UINoto Sans Thai130 %
DevanagariKohinoor DevanagariNirmala UINoto Sans Devanagari130 %
BengalíBangla Sangam MNNirmala UINoto Sans Bengali125 %
TamilTamil Sangam MNNirmala UINoto Sans Tamil125 %

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:

Escalas de calibración de size-adjust para árabe y chino, con valores del 100 % al 150 % renderizados junto a texto latino. El valor elegido aparece resaltado: 140 % para el árabe y 110 % para el chino.

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:

Navegadorlocal() resuelve por…
Chrome · Edgesolo nombre completo o PostScript
Safari · Firefoxtambié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.