如何使用 JavaScript 将部分文本包裹在节点中

IT技术 javascript html regex algorithm
2021-03-12 01:56:31

我有一个具有挑战性的问题要解决。我正在编写一个将正则表达式作为输入的脚本。然后,此脚本在文档中查找此正则表达式的所有匹配项,并将每个匹配项包装在其自己的 <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>

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

我已经有一些代码可以找到所有的文本节点,并将它们连同它们在整个文档中的位置和深度一起存储在一个列表中。你真的不需要理解那些代码来帮助我,它的递归结构可能有点令人困惑。Ť他我不知道该怎么办第一部分是搞清楚哪些元素应包括在范围之内。

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>

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

我知道这听起来像是一个“为我做”的问题,而不是我们每天在 SO 上看到的那种快速问题,但我已经坚持了一段时间,这是一个开源库我正在尝试。解决这个问题是最后一个障碍。如果您认为另一个 SE 站点最适合此问题,请重定向我。

5个回答

这里有两种方法来处理这个问题。

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


第一种方法

这种方法在<span>每个匹配项中只创建一个标签,利用了一些不太常见的浏览器 API。
(见demo下面这种方法的主要问题,如果不确定,请使用第二种方法)

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>

好吧,那是一种懒惰的方法,不幸的是在某些情况下不起作用。如果您突出显示内联元素,则效果很好,但由于该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>标签的数量,可以通过扩展这个函数来完成,但我现在想让它保持简单。

非常感谢!我在我的工作场所,在我的新公寓里没有任何互联网,但我会尽快查看所有的小细节。我什至不知道 nodeiterator 对象。(为了安全起见,我会问:我可以在 MIT 许可下发布的开源库中使用其中的一部分吗?如果你愿意,我会相信你)。
2021-04-23 01:56:31
恭喜,并感谢您提供创造性的解决方案:)
2021-04-25 01:56:31
嗨@Lucas Trzesniewski 我不是主题入门者,我可以在 MIT 许可下在我的项目中使用此代码的一部分吗?
2021-05-07 01:56:31
@JacqueGoupil 当然,继续,并在可用时留下带有指向您的库的链接的评论-我对您可以找到的任何小细节感兴趣,因为我希望在我的项目中出现类似的要求(我'我可能不得不在 C# 中重新实现它)。
2021-05-12 01:56:31
@User 当然,继续。只需在评论中添加指向此帖子的链接,因为 SO 许可证要求这样做。
2021-05-16 01:56:31

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 );
.sentence {
  text-decoration: underline wavy red;
}

.sentence-end {
  border-right: 1px solid red;
}
<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>

@JBaba“它打破了”不是一个建设性的声明,因为它没有告诉我你期待什么行为以及你看到什么,然后我需要猜测问题是什么。我建议你将你的例子减少到一个最小的可重现例子,它只包含在“它中断”时演示问题所需的最少代码,然后提出一个新问题,其中包含“它如何中断”的所有细节以及你的期望和你已尝试并参考此问题,然后在此处发布另一条评论,并附上指向该新问题的链接(因此您可以标记我,如果我有时间,我会查看它)。
2021-04-23 01:56:31
@MTO 抱歉评论不充分,我会按照建议的做。
2021-04-24 01:56:31
@MTO 我正在尝试使用它。jsfiddle.net/remixosbox72/9pmb0Lch它在我的示例 html 中中断。这也很长。我已经实现了所有解决方案,甚至最后一个“平面 dom”,但无法将处理时间缩短到几毫秒。只有您在这里的解决方案足够快。你会帮忙吗?因为我是为我的手机写这个,所以它需要更快。
2021-04-28 01:56:31
@MTO 我想让你知道,在修复了复杂的 html 用例之后,我无法将这种方法扩展到 100 万个单词,并且仍然在工厂中处理它。我通过使用范围(Mark.js)提出了第四种方法。
2021-05-10 01:56:31

我会为这样的任务使用“平面 DOM”表示。

在平面 DOM 中这一段

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

将由两个向量表示:

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

您将使用普通的正则表达式chars来标记props向量上的跨度区域:

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

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

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

转换 tree-DOM <-> flat-DOM 可以使用简单的状态自动机。

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

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

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

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

编辑:我想我现在明白了。

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>
*/

重新阅读文本我可能没有理解完整的要求。如果有什么我遗漏的,请指教。
2021-04-29 01:56:31
我认为使用 JavaScript DOM 接口比用正则表达式解析 HTML 标签更实用,但我印象深刻的是,你用那段小代码得到了不错的结果。
2021-05-08 01:56:31
就像我说的,这个答案纯粹是学术性的。可以使用正则表达式完成吗?差不多。它是否可能有无法预料的副作用?大概。你真的应该使用任何其他方法吗?明确地。这是一个有趣的小谜题来解决吗?你打赌。
2021-05-16 01:56:31

我花了很长时间来实现这个线程中给出的所有方法。

  1. 节点迭代器
  2. html解析
  3. 平面 Dom

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

性能瓶颈

我无法将任何这种方法扩展到 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 结构。而且您可以轻松修复复杂的用例,并且技术债务不会随着 if 和 else 的增加而增加。

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

还要看看Splitting.js您是否只想将 html 文档拆分成words/chars/lines更多内容……但是这种方法的一个缺点是会Splitting.js折叠文档中的额外空间,因此我们会丢失一点信息。

谢谢。