Quantos caracteres tem "Ação 👍🏽"? JavaScript diz 9, Python diz 7
Enquanto eu desenvolvia um contador de caracteres para o meu site, um teste meu falhou. Eu esperava que 'Ação 👍🏽'.length desse 8 no JavaScript, mas deu 9. Fui conferir em outras linguagens, e cada uma deu um número diferente:
| Como contei | Resultado |
|---|---|
JavaScript, .length | 9 |
Python, len() | 7 |
PHP, mb_strlen() | 7 |
PHP, strlen() | 15 |
| Como uma pessoa conta | 6 |
As cinco respostas estão certas: cada uma conta uma coisa diferente.
O que cada linguagem conta
- Bytes (15): o
strlendo PHP conta os bytes em UTF-8. "ç" e "ã" ocupam 2 bytes cada, e cada parte do emoji ocupa 4. - Unidades UTF-16 (9): o
.lengthdo JavaScript (e do Java) conta pedaços de 16 bits. Emojis ficam fora do espaço de 16 bits e ocupam dois pedaços cada. - Code points (7): o
lendo Python e omb_strlendo PHP contam os caracteres Unicode. - Grafemas (6): o que aparece na tela. 👍🏽 são dois code points, o polegar e o tom de pele, que viram um desenho só.
Na prática, o problema aparece quando o front-end limita um campo a 280 caracteres com .length e o back-end valida com outra regra. Para a pessoa, o texto cabe; para o servidor, não.
Fica pior
O mesmo "Ação", digitado no Mac ou colado de um PDF, pode vir decomposto: o "ã" vira "a" + til. As duas versões aparecem iguais na tela, mas não são iguais no código:
const a = 'Ação 👍🏽';
const b = a.normalize('NFD');
console.log(a === b); // false
console.log(a.length, b.length); // 9 11
E cortar o texto também cobra o preço. Em JavaScript, 'Ação 👍🏽'.slice(0, 6) corta o emoji no meio e deixa um caractere quebrado no final. Em Python, s[:6] não quebra nada, mas perde o tom de pele e devolve um 👍 amarelo.
Como resolvi
Para contar como uma pessoa conta, uso o Intl.Segmenter, que separa o texto em grafemas e já vem nos navegadores modernos e no Node:
const seg = new Intl.Segmenter('pt-BR', { granularity: 'grapheme' });
const caracteres = (s) => [...seg.segment(s)].length;
caracteres('Ação 👍🏽'); // 6
caracteres('Ação 👍🏽'.normalize('NFD')); // 6
caracteres('👨👩👧'); // 1 (são 8 no .length)
No contador, mostro as três medidas lado a lado: os caracteres como a pessoa vê, os bytes em UTF-8 (o que pesa no banco e na rede) e o .length, para quem precisa bater com um maxlength do HTML.
O que aprendi
- Antes de limitar um campo, decida o que é "caractere" e use a mesma regra no front-end e no back-end.
- Normalize o texto (
normalize('NFC')) antes de comparar ou salvar, para "Ação" ser sempre igual a "Ação". - Para cortar texto sem quebrar emoji, corte por grafemas, não com
slice. - Limite de banco e de API geralmente é em bytes ou em code points, não no que a pessoa vê. Confira na documentação.
Você já tomou um erro desses em produção? Conta nos comentários.