13

I need to find and replace all text matches in a case insensitive way, unless the text is within an anchor tag - for example:

<p>Match this text and replace it</p>
<p>Don't <a href="/">match this text</a></p>
<p>We still need to match this text and replace it</p>

Searching for 'match this text' would only replace the first instance and last instance.

[Edit] As per Gordon's comment, it may be preferred to use DOMDocument in this instance. I'm not at all familiar with the DOMDocument extension, and would really appreciate some basic examples for this functionality.

István Ujj-Mészáros
  • 3,228
  • 1
  • 27
  • 46
BrynJ
  • 8,322
  • 14
  • 65
  • 89

7 Answers7

18

Here is an UTF-8 safe solution, which not only works with properly formatted documents, but also with document fragments.

The mb_convert_encoding is needed, because loadHtml() seems to has a bug with UTF-8 encoding (see here and here).

The mb_substr is trimming the body tag from the output, this way you get back your original content without any additional markup.

<?php
$html = '<p>Match this text and replace it</p>
<p>Don\'t <a href="/">match this text</a></p>
<p>We still need to match this text and replace itŐŰ</p>
<p>This is <a href="#">a link <span>with <strong>don\'t match this text</strong> content</span></a></p>';

$dom = new DOMDocument();
// loadXml needs properly formatted documents, so it's better to use loadHtml, but it needs a hack to properly handle UTF-8 encoding
$dom->loadHtml(mb_convert_encoding($html, 'HTML-ENTITIES', "UTF-8"));

$xpath = new DOMXPath($dom);

foreach($xpath->query('//text()[not(ancestor::a)]') as $node)
{
    $replaced = str_ireplace('match this text', 'MATCH', $node->wholeText);
    $newNode  = $dom->createDocumentFragment();
    $newNode->appendXML($replaced);
    $node->parentNode->replaceChild($newNode, $node);
}

// get only the body tag with its contents, then trim the body tag itself to get only the original content
echo mb_substr($dom->saveXML($xpath->query('//body')->item(0)), 6, -7, "UTF-8");

References:
1. find and replace keywords by hyperlinks in an html fragment, via php dom
2. Regex / DOMDocument - match and replace text not in a link
3. php problem with russian language
4. Why Does DOM Change Encoding?

I read dozens of answers in the subject, so I am sorry if I forgot somebody (please comment it and I will add yours as well in this case).

Thanks for Gordon and stillstanding for commenting on my other answer.

Community
  • 1
  • 1
István Ujj-Mészáros
  • 3,228
  • 1
  • 27
  • 46
6

Try this one:

$dom = new DOMDocument;
$dom->loadHTML($html_content);

function preg_replace_dom($regex, $replacement, DOMNode $dom, array $excludeParents = array()) {
  if (!empty($dom->childNodes)) {
    foreach ($dom->childNodes as $node) {
      if ($node instanceof DOMText && 
          !in_array($node->parentNode->nodeName, $excludeParents)) 
      {
        $node->nodeValue = preg_replace($regex, $replacement, $node->nodeValue);
      } 
      else
      {
        preg_replace_dom($regex, $replacement, $node, $excludeParents);
      }
    }
  }
}

preg_replace_dom('/match this text/i', 'IT WORKS', $dom->documentElement, array('a'));
netcoder
  • 66,435
  • 19
  • 125
  • 142
  • but this fail when replace hyperlink into it, example, use IT WORKS then echo final output to display at browser, the IT WORKS hyperlink will show as raw plain, not clickable – i need help Jul 28 '22 at 01:29
3

This is the stackless non-recursive approach using pre-order traversal of the DOM tree.

  libxml_use_internal_errors(TRUE);
  $dom=new DOMDocument('1.0','UTF-8');

  $dom->substituteEntities=FALSE;
  $dom->recover=TRUE;
  $dom->strictErrorChecking=FALSE;

  $dom->loadHTMLFile($file);
  $root=$dom->documentElement;
  $node=$root;
  $flag=FALSE;
  for (;;) {
      if (!$flag) {
          if ($node->nodeType==XML_TEXT_NODE &&
              $node->parentNode->tagName!='a') {
              $node->nodeValue=preg_replace(
                  '/match this text/is',
                  $replacement, $node->nodeValue
              );
          }
          if ($node->firstChild) {
              $node=$node->firstChild;
              continue;
          }
     }
     if ($node->isSameNode($root)) break;
     if ($flag=$node->nextSibling)
          $node=$node->nextSibling;
     else
          $node=$node->parentNode;
 }
 echo $dom->saveHTML();

libxml_use_internal_errors(TRUE); and the 3 lines of code after $dom=new DOMDocument; should be able to handle any malformed HTML.

bcosca
  • 17,371
  • 5
  • 40
  • 51
2
$a='<p>Match this text and replace it</p>
<p>Don\'t <a href="/">match this text</a></p>
<p>We still need to match this text and replace it</p>';

echo preg_replace('~match this text(?![^<]*</a>)~i','replacement',$a);

The negative lookahead ensures the replacement happens only if the next tag is not a closing link . It works fine with your example, though it won't work if you happen to use other tags inside your links.

lheurt
  • 391
  • 2
  • 8
1

You can use PHP Simple HTML DOM Parser. It is similar to DOMDocument, but in my opinion it's simpler to use. Here is the alternative in parallel with Netcoder's DomDocument solution:

function replaceWithSimpleHtmlDom($html_content, $search, $replace, $excludedParents = array()) {
    require_once('simple_html_dom.php');
    $html = str_get_html($html_content);
    foreach ($html->find('text') as $element) {
        if (!in_array($element->parent()->tag, $excludedParents))
            $element->innertext = str_ireplace($search, $replace, $element->innertext);
    }
    return (string)$html;
}

I have just profiled this code against my DomDocument solution (witch prints the exact same output), and the DomDocument is (not surprisingly) way faster (~4ms against ~77ms).

Community
  • 1
  • 1
István Ujj-Mészáros
  • 3,228
  • 1
  • 27
  • 46
  • Suggested third party alternatives to [SimpleHtmlDom](http://simplehtmldom.sourceforge.net/) that actually use [DOM](http://php.net/manual/en/book.dom.php) instead of String Parsing: [phpQuery](http://code.google.com/p/phpquery/), [Zend_Dom](http://framework.zend.com/manual/en/zend.dom.html), [QueryPath](http://querypath.org/) and [FluentDom](http://www.fluentdom.org). – Gordon Nov 16 '10 at 10:58
  • @Gordon: I think all of them are builds the DOM by parsing strings (including DOMDocument). The question is how are these doing this (are they mess up the document with unwanted entities for example, or are they just doing their work). And the speed is not a real issue here, because you want only process the document when it gets modified. Anyway, thanks for the suggestions, I will further investigate them. – István Ujj-Mészáros Nov 16 '10 at 13:06
  • @styu all of these are based on DOM and DOM uses libxml. – Gordon Nov 16 '10 at 13:22
  • @Gordon Maybe there is a bug in libxml, but if all of them using DOM, then all of them has the same issues (they are just different wrappers for the same library). phpQuery and Zend_Dom works fine without the DocType declaration, but none of them can handle UTF-8 encoding. They are transforming ÁÍŰŐ into ÃÃŰŠor ÁÍŰŐ If you know a proper solution with DOM, please describe it, and I will happily use it. – István Ujj-Mészáros Nov 16 '10 at 18:08
  • @styu DOM works fine with UTF-8 and does not transform anything unless you tell it to. If you need help using DOM, feel free to make it into a question and I might be inclined to answer it. [Some of my many previous answers on DOM usage might help you too, too](http://stackoverflow.com/search?q=user%3A208809+dom), as might [Best methods to parse HTML](http://stackoverflow.com/questions/3577641/best-methods-to-parse-html) – Gordon Nov 16 '10 at 18:18
  • @styu: see how my solution handles utf-8 – bcosca Nov 17 '10 at 09:24
  • @stillstanding: Your rev5 version works with [this](http://pastie.org/1305199) HTML code, but rev6 drops a Fatal error: Maximum execution time of 30 seconds exceeded. Is it possible to load only [this](http://pastie.org/1305222) part of the HTML, and save it without the full DOM tree? Simple HTML DOM is doing this without any further configuration (but I am still interested in the DOMDocument solution). – István Ujj-Mészáros Nov 17 '10 at 11:12
  • There's DOMDocumentFragment for handling partial HTML/XML documents: http://php.net/manual/en/class.domdocumentfragment.php – bcosca Nov 17 '10 at 12:04
  • @Gordon, @stillstanding: I have just posted an other answer with DomDocument, according to my experiences. Thanks for your comments. – István Ujj-Mészáros Nov 17 '10 at 22:49
  • @Gordon Please review my other, [DomDocument related answer](http://stackoverflow.com/questions/2735291/php-domdocument-class-unable-access-domnode/4230447#4230447) for a quite old question, where I compared two solution, one with DomDocument and the same with Simple Html DOM Parser. – István Ujj-Mészáros Nov 20 '10 at 00:13
0
<?php
$a = '<p>Match this text and replace it</p>
<p>Don\'t <a href="/">match this text</a></p>
<p>We still need to match this text and replace it</p>
';
$res = preg_replace("#[^<a.*>]match this text#",'replacement',$a);
echo $res;
?>

This way works. Hope you want realy case sensitive, so match with small letter.

MnomrAKostelAni
  • 458
  • 1
  • 4
  • 13
  • I'm sorry, but this is not going to work in many cases. Right now, you're looking for "match this text", preceded by any character except `<`, `.`, `*` or `>`... – Tim Pietzcker Nov 11 '10 at 11:07
  • this code really isn't going to do the job. There are a dozen senarios where this would fail to do it's job. – Caleb Nov 13 '10 at 09:52
0

HTML parsing with regexs is a huge challenge, and they can very easily end up getting too complex and taking up loads of memory. I would say the best way is to do this:

preg_replace('/match this text/i','replacement text');
preg_replace('/(<a[^>]*>[^(<\/a)]*)replacement text(.*?<\/a)/is',"$1match this text$3");

If your replacement text is something which might occur otherwise, you might want to add an intermediate step with some unique identifier.

Nathan MacInnes
  • 11,033
  • 4
  • 35
  • 50