Cortar texto no limite sem quebrar acento nem emoji
Resumo de card e meta description cortados por byte terminam em losango. Veja como cortar por caractere em PHP e JavaScript, voltar ao espaço e pôr reticências.
O card do blog mostra "Promoção de inverno: casacos com 30% até o fim do m�" e para ali, num losango com interrogação. O limite de 120 caracteres foi respeitado, mas foi contado em bytes, e o corte caiu no meio do "ê" de "mês". Para o substr do PHP, aquilo eram 120 unidades certinhas; para quem lê, é texto quebrado na vitrine.
A resposta curta: conte e corte por caractere, nunca por byte. No PHP, use mb_substr com 'UTF-8' (ou grapheme_substr, se a extensão intl estiver instalada). No JavaScript, use Intl.Segmenter quando o texto puder ter emoji. Depois, volte até o último espaço para não partir a palavra e ponha reticências.
Por que o corte parte a letra
Em UTF-8, cada caractere ocupa de 1 a 4 bytes. As letras sem acento usam 1 byte; "ç", "ã" e "ê" usam 2; a maioria dos emoji usa 4. A palavra "Ação" tem 4 letras e 6 bytes. As funções que andam em byte, como substr e strlen do PHP, não sabem disso. substr('Ação', 0, 2) devolve o "A" e só o primeiro byte do "ç", que sozinho não é nada e vira losango na tela.
Existe ainda um terceiro nível. Um emoji com tom de pele, como 👍🏽, é formado por dois pontos de código: a mão e o modificador de cor. Bandeiras são dois pontos de código; emoji de família são vários, colados por um caractere invisível. Uma letra com acento também pode vir como "e" mais um acento combinante, em vez do "é" pronto. O que a pessoa enxerga como uma letra se chama grafema, e pode ter vários pontos de código.
Na prática, você tem três medidas para o mesmo texto:
- Bytes: o que
strlenesubstrcontam no PHP. Serve para tamanho de arquivo e de rede, não para limite de leitura. - Pontos de código: o que
mb_strlenemb_substrcontam, e o queArray.from(texto)separa no JavaScript. Resolve acento pronto e emoji simples. - Grafemas: o que
grapheme_strleneIntl.Segmentercontam. É a letra que o olho vê.
O JavaScript tem uma pegadinha a mais: length e slice contam unidades de UTF-16. O "ç" ocupa uma, mas 👍🏽 ocupa quatro. '👍🏽'.length é 4, Array.from('👍🏽').length é 2, e o Intl.Segmenter conta 1. Um slice no lugar errado parte o emoji ao meio, e o que sobra aparece como um quadrado ou um losango. Se o problema é contar, e não cortar, o post strlen não conta letra mostra as medidas lado a lado.
Como cortar um resumo sem quebrar nada
- Normalize os espaços: troque quebras de linha e espaços repetidos por um espaço só, para não gastar o limite com nada.
- Se o texto já cabe no limite, devolva como está, sem reticências.
- Corte por caractere (ou grafema) deixando uma posição para as reticências.
- Procure o último espaço dentro do corte. Se ele não estiver muito perto do começo, corte ali para não partir a palavra.
- Tire pontuação solta do fim (vírgula, dois-pontos, hífen) e acrescente "…".
A função em PHP
function resumir(string $texto, int $limite = 120): string
{
$texto = trim(preg_replace('/\s+/u', ' ', $texto));
if (mb_strlen($texto, 'UTF-8') <= $limite) {
return $texto;
}
$corte = mb_substr($texto, 0, $limite - 1, 'UTF-8');
$ultimoEspaco = mb_strrpos($corte, ' ', 0, 'UTF-8');
if ($ultimoEspaco !== false && $ultimoEspaco > $limite * 0.6) {
$corte = mb_substr($corte, 0, $ultimoEspaco, 'UTF-8');
}
return rtrim($corte, ' ,.;:-') . '…';
}
echo resumir('Promoção de inverno: casacos com 30% até o fim do mês, frete grátis acima de R$ 199', 50);
// Promoção de inverno: casacos com 30% até o fim…
O modificador u na expressão regular faz o preg_replace tratar o texto como UTF-8. Sem ele, a regex também anda em byte. A regra dos 60% evita o caso em que o único espaço está logo no começo e o resumo ficaria com duas palavras.
Se o seu conteúdo tem muito emoji (comentários, nomes de produto de rede social), troque as funções mb_ pelas equivalentes grapheme_strlen, grapheme_substr e grapheme_strrpos, da extensão intl. A lógica é a mesma; muda a unidade de contagem.
A função em JavaScript
function resumir(texto, limite = 120) {
const limpo = texto.replace(/\s+/g, ' ').trim();
const segmentador = new Intl.Segmenter('pt-BR', { granularity: 'grapheme' });
const letras = Array.from(segmentador.segment(limpo), (s) => s.segment);
if (letras.length <= limite) return limpo;
let corte = letras.slice(0, limite - 1).join('');
const espaco = corte.lastIndexOf(' ');
if (espaco > corte.length * 0.6) corte = corte.slice(0, espaco);
return corte.replace(/[ ,.;:-]+$/, '') + '…';
}
O slice na última linha antes do retorno é seguro porque corta exatamente num espaço, e espaço é sempre fronteira de grafema. O Intl.Segmenter existe nos navegadores atuais e no Node; se você precisa atender um navegador antigo, Array.from(texto) já resolve acento e a maioria dos emoji simples.
Onde o limite deve morar
Uma função só para o card e para a meta description
Quando o card corta no PHP e a meta description corta num template com outra regra, os dois mostram resumos diferentes do mesmo post, e um deles costuma estar quebrado. Centralize o corte numa função e chame a mesma nos dois lugares. O que vai na tag de descrição está em meta tags que o Google lê.
Quando o corte é só visual, deixe o CSS fazer
Se o objetivo é só caber no layout, o CSS corta sem tocar no dado e sem risco de partir a letra. Para uma linha, text-overflow: ellipsis; para várias, o line-clamp:
.card-resumo {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
O texto completo continua no HTML, o leitor de tela lê tudo, e o corte se adapta à largura da tela. Reserve o corte no servidor para quando o texto vai para outro lugar: meta tag, notificação, SMS, coluna com limite.
O banco também conta caractere
No MySQL com utf8mb4, um VARCHAR(120) aceita 120 caracteres, não 120 bytes. Cortar em byte "para caber na coluna" é cortar à toa e ainda quebrar o texto. Se o acento já chega quebrado do banco, o problema é outro, e está em acentos que quebram no banco.
O que não se corta
Senha, token, chave de API e URL não se resumem. Um token cortado não serve para nada e um pedaço de token num log ainda é informação vazada: ou mostre mascarado com os últimos quatro dígitos, ou não mostre. HTML também não se corta como texto: cortar o corpo de um post no meio de um <strong> deixa a tag aberta e o card inteiro em negrito. Tire as tags primeiro (strip_tags) e corte o texto puro.
Erros comuns
- Usar
substrem texto com acento. Funciona nos testes com texto em inglês e quebra no primeiro "ç" que cair na fronteira. - Esquecer o
una regex do PHP. A limpeza de espaços também anda em byte e pode corromper caracteres antes do corte. - Cortar com
sliceno JavaScript sem pensar em emoji. O emoji partido aparece como quadrado e alguns sistemas recusam o texto inválido. - Pôr reticências em texto que já cabia. O título curto ganha "…" e parece incompleto sem estar.
- Cortar HTML como se fosse texto. A tag aberta no meio do resumo quebra o layout do card e dos vizinhos.
- Ter um limite diferente em cada tela. Card, busca e compartilhamento mostram resumos que não batem.
Checklist
- Nenhum corte de texto visível usa
substroustrlendo PHP. - Toda função
mb_recebe'UTF-8'ou o padrão do PHP está definido como UTF-8. - Expressões regulares sobre texto do usuário usam o modificador
u. - O corte volta ao último espaço e só põe reticências quando cortou de fato.
- Card e meta description chamam a mesma função.
- Texto com HTML passa por
strip_tagsantes do corte. - Há um teste com um título que tem "ç" e um emoji exatamente na posição do limite.
Pegue o título mais longo do seu site, ponha um "ç" e um 👍🏽 bem na posição do limite e gere o resumo. Ele termina numa letra inteira, ou o seu corte ainda está contando bytes?
Referência: http://www.vivaolinux.com.br/dica/Separar-caracteres-de-uma-string-no-PHP