如何用JavaScript

时间:2015-07-07 17:26:33

标签: javascript html regex algorithm

我有一个具有挑战性的问题需要解决。我正在编写一个以正则表达式作为输入的脚本。然后,此脚本在文档中查找此正则表达式的所有匹配项,并将每个匹配项包装在其自己的< span>中。元件。困难的部分是文本是格式化的html文档,因此我的脚本需要在DOM中导航并同时在多个文本节点上应用正则表达式,同时确定在必要时拆分文本节点的位置。

例如,使用正则表达式捕获以大写字母开头并以句点结尾的完整句子,此文档:

<p>
  <b>HTML</b> is a language used to make <b>websites.</b>
  It was developed by <i>CERN</i> employees in the early 90s.
<p>

将变成这样:

<p>
  <span><b>HTML</b> is a language used to make <b>websites.</b></span>
  <span>It was developed by <i>CERN</i> employees in the early 90s.</span>
<p>

然后脚本返回所有已创建的跨度列表。

我已经有一些代码可以找到所有文本节点并将它们存储在列表中,以及它们在整个文档中的位置及其深度。你真的不需要理解代码来帮助我及其递归结构可能会有点混乱。 T 他的第一部分我不知道该怎么做才能确定哪些元素应该包含在范围内。

function SmartNode(node, depth, start) {
  this.node = node;
  this.depth = depth;
  this.start = start;
}


function findTextNodes(node, depth, start) {
  var list = [];
  var start = start || 0;
  depth = (typeof depth !== "undefined" ? depth : -1);

  if(node.nodeType === Node.TEXT_NODE) {
    list.push(new SmartNode(node, depth, start));
  } else {
    for(var i=0; i < node.childNodes.length; ++i) {
      list = list.concat(findTextNodes(node.childNodes[i], depth+1, start));
      if(list.length) start += list[list.length-1].node.nodeValue.length;
    }
  }

  return list;
}

我想我将从所有文档中创建一个字符串,通过它运行正则表达式并使用列表查找哪些节点对应于巫法正则表达式匹配,然后相应地拆分文本节点。

但是当我有这样的文件时,问题就出现了:

<p>
  This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a>
</p>

这句话从<a>标签之外开始,但在其内部结束。现在,我不希望脚本将该链接拆分为两个标记。在一个更复杂的文档中,它可能会破坏页面。代码可以将两个句子包装在一起:

<p>
  <span>This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a></span>
</p>

或者只是将每个部分包装在自己的元素中:

<p>
  <span>This program is </span>
  <a href="beta.html">
    <span>not stable yet.</span>
    <span>Do not use this in production yet.</span>
  </a>
</p>

可以有一个参数来指定它应该做什么。我不确定如何弄清楚何时会发生不可能的切割,以及如何从中恢复。

当我在像这样的子元素中有空格时,会出现另一个问题

<p>This is a <b>sentence. </b></p>

从技术上讲,正则表达式匹配将在<b>标记结束之前的句点之后结束。但是,将空间视为匹配的一部分会更好,并将其包装成如下:

<p><span>This is a <b>sentence. </b></span></p>

比这个:

<p><span>This is a </span><b><span>sentence.</span> </b></p>

但那是一个小问题。毕竟,我可以在正则表达式中包含额外的空格。

我知道这可能听起来像是&#34;为我这样做&#34;问题,而不是我们每天在SO上看到的那种快速问题,但是我已经坚持了一段时间,并且它是一个开源库,我正在努力。解决这个问题是最后一个障碍。如果您认为另一个SE网站最适合此问题,请重定向我。

5 个答案:

答案 0 :(得分:30)

以下两种方法可以解决这个问题。

我不知道以下完全是否符合您的需求。这是一个简单的问题解决方案,但至少它不使用RegEx来操作HTML标记。它对原始文本执行模式匹配,然后使用DOM来操作内容。

第一种方法

这种方法每次匹配只创建一个<span>标记,利用一些不太常见的浏览器API (请参阅演示下方此方法的主要问题,如果不确定,请使用第二种方法)

Range类表示文本片段。它有一个surroundContents函数,可以让你在一个元素中包装一个范围。除了它有一个警告:

  

此方法几乎等同于newNode.appendChild(range.extractContents()); range.insertNode(newNode)。环绕后,范围的边界点包括newNode

     

但是,如果Range将非Text节点与其一个边界点分开,则会抛出异常。也就是说,与上面的替代方案不同,如果存在部分选定的节点,则不会克隆它们,而是操作将失败。

嗯,MDN中提供了解决方法,所以一切都很好。

所以这是一个算法:

  • 列出Text个节点,并在文本中保留其起始索引
  • 连接这些节点的值以获取text
  • 在文本和每场比赛中查找匹配项:

    • 找到匹配的开始和结束节点,将节点的起始索引与匹配位置进行比较
    • 在比赛中创建Range
    • 让浏览器使用上面的技巧
    • 进行肮脏的工作
    • 自上次操作更改DOM
    • 以来重建节点列表

这是我的演示实现:

function highlight(element, regex) {
    var document = element.ownerDocument;
    
    var getNodes = function() {
        var nodes = [],
            offset = 0,
            node,
            nodeIterator = document.createNodeIterator(element, NodeFilter.SHOW_TEXT, null, false);
            
        while (node = nodeIterator.nextNode()) {
            nodes.push({
                textNode: node,
                start: offset,
                length: node.nodeValue.length
            });
            offset += node.nodeValue.length
        }
        return nodes;
    }
    
    var nodes = getNodes(nodes);
    if (!nodes.length)
        return;
    
    var text = "";
    for (var i = 0; i < nodes.length; ++i)
        text += nodes[i].textNode.nodeValue;

    var match;
    while (match = regex.exec(text)) {
        // Prevent empty matches causing infinite loops        
        if (!match[0].length)
        {
            regex.lastIndex++;
            continue;
        }
        
        // Find the start and end text node
        var startNode = null, endNode = null;
        for (i = 0; i < nodes.length; ++i) {
            var node = nodes[i];
            
            if (node.start + node.length <= match.index)
                continue;
            
            if (!startNode)
                startNode = node;
            
            if (node.start + node.length >= match.index + match[0].length)
            {
                endNode = node;
                break;
            }
        }
        
        var range = document.createRange();
        range.setStart(startNode.textNode, match.index - startNode.start);
        range.setEnd(endNode.textNode, match.index + match[0].length - endNode.start);
        
        var spanNode = document.createElement("span");
        spanNode.className = "highlight";

        spanNode.appendChild(range.extractContents());
        range.insertNode(spanNode);
        
        nodes = getNodes();
    }
}

// Test code
var testDiv = document.getElementById("test-cases");
var originalHtml = testDiv.innerHTML;
function test() {
    testDiv.innerHTML = originalHtml;
    try {
        var regex = new RegExp(document.getElementById("regex").value, "g");
        highlight(testDiv, regex);
    }
    catch(e) {
        testDiv.innerText = e;
    }
}
document.getElementById("runBtn").onclick = test;
test();
.highlight {
  background-color: yellow;
  border: 1px solid orange;
  border-radius: 5px;
}

.section {
  border: 1px solid gray;
  padding: 10px;
  margin: 10px;
}
<form class="section">
  RegEx: <input id="regex" type="text" value="[A-Z].*?\." /> <button id="runBtn">Highlight</button>
</form>

<div id="test-cases" class="section">
  <div>foo bar baz</div>
  <p>
    <b>HTML</b> is a language used to make <b>websites.</b>
	It was developed by <i>CERN</i> employees in the early 90s.
  <p>
  <p>
    This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a>
  </p>
  <div>foo bar baz</div>
</div>

好的,那就是 lazy 方法,遗憾的是在某些情况下不起作用。如果跨内联元素突出显示它会很好,但是由于extractContents函数的以下属性,在途中存在块元素时会中断:

  

克隆部分选定的节点以包含使文档片段有效所必需的父标记。

那很糟糕。它只会复制块级节点。如果你想看看它是如何破坏的,请尝试使用baz\s+HTML正则表达式的上一个演示。

第二种方法

此方法迭代匹配的节点,沿途创建<span>标记。

整体算法很简单,因为它只是将每个匹配节点包装在自己的<span>中。但这意味着我们必须处理部分匹配的文本节点,这需要更多的努力。

如果文本节点部分匹配,则将其与splitText函数分开:

  

拆分后,当前节点包含指定偏移点之前的所有内容,同一类型的新创建节点包含剩余文本。新创建的节点将返回给调用者。

function highlight(element, regex) {
    var document = element.ownerDocument;
    
    var nodes = [],
        text = "",
        node,
        nodeIterator = document.createNodeIterator(element, NodeFilter.SHOW_TEXT, null, false);
        
    while (node = nodeIterator.nextNode()) {
        nodes.push({
            textNode: node,
            start: text.length
        });
        text += node.nodeValue
    }
    
    if (!nodes.length)
        return;

    var match;
    while (match = regex.exec(text)) {
        var matchLength = match[0].length;
        
        // Prevent empty matches causing infinite loops        
        if (!matchLength)
        {
            regex.lastIndex++;
            continue;
        }
        
        for (var i = 0; i < nodes.length; ++i) {
            node = nodes[i];
            var nodeLength = node.textNode.nodeValue.length;
            
            // Skip nodes before the match
            if (node.start + nodeLength <= match.index)
                continue;
        
            // Break after the match
            if (node.start >= match.index + matchLength)
                break;
            
            // Split the start node if required
            if (node.start < match.index) {
                nodes.splice(i + 1, 0, {
                    textNode: node.textNode.splitText(match.index - node.start),
                    start: match.index
                });
                continue;
            }
            
            // Split the end node if required
            if (node.start + nodeLength > match.index + matchLength) {
                nodes.splice(i + 1, 0, {
                    textNode: node.textNode.splitText(match.index + matchLength - node.start),
                    start: match.index + matchLength
                });
            }
            
            // Highlight the current node
            var spanNode = document.createElement("span");
            spanNode.className = "highlight";
            
            node.textNode.parentNode.replaceChild(spanNode, node.textNode);
            spanNode.appendChild(node.textNode);
        }
    }
}

// Test code
var testDiv = document.getElementById("test-cases");
var originalHtml = testDiv.innerHTML;
function test() {
    testDiv.innerHTML = originalHtml;
    try {
        var regex = new RegExp(document.getElementById("regex").value, "g");
        highlight(testDiv, regex);
    }
    catch(e) {
        testDiv.innerText = e;
    }
}
document.getElementById("runBtn").onclick = test;
test();
.highlight {
  background-color: yellow;
}

.section {
  border: 1px solid gray;
  padding: 10px;
  margin: 10px;
}
<form class="section">
  RegEx: <input id="regex" type="text" value="[A-Z].*?\." /> <button id="runBtn">Highlight</button>
</form>

<div id="test-cases" class="section">
  <div>foo bar baz</div>
  <p>
    <b>HTML</b> is a language used to make <b>websites.</b>
	It was developed by <i>CERN</i> employees in the early 90s.
  <p>
  <p>
    This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a>
  </p>
  <div>foo bar baz</div>
</div>

对于我希望的大多数情况,这应该足够好了。如果您需要最小化<span>标签的数量,可以通过扩展此功能来完成,但我现在想保持简单。

答案 1 :(得分:5)

&#13;
&#13;
function parseText( element ){
  var stack = [ element ];
  var group = false;
  var re = /(?!\s|$).*?(\.|$)/;
  while ( stack.length > 0 ){
    var node = stack.shift();
    if ( node.nodeType === Node.TEXT_NODE )
    {
      if ( node.textContent.trim() != "" )
      {
        var match;
        while( node && (match = re.exec( node.textContent )) )
        {
          var start  = group ? 0 : match.index;
          var length = match[0].length + match.index - start;
          if ( start > 0 )
          {
            node = node.splitText( start );
          }
          var wrapper = document.createElement( 'span' );
          var next    = null;
          if ( match[1].length > 0 ){
            if ( node.textContent.length > length )
              next = node.splitText( length );
            group = false;
            wrapper.className = "sentence sentence-end";
          }
          else
          {
            wrapper.className = "sentence";
            group = true;
          }
          var parent  = node.parentNode;
          var sibling = node.nextSibling;
          wrapper.appendChild( node );
          if ( sibling )
            parent.insertBefore( wrapper, sibling );
          else
            parent.appendChild( wrapper );
          node = next;
        }
      }
    }
    else if ( node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_NODE )
    {
      stack.unshift.apply( stack, node.childNodes );
    }
  }
}

parseText( document.body );
&#13;
.sentence {
  text-decoration: underline wavy red;
}

.sentence-end {
  border-right: 1px solid red;
}
&#13;
<p>This is a sentence. This is another sentence.</p>
<p>This sentence has <strong>emphasis</strong> inside it.</p>
<p><span>This sentence spans</span><span> two elements.</span></p>
&#13;
&#13;
&#13;

答案 2 :(得分:5)

正如大家已经说过的那样,这更像是一个学术问题,因为这不应该是你的方式。话虽如此,这似乎很有趣,所以这是一种方法。

编辑:我想我现在已经掌握了它的主旨。

function myReplace(str) {
  myRegexp = /((^<[^>*]>)+|([^<>\.]*|(<[^\/>]*>[^<>\.]+<\/[^>]*>)+)*[^<>\.]*\.\s*|<[^>]*>|[^\.<>]+\.*\s*)/g; 
  arr = str.match(myRegexp);
  var out = "";
  for (i in arr) {
var node = arr[i];
if (node.indexOf("<")===0) out += node;
else out += "<span>"+node+"</span>"; // Here is where you would run whichever 
                                     // regex you want to match by
  }
  document.write(out.replace(/</g, "&lt;").replace(/>/g, "&gt;")+"<br>");
  console.log(out);
}

myReplace('<p>This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a></p>');
myReplace('<p>This is a <b>sentence. </b></p>');
myReplace('<p>This is a <b>another</b> and <i>more complex</i> even <b>super complex</b> sentence.</p>');
myReplace('<p>This is a <b>a sentence</b>. Followed <i>by</i> another one.</p>');
myReplace('<p>This is a <b>an even</b> more <i>complex sentence. </i></p>');

/* Will output:
<p><span>This program is </span><a href="beta.html"><span>not stable yet. </span><span>Do not use this in production yet.</span></a></p>
<p><span>This is a </span><b><span>sentence. </span></b></p>
<p><span>This is a <b>another</b> and <i>more complex</i> even <b>super complex</b> sentence.</span></p>
<p><span>This is a <b>a sentence</b>. </span><span>Followed <i>by</i> another one.</span></p>
<p><span>This is a </span><b><span>an even</span></b><span> more </span><i><span>complex sentence. </span></i></p>
*/

答案 3 :(得分:4)

我会使用“flat DOM”表示来执行此类任务。

在扁平DOM这个段落

<p>abc <a href="beta.html">def. ghij.</p>

将由两个向量表示:

chars: "abc def. ghij.",
props:  ....aaaaaaaaaa, 

您将在chars上使用普通正则表达式标记道具矢量上的跨区域:

chars: "abc def. ghij."
props:  ssssaaaaaaaaaa  
            ssss sssss

我在这里使用原理图表示,它的真实结构是一个数组数组:

props: [
  [s],
  [s],
  [s],
  [s],
  [a,s],
  [a,s],
  ...
]

转换树-DOM&lt; - &gt; flat-DOM可以使用简单的状态自动机。

最后,您将平面DOM转换为树状DOM,如下所示:

<p><s>abc </s><a href="beta.html"><s>def.</s> <s>ghij.</s></p>

以防万一:我在HTML WYSIWYG编辑器中使用这种方法。

答案 4 :(得分:0)

我花了很长时间实施该线程中给出的所有方法。

  1. 节点迭代器
  2. HTML解析
  3. 平面大教堂

对于任何一种方法,您都必须想出一种将整个html拆分为句子并包装为span的技术(有些人可能需要span中的单词)。一旦这样做,我们就会遇到性能问题(我应该说像我这样的初学者会遇到性能问题)。

性能瓶颈

我无法将这种方法的任何一个扩展到70k-200k字,而且仍然可以在几秒钟内完成。包装时间随着页面文字的增加而不断增加。

使用文本节点和不同元素的组合的复杂html页面,我们很快就会遇到麻烦,并且这种技术负担不断增加。

最佳方法:Mark.js(根据我)

注意::如果您正确执行此操作,则可以处理任意数量的以毫秒为单位的单词。

只需使用Ranges,我就推荐Mark.js和以下示例,

var instance = new Mark(document.body);
instance.markRanges([{
    start: 15,
    length: 5
}, {
    start: 25:
    length: 8
}]); /

使用此方法,我们可以将整个body.textContent视为字符串,而只需突出显示substring

此处未修改DOM结构。而且,您可以轻松地修复复杂的用例,并且在其他情况下,技术债务不会增加。

此外,一旦使用html5 mark标签突出显示了文本,您就可以对这些标签进行后期处理以找出边界矩形。

如果您只想将html文档拆分为Splitting.js,还可以查看words/chars/lines,但是这种方法的缺点是Splitting.js会将文档中的其他空格折叠起来所以我们失去了一点信息。

谢谢。