Problemas al usar \b en UTF-8 al buscar palabras completas

Utilizando expresiones regulares en php podemos buscar texto en una cadena y hacer lo que queramos con él, por ejemplo, reemplazarlo por otra cadena utilizando funciones como preg_replace, preg_match, etc.

'\b' es un ancla que coincide con una posición, no con un carácter, de fin o comienzo de palabra, los límites de una palabra (word boundary) y por ello es necesario su uso si queremos buscar un determinado texto dentro de una cadena y queremos que el texto buscado coincida con una palabra completa y no un simple fragmento.

Por ejemplo, quiero buscar la palabra "ATR" y reemplazarla por "<a href="http://unenlace.com">ATR</a>":

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="es-es" lang="es-es" dir="ltr" >
 <head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" />
 </head>
 <body>
<?php
$word = 'ATR';
$link = 'http://unenlace.com';
$text = 'La marcha atrás no funciona';
echo text_replace($text,$word,$link);
function text_replace($text,$word,$link) {
         $replace='<a href="'.$link.'">'.$word.'</a>';
         $regEx = '\'(?!((<.*?)|(<a.*?)))('.$word.')(?!(([^<>]*?)>)|([^>]*?</a>))\'si';		 
         $text = preg_replace($regEx,$replace,$text);
         return $text;
}

?>
 </body>
</html>

El regex utilizado en la función preg_replace en el script anterior busca la palabra dada solo si no es ya parte de un enlace. Se han añadido los modificadores 's' para que la búsqueda se haga multilínea e 'i' para que no sea sensible a mayúsculas y minúsculas. Utilizo '\' como delimitador de patrón para no tener que escapar los '\' del enlace.

Ahora, si probamos el script anterior veremos como la palabra "atrás" se transforma en "ATRás" (ATR con el enlace). Esto no es lo que queríamos, lo que queremos es buscar ATR sólo cuándo sea una palabra y no el fragmento de otra palabra, es decir, queremos buscar palabras completas. Para ello debemos utilizar el carácter especial "\b".

El script quedaría:

function text_replace($text,$word,$link) {
         $replace='<a href="'.$link.'">'.$word.'</a>';
         $regEx = '\'(?!((<.*?)|(<a.*?)))(\b'.$word.'\b)(?!(([^<>]*?)>)|([^>]*?</a>))\'si';		 
         $text = preg_replace($regEx,$replace,$text);
         return $text;
}

\b no funciona en UTF-8 o caracteres especial

Si volvemos a probar la función con la introducción de \b en la expresión regular veremos que seguimos teniendo el mismo problema. ¿Es que no funciona la búsqueda de palabras completas en php con \b? Sí funciona pero no funciona correctamente con caracteres especiales de muchos idiomas, es decir, no funciona con una cadena UTF-8. Y nuestra cadena contiene la palabra "atrás" y "á" no es detectado como una letra apareciendo el problema.

La solución al problema UTF-8 de \b

Para solucionar este error se puede introducir el modificador "\u" en la expresión regular, el regex quedaría:

$regEx = '\'(?!((<.*?)|(<a.*?)))(\b'.$word.'\b)(?!(([^<>]*?)>)|([^>]*?</a>))\'siu';

El modificador "\u" indica que la expresión regular sea tratada como UTF-8. Para que esto funcione el motor PCRE de nuestra instalación ha de haber sido compilado con soporte para esto y no es el caso de la mayoría de instalaciones. Tampoco nos vale para crear un script que funcione correctamente en la mayoría de entornos. La expresión regular anterior incluso daría diferentes resultados en entornos Unix y en entornos Windows. Hay muchos comentarios sobre estos problemas en la página de php.net, ¿un bug de php?.

Sea como sea debemos idear otra solución. ¿que podemos hacer? Cómo lo que queremos es tener una expresión regular que busque palabras completas y el ancla \b nos da problemas en UTF-8 podemos usar el conjunto de caracteres "\pL". "\pL" es una clase de caracteres Unicode de la categoría "Letter" y coincide con cualquier punto de código (code point) de una letra. La letra "á" que nos da el problema en nuestro ejemplo puede estar codificada como un sólo código Unicode (U+00E0) o la suma de dos (U+0061 U+0300), y es aquí, cuándo se codifica como la suma de dos (la a + el acento) cuándo aparece el problema ya que es una letra multibyte aunque aparece en la pantella como un solo grafema (la a más el acento, y el acento es una marca, no una letra). No obstante, la suma de los códigos está incluido en la clase \pL, ya que esta clase inlucye letras multibyte. Así que ya tenemos la solución más cerca:

$regEx = '\'(?!((<.*?)|(<a.*?)))((^|[^\pL])'. $word .'([^\pL]|$))(?!(([^<>]*?)>)|([^>]*?</a>))\'si';

¿Qué hemos hecho? Hemos sustituido "\b", que indica inicio o final de palabra, por un subpatrón de caracteres:

  • (^|[^\pL]): lo ponemos delante de la palabra que buscamos e indica comienzo de cadena (^) o cualquier otro carácter que no sea una letra (indicado por la clase [^\pL]).
  • ([^\pL]|$): este subpatrón va después de la palabra que buscamos e indica cualquier carácter que no sea una letra o fin de cadena ($).

Con esto ya lo tenemos casi listo:

<?php
$word = 'atrás';
$link = 'http://unenlace.com';
$text = 'La marcha atrás no funciona';
echo text_replace($text,$word,$link);
function text_replace($text,$word,$link) {
         $replace='<a href="'.$link.'">'.$word.'</a>';
         $regEx = '\'(?!((<.*?)|(<a.*?)))((^|[^\pL])'. $word .'([^\pL]|$))(?!(([^<>]*?)>)|([^>]*?</a>))\'si';		 
         $text = preg_replace($regEx,$replace,$text);
         return $text;
}

?>

Nota que ahora he puesto "atrás" como palabra buscada para ver un nuevo problema que surge. "\b" coincide, como dijimos, con los límites de palabra y esto es una posición de longitud cero, pero al cambiarlo por pL coincidirá con "caracteres" que no sean letras, por ejemplo con un espacio. Si probáis el último ejemplo vereis como aparece "La marchaatrásrno funciona" (atrás con el enlace). Hemos conseguido nuestro propósito, encontramos sólo palabras completas y las reemplazamos por lo que queremos pero nos destroza el formato de la cadena original. Esto se debe a que los caracteres que buscamos con pL no son de longitud cero, como pasa al usar \b que coincide con posiciones (no caracteres) de longitud cero. La solución es sencilla, pasa por poner en la cadena de reemplazao la referencia a los sub-patrones que van delante y después de la palabra. Así pues, la función quedaría definitivamente:

function text_replace($text,$word,$link) {
         $replace='$5<a href="'.$link.'">'.$word.'</a>$6';
         $regEx = '\'(?!((<.*?)|(<a.*?)))((^|[^\pL])'. $word .'([^\pL]|$))(?!(([^<>]*?)>)|([^>]*?</a>))\'si';		 
         $text = preg_replace($regEx,$replace,$text);
         return $text;
}

Nota como he puesto "$5" y "$6" delante y detrás de la cadena de reemplazo. Esto introducirá los caracteres encontrados en los subpatrones que van delante y detrás de la palabra buscada, ya sea un espacio, un punto, una coma. etc.

Con esta función ya podremos buscar palabras completas con php y expresiones regulares evitando el problema de "\b" en UTF-8.



Comentarios (2)

Community Builder Avatar
cybnet
(14.11.2011 (11:21:16))
Sí No Citando "paco" :
Código :
.... lo que sea</p>

Con este código html, si la palabra buscada es "sea" tu solución no funciona.

Tienes razón, pero ese string estaría mal formado, hay que escribir bien y no olvidarse de los signos de puntuación. No obstante pensaré en una expresión regular para buscar palabras completas que contemple casos como ese.
Community Builder Avatar
paco
(10.11.2011 (23:13:20))
Falta algunos detalles Sí No Me gusta un montón este post!! Yo tuve este problema usando preg_replace con cadenas UTF-8 y el dichoso word boundary. La solución pasó por la que tu propones salvo que deberías añadir más comprobociones: por ejemplo si al final de un párrafo se te olvida poner el punto, tu solución no funciona:

Código :
.... lo que sea</p>

Con este código html, si la palabra buscada es "sea" tu solución no funciona.

Smileys

:confused::cool::cry::laugh::lol::normal::blush::rolleyes::sad::shocked::sick::sleeping::smile::surprised::tongue::unsure::whistle::wink: