Longitud de strings en 8 lenguajes: emoji y grafemas

Cuánto mide «José Muñoz», 👍🏽, 👨‍👩‍👧 o 🇲🇽 en Python, JS, Swift, Dart, Java, C#, PHP y SQLite, medido, con bugs al recortar y límites de columnas.

Serie: curso de texto y codificación 5/6 (avanzado). Para quien valida, recorta o guarda texto de usuarios y descubrió que un "máximo de 10 caracteres" significa algo distinto en cada capa. Al terminar vas a saber cuál de las cuatro "longitudes" devuelve cada API, cómo cortar un string sin romper un emoji y qué conteo usar en la interfaz, en la base de datos y en un protocolo.

Esta clase es para ti si el formulario de registro aceptó mia_🇲🇽🌮 y la API lo rechazó, si una notificación push terminaba en ?, o si un "Leer más…" convirtió a la familia del emoji en una pareja. La respuesta corta: un string no tiene una sola longitud. Tiene bytes UTF-8, unidades de código UTF-16, puntos de código y grupos de grafemas, y cada lenguaje elige uno como su "length" por defecto. Aquí pasamos los mismos strings por Python, JavaScript, Swift, Dart, Java, C#, PHP y SQLite, y cada número sale de una ejecución real.

Entorno de todas las salidas: Mac con Apple Silicon (M1 Pro), macOS 26.6, Python 3.9.6 (base de datos Unicode 13.0), JavaScriptCore jsc de macOS, Swift 6.4.2, Dart 3.11.5, OpenJDK 26, .NET 10.0.403 (C#), PHP 8.5.6 con intl (ICU 78.3) y SQLite 3.51.0. No se ejecutaron Node.js, navegadores, MySQL ni Windows.

Las cuatro "longitudes" de un string

En la clase 1 separamos caracteres, puntos de código y bytes. Para comparar lenguajes hace falta una capa más, porque JavaScript y Java guardan el texto en UTF-16:

  • Bytes UTF-8: lo que viaja por la red y se guarda en disco. strlen de PHP, utf8.count de Swift, octet_length() de SQLite.
  • Unidades de código UTF-16: bloques de 16 bits. Los caracteres por encima de U+FFFF (casi todos los emoji, CJK poco común) necesitan dos, y a esa pareja se le llama par sustituto (surrogate pair). .length de JavaScript, length() de Java, Length de C#, .length de Dart.
  • Puntos de código: los caracteres numerados de Unicode (U+00F1 es ñ). len() de Python, mb_strlen de PHP, length() de SQLite.
  • Grupos de grafemas: lo que una persona llama "una letra". Una bandera son dos puntos de código, 👍🏽 es un pulgar más un modificador de tono de piel, y 👨‍👩‍👧 son tres personas unidas por dos ZERO WIDTH JOINER (U+200D) invisibles. Una ñ en NFD (n + tilde combinable) también es un solo grafema. Unicode lo define como extended grapheme cluster (UAX #29).
Diagrama. El emoji de familia hombre, mujer y niña es 1 grafema (count de Swift, Intl.Segmenter, grapheme_strlen de PHP), 5 puntos de código (len de Python, mb_strlen de PHP, length de SQLite), 8 unidades UTF-16 (length en JavaScript, Java, C# y Dart) y 18 bytes UTF-8 (strlen de PHP, octet_length de SQLite).
Un emoji de familia, cuatro longitudes. Cada lenguaje elige una capa como su longitud por defecto.

La gran tabla: 14 strings medidos

En cada lenguaje escribimos los strings con escapes ("ñ", "\u{1F468}‍…") para que ningún editor los normalizara. Dentro de cada unidad, todas las API coincidieron exactamente; la tabla siguiente dice qué API alimentó cada columna.

String (puntos de código) Bytes UTF-8 Unidades UTF-16 Puntos de código Grafemas
é NFC (U+00E9) 2 1 1 1
é NFD (U+0065 U+0301) 3 2 2 1
한 NFC (U+D55C) 3 1 1 1
한 NFD (3 jamo) 9 3 3 1
👍 4 2 1 1
👍🏽 (pulgar + tono de piel) 8 4 2 1
👨‍👩‍👧 (3 personas + 2 ZWJ) 18 8 5 1
🇯🇵 (2 indicadores regionales) 8 4 2 1
𠮷 (U+20BB7) 4 2 1 1
ガ NFD (カ + U+3099) 6 2 2 1
año 4 3 3 3
文字化け 12 4 4 4
한글 6 2 2 2
café 5 4 4 4

Y los strings que de verdad llegan a un formulario en español:

String Bytes UTF-8 Unidades UTF-16 Puntos de código Grafemas
José Muñoz (NFC, tecleado) 12 10 10 10
José Muñoz (NFD, copiado de un PDF) 14 12 12 10
¿Qué tal, señor? 19 16 16 16
mia_🇲🇽🌮 16 10 7 6

Las dos versiones de José Muñoz se ven idénticas en pantalla, pero una mide 10 y la otra 12 en Python, y la NFD convertida a Windows-1252 con errors='replace' quedó como b'Jose? Mun?oz': los acentos sueltos no existen en esa codificación. La normalización NFC/NFD se explica en la clase 3. Y mia_🇲🇽🌮 son 6 caracteres para el usuario, 7 para Python, 10 para JavaScript y 16 bytes para la base de datos.

Qué API devuelve qué unidad

Lenguaje Bytes UTF-8 Unidades UTF-16 Puntos de código Grafemas
Python 3.9 len(s.encode()) len(s.encode('utf-16-le'))//2 len(s) no hay en la stdlib
JavaScript (jsc) jsc no tiene TextEncoder s.length [...s].length Intl.Segmenter
Swift s.utf8.count s.utf16.count s.unicodeScalars.count s.count
Dart (utf8.encode) s.length s.runes.length no hay en el core
Java 26 getBytes(UTF_8).length s.length() codePointCount BreakIterator, regex \X
C# (.NET 10) Encoding.UTF8.GetByteCount s.Length EnumerateRunes().Count() StringInfo.LengthInTextElements
PHP 8.5 strlen — mb_strlen grapheme_strlen
SQLite 3.51 octet_length() — length() no hay

Lo que la tabla no muestra y comprobamos directamente:

  • Este jsc sí tiene Intl.Segmenter (typeof Intl.Segmenter imprimió function), pero no TextEncoder (undefined), así que contar bytes en jsc requiere un bucle propio.
  • Python 3.9 no sabe de grafemas: re.compile(r'\X') falla con re.error: bad escape \X at position 0. El módulo externo regex sí soporta \X, pero en esta clase no se instaló nada.
  • Swift también compara por grafemas: "\u{00E9}" == "e\u{0301}" imprimió true en Swift, false en Dart y False en C# con ==. En C#, string.Compare(..., StringComparison.InvariantCulture) devolvió 0 (iguales).
  • SQLite no sabe de acentos: upper('José Muñoz') devolvió JOSé MUñOZ, porque su upper() integrado solo convierte ASCII. Si buscas sin distinguir mayúsculas, hazlo en la aplicación o con una extensión ICU.

Bugs al recortar, con salidas reales

Contar mal es molesto; cortar mal corrompe datos. Esto pasa al cortar con la unidad por defecto de cada lenguaje.

JavaScript: un sustituto huérfano. "OK👍".slice(0, 3) conserva solo la primera mitad del par:

"OK\ud83d" 3 false OK�
URIError: String contained an illegal UTF-16 sequence.

Los valores son JSON.stringify(cut), cut.length, cut.isWellFormed() y cut.toWellFormed(). El string roto sigue siendo un string JS válido, así que nada falla hasta que sale del programa: encodeURIComponent lanzó el URIError de arriba. Es el bug de "la búsqueda funciona para todos menos para quien puso un emoji".

JavaScript otra vez: la familia pierde a la hija. Cortar el emoji de familia en 5 unidades da "👨‍👩": string válido, sin error, con otro significado. Cortar por puntos de código ([...s].slice(0, 3)) deja a las mismas dos personas. Solo cortar por grafemas es seguro:

const seg = new Intl.Segmenter("es", {granularity: "grapheme"});
function truncate(str, n) {
  let out = "", i = 0;
  for (const {segment} of seg.segment(str)) {
    if (i++ === n) return out + "…";
    out += segment;
  }
  return out;
}
print(truncate("Hola \u{1F1F2}\u{1F1FD}!", 6));   // Hola 🇲🇽…

Java y C#: medio emoji se vuelve ? o � al salir. En Java, "OK👍".substring(0, 3) codificado a UTF-8 da 4f 4b 3f, así que a la base de datos le llega un signo de interrogación literal. En C#, Substring(0, 3) se codificó como 4F4BEFBFBD, que es U+FFFD (�). Con un codificador estricto (new UTF8Encoding(false, true)), .NET se niega:

EncoderFallbackException: Unable to translate Unicode character \\uD83D at index 2 to specified code page.

En Java, corta en un límite de punto de código (s.offsetByCodePoints(0, n)) o, mejor, en un límite de BreakIterator; en C#, new StringInfo(s).SubstringByTextElements(0, 3) devolvió 가족👨‍👩‍👧 completo.

Python y PHP: cortar bytes. Los límites en bytes son los que más duelen, porque los protocolos y las columnas miden bytes. En Python:

>>> "año".encode()[:2].decode()
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc3 in position 1: unexpected end of data

Con errors="ignore" se descarta el carácter partido y queda a, lo que parece una solución. No lo es: "OK👍🏽" cortado a 7 bytes quedó como OK👍. El modificador de tono de piel desapareció en silencio y el emoji del usuario cambió. En PHP, substr("한글 깨짐", 0, 4) devolvió los bytes ed959cea y json_encode de eso devolvió false con Malformed UTF-8 characters, possibly incorrectly encoded: así es como una respuesta de API entera queda vacía. mb_substr corta por punto de código, así que mb_substr("가족👨‍👩‍👧", 0, 4) terminó con un ZWJ colgando (…f09f91a8e2808d).

La extensión intl de PHP tiene la herramienta correcta: grapheme_extract con GRAPHEME_EXTR_MAXBYTES devuelve el tramo más largo de grafemas completos que cabe en N bytes:

$s = "가족👨‍👩‍👧!";             // 6 + 18 + 1 = 25 bytes
foreach ([23, 24] as $max) {
    $next = 0;
    var_dump(grapheme_extract($s, $max, GRAPHEME_EXTR_MAXBYTES, 0, $next), $next);
}
// string(6) "가족"      int(6)    (la familia necesita 24 bytes)
// string(24) "가족👨‍👩‍👧" int(24)

Error común: un "largo máximo" contado en tres capas

El error. La regla de producto dice "nombre visible de hasta 10 caracteres". El formulario web comprueba value.length <= 10 (UTF-16), la API comprueba len(name) <= 10 (puntos de código) y la columna es VARCHAR(10).

Lo que pasa en realidad. Cinco banderas 🇲🇽🇯🇵🇰🇷🇪🇸🇺🇸 son 5 grafemas, 10 puntos de código y 20 unidades UTF-16: el formulario dice "demasiado largo" ante algo que se ve como cinco caracteres. Un "José Muñoz" en NFD cuenta 12 en la API. Y un nombre cortado con name[:10] en la API puede terminar a mitad de una secuencia ZWJ.

Solución. Decide por capa, a propósito:

Qué limitas Qué cuentas Por qué
Lo que ve el usuario ("10 caracteres") grafemas coincide con la pantalla; nunca parte un emoji
Un presupuesto en bytes (columna, header, payload de push) bytes UTF-8, cortando en límites de grafema el límite es físico
VARCHAR(n) en MySQL, length() en SQLite puntos de código (tras normalizar a NFC) es lo que cuenta la base de datos
Índices de string y posición del cursor en JS, Java, C# unidades UTF-16 es lo que indexa la API

Luego aplica el límite más estricto en el servidor y muestra el del usuario en la interfaz. Si haces apps con Flutter: según su documentación, TextField.maxLength cuenta grupos de grafemas, mientras que el filtro clásico maxLength de Android cuenta chars UTF-16 (ambos descritos según la documentación, no ejecutados aquí).

Columnas de base de datos: SQLite medido, MySQL descrito

SQLite trata el largo declarado como decoración:

sqlite> create table u(name varchar(5));
sqlite> insert into u values ('abcdefghijklmnop');
sqlite> select name, length(name), octet_length(name) from u;
abcdefghijklmnop|16|16
👨‍👩‍👧|5|18

Si necesitas un límite, escríbelo tú, y recuerda que length() cuenta puntos de código: con check (length(name) <= 4), 文字化け se aceptó y el emoji de familia se rechazó con CHECK constraint failed: length(name) <= 4 (19), aunque en pantalla sea un solo carácter. substr(fam, 1, 2) devolvió los bytes F09F91A8E2808D: un hombre más un unificador colgando. SQLite tampoco normaliza: char(0xe9) = 'e' || char(0x301) devolvió 0. Lo básico de las consultas está en el curso de SQL.

MySQL (no se ejecutó aquí; según el manual de referencia de MySQL): VARCHAR(n) cuenta caracteres (puntos de código), no bytes. El charset llamado utf8 es un alias de utf8mb3, que guarda como máximo 3 bytes por carácter, así que cualquier carácter de 4 bytes (todos los emoji, y 𠮷) se rechaza en modo estricto con el error 1366 "Incorrect string value". Usa utf8mb4, el valor por defecto desde MySQL 8.0. Por debajo del límite en caracteres siguen existiendo límites en bytes: una fila admite hasta 65,535 bytes y la clave de un índice tiene un tope en bytes (767 o 3,072 según el formato de fila), así que un VARCHAR(255) en utf8mb4 puede necesitar hasta 1,020 bytes en un índice.

Medición: cuánto cuesta contar grafemas

Contar grafemas no es gratis: hay que recorrer el string aplicando las reglas de UAX #29. Repetimos "文字化けcafé한글👍🏽 " 100,000 veces (1,200,000 grafemas, 1,300,000 puntos de código, 1,500,000 unidades UTF-16, 3,200,000 bytes) y tomamos la mediana de 5 ejecuciones:

Lenguaje Llamada Resultado Mediana
jsc s.length 1,500,000 0.0 ms
jsc [...s].length 1,300,000 22.1 ms
jsc bucle con Intl.Segmenter 1,200,000 84.2 ms
PHP 8.5 strlen 3,200,000 0.00 ms
PHP 8.5 mb_strlen 1,300,000 2.99 ms
PHP 8.5 grapheme_strlen 1,200,000 25.20 ms
Swift -O s.unicodeScalars.count 1,300,000 5.37 ms
Swift -O s.count 1,200,000 17.69 ms
Python 3.9 len(s.encode()) 3,200,000 1.61 ms
Gráfico de barras de medianas sobre un string de 3.2 MB: Intl.Segmenter en jsc 84.2 ms, grapheme_strlen de PHP 25.2 ms, spread a puntos de código en jsc 22.1 ms, count de Swift 17.7 ms, unicodeScalars.count de Swift 5.4 ms, mb_strlen de PHP 3.0 ms; .length en jsc y strlen en PHP tardaron unos 0 ms.
Contar 1,200,000 grafemas (mediana de 5 ejecuciones, Apple M1 Pro). Las longitudes guardadas son gratis; los grafemas recorren todo el string.

Dos lecciones. Las longitudes guardadas (.length, strlen, len de Python) son O(1). Contar grafemas es O(n): unos 70 ns por grafema con Intl.Segmenter en jsc y 21 ns con grapheme_strlen de PHP, que usa ICU. Para un nombre de 30 caracteres no importa; dentro de un bucle sobre un log de 10 MB, sí. Cuenta grafemas en los bordes (validación, recorte para mostrar), no en bucles críticos.

Límites honestos y notas de versión

  • Intl.Segmenter está en ECMA-402 (edición 2022); solo comprobamos que este jsc de macOS lo tiene. isWellFormed/toWellFormed son de ES2024 y también existen aquí.
  • Según sus notas de versión, BreakIterator de Java sigue los grupos de grafemas extendidos desde JDK 20; solo lo comprobamos en 26. La regex \X existe desde Java 9.
  • StringInfo de .NET pasó a grupos de grafemas extendidos en .NET 5 (un cambio incompatible documentado); se ejecutó en .NET 10.
  • grapheme_strlen depende de la versión de ICU incluida en PHP (aquí 78.3). Con un ICU más viejo, los emoji nuevos pueden segmentarse distinto; el unicodedata de Python 3.9 es Unicode 13.0, más antiguo que ICU 78.
  • En Dart, los grafemas se manejan con el paquete characters (el que usa Flutter); no se ejecutó porque no se podía instalar nada.
  • MySQL, Node.js, navegadores y Android no se ejecutaron; lo dicho sobre ellos viene de su documentación.

Ejercicios

1. Predice y luego comprueba: la bandera de México 🇲🇽 en .length de JS, len de Python, count de Swift y strlen de PHP.

2. Escribe en PHP una función cut_for_column(string $s, int $maxBytes): string que nunca parta un grafema, y pruébala con "가족👨‍👩‍👧!" usando 23 y 24.

Respuestas

1. Medido: 4, 2, 1, 8. Son dos indicadores regionales (U+1F1F2 y U+1F1FD), cada uno de 4 bytes y 2 unidades UTF-16, que forman un solo grafema.

2. function cut_for_column(string $s, int $maxBytes): string { $next = 0; return grapheme_extract($s, $maxBytes, GRAPHEME_EXTR_MAXBYTES, 0, $next) ?: ""; } devolvió 가족 (6 bytes) con 23 y el 가족👨‍👩‍👧 de 24 bytes con 24, como se ve arriba. El ?: "" cubre el false que devuelve grapheme_extract cuando la entrada no es UTF-8 válido.

Lo que sigue

La última clase responde la pregunta con la que termina toda importación de un sistema viejo: el archivo no declara su codificación, ¿puede adivinarla un programa? Medimos cuántas veces acierta, qué tan rápida es la conversión, y corregimos el bug de lectura por bloques de la clase 1.

Preguntas frecuentes

¿Por qué en JavaScript un emoji tiene length 2?

Porque .length cuenta unidades de código UTF-16, y todo emoji fuera del plano básico necesita dos (un par sustituto). 👍 mide 2, 👍🏽 mide 4 y el emoji de familia 👨‍👩‍👧 mide 8. [...s].length cuenta puntos de código (1, 2, 5) e Intl.Segmenter cuenta lo que ve el usuario (1, 1, 1); lo medimos en JavaScriptCore sobre macOS 26.

¿Por qué «José Muñoz» a veces mide 12 en vez de 10?

Porque está en forma NFD: la é es una e más un acento combinable (U+0301) y la ñ es una n más una tilde (U+0303). Son 12 puntos de código pero 10 grafemas. Pasa con texto copiado de un PDF o con nombres de archivo creados en Mac. Normaliza a NFC antes de contar o comparar.

¿Cabe un emoji en una columna VARCHAR de MySQL con utf8?

No. En MySQL, utf8 es un alias de utf8mb3, que guarda como máximo 3 bytes por carácter, y todo emoji ocupa 4 bytes en UTF-8; en modo estricto da el error 1366 Incorrect string value. Usa utf8mb4, el valor por defecto desde MySQL 8.0. Esta parte se describe según la documentación de MySQL; no se ejecutó aquí. SQLite, en cambio, ni siquiera respeta el largo: VARCHAR(5) aceptó 16 caracteres.

  • #codificación
  • #avanzado
  • #Unicode
  • #emoji
  • #grafemas
  • #JavaScript
  • #Swift
  • #Java
  • #PHP

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑