Programação Por Equipe DevBras

Capa do artigo: Cortar texto no limite sem quebrar acento nem emoji

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 strlen e substr contam no PHP. Serve para tamanho de arquivo e de rede, não para limite de leitura.
  • Pontos de código: o que mb_strlen e mb_substr contam, e o que Array.from(texto) separa no JavaScript. Resolve acento pronto e emoji simples.
  • Grafemas: o que grapheme_strlen e Intl.Segmenter contam. É 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

  1. 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.
  2. Se o texto já cabe no limite, devolva como está, sem reticências.
  3. Corte por caractere (ou grafema) deixando uma posição para as reticências.
  4. 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.
  5. 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 substr em texto com acento. Funciona nos testes com texto em inglês e quebra no primeiro "ç" que cair na fronteira.
  • Esquecer o u na regex do PHP. A limpeza de espaços também anda em byte e pode corromper caracteres antes do corte.
  • Cortar com slice no 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 substr ou strlen do 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_tags antes 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?

Avalie este artigo

Referência: http://www.vivaolinux.com.br/dica/Separar-caracteres-de-uma-string-no-PHP