一尘不染

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

algorithm

我要解决一个具有挑战性的问题。我正在研究一个将正则表达式作为输入的脚本。然后,此脚本在文档中找到此正则表达式的所有匹配项,并将每个匹配项包装在其自己的元素中。困难的部分是文本是格式化的html文档,因此我的脚本需要在DOM中导航并一次将regex应用于多个文本节点,同时还要弄清楚它在需要时在何处拆分文本节点。

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

<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网站最适合此问题,请重定向我。


阅读 211

收藏
2020-07-28

共1个答案

一尘不染

这有两种处理方法。

我不知道以下内容是否 完全 符合您的需求。这是解决问题的足够简单的方法,但是至少 它不使用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>

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

2020-07-28