关于HTML:如何用JavaScript在节点中包装部分文本

How to wrap part of a text in a node with JavaScript

我有一个难题要解决。我正在编写一个以regex作为输入的脚本。然后,此脚本在文档中查找此regex的所有匹配项,并将每个匹配项包装在自己的元素中。最困难的部分是,文本是一个格式化的HTML文档,因此我的脚本需要在DOM中导航,并一次在多个文本节点上应用regex,同时在需要时确定在何处拆分文本节点。

例如,使用一个regex,它捕获以大写字母开头、以句点结尾的完整句子,此文档:

1
2
3
4
5
<p>

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

会变成这样:

1
2
3
4
5
<p>

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

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

我已经有了一些代码,可以找到所有的文本节点,并将它们存储在一个列表中,以及它们在整个文档中的位置和深度。您不需要真正理解代码来帮助我,它的递归结构可能会有点混乱。第一部分,我不确定如何做,是找出哪些元素应该包含在这个范围内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;
}

我想我将从所有文档中创建一个字符串,通过它运行regex,并使用列表查找与witch regex匹配的节点,然后相应地拆分文本节点。

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

1
2
3
4
5
<p>

  This program is not stable yet. Do not use this in production yet.

</p>

有一个句子从标签外开始,但在里面结束。现在,我不希望脚本将该链接拆分为两个标记。在更复杂的文档中,如果这样做,可能会破坏页面。代码可以将两个句子组合在一起:

1
2
3
4
5
<p>

  <span>This program is not stable yet. Do not use this in production yet.</span>

</p>

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

1
2
3
4
5
6
7
8
9
<p>

  <span>This program is </span>
 
    <span>not stable yet.</span>
    <span>Do not use this in production yet.</span>
 

</p>

可能有一个参数来指定它应该做什么。我只是不知道怎么弄清楚什么时候不可能发生割伤,以及如何从中恢复过来。

当我在这样的子元素中有空白时,另一个问题出现了:

1
2
3
<p>
This is a sentence.
</p>

从技术上讲,regex匹配将在时期结束后,在标记结束之前结束。但是,最好将空间视为匹配的一部分,然后这样包装:

1
2
3
<p>
<span>This is a sentence. </span>
</p>

比这个:

1
2
3
<p>
<span>This is a </span><span>sentence.</span>
</p>

但这是个小问题。毕竟,我可以允许在regex中包含额外的空白。

我知道这听起来像是一个"为我做"的问题,这不是我们每天都会看到的那种快速的问题,但我已经在这个问题上停留了一段时间,这是为我正在研究的一个开源库。解决这个问题是最后的障碍。如果您认为另一个SE站点最适合这个问题,请重定向我。


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

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

第一途径

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

Range类表示文本片段。它有一个surroundContents函数,允许您在元素中包装一个范围。除非有警告:

This method is nearly equivalent to newNode.appendChild(range.extractContents()); range.insertNode(newNode). After surrounding, the boundary points of the range include newNode.

An exception will be thrown, however, if the Range splits a non-Text node with only one of its boundary points. That is, unlike the alternative above, if there are partially selected nodes, they will not be cloned and instead the operation will fail.

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

下面是一个算法:

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

    • 查找匹配的开始和结束节点,比较节点的开始索引与匹配位置
    • 在匹配项上创建Range
    • 使用上面的技巧让浏览器完成脏工作
    • 自上次操作更改了DOM后重新生成节点列表

下面是我的演示实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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();
1
2
3
4
5
6
7
8
9
10
11
.highlight {
  background-color: yellow;
  border: 1px solid orange;
  border-radius: 5px;
}

.section {
  border: 1px solid gray;
  padding: 10px;
  margin: 10px;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<form class="section">
  RegEx: <input id="regex" type="text" value="[A-Z].*?\." /> <button id="runBtn">Highlight</button>
</form>


  foo bar baz
  <p>

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

  <p>

    This program is not stable yet. Do not use this in production yet.
 
</p>
  foo bar baz

好吧,那是一种懒惰的方法,不幸的是,在某些情况下,这种方法不起作用。如果只在内联元素之间突出显示,则效果很好,但由于extractContents函数的以下属性,在沿途有块元素时会中断:

Partially selected nodes are cloned to include the parent tags necessary to make the document fragment valid.

那太糟糕了。它只复制块级节点。如果您想了解它是如何崩溃的,可以使用baz\s+HTMLregex尝试上一个演示。

第二途径

这种方法迭代匹配的节点,沿途创建标记。

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

如果文本节点部分匹配,则使用splitText函数拆分:

After the split, the current node contains all the content up to the specified offset point, and a newly created node of the same type contains the remaining text. The newly created node is returned to the caller.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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();
1
2
3
4
5
6
7
8
9
.highlight {
  background-color: yellow;
}

.section {
  border: 1px solid gray;
  padding: 10px;
  margin: 10px;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<form class="section">
  RegEx: <input id="regex" type="text" value="[A-Z].*?\." /> <button id="runBtn">Highlight</button>
</form>


  foo bar baz
  <p>

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

  <p>

    This program is not stable yet. Do not use this in production yet.
 
</p>
  foo bar baz

我希望这对大多数情况都足够好。如果您需要最小化标记的数量,可以通过扩展这个函数来实现,但我现在想保持简单。


正如每个人都说过的,这更像是一个学术问题,因为你不应该这样做。尽管如此,这看起来很有趣,所以这里有一个方法。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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;")+"");
  console.log(out);
}

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

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


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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 );
1
2
3
4
5
6
7
.sentence {
  text-decoration: underline wavy red;
}

.sentence-end {
  border-right: 1px solid red;
}
1
2
3
4
5
6
7
8
9
<p>
This is a sentence. This is another sentence.
</p>
<p>
This sentence has emphasis inside it.
</p>
<p>
<span>This sentence spans</span><span> two elements.</span>
</p>


对于这样的任务,我将使用"平面DOM"表示。

在平面图中这段

1
2
3
<p>
abc def. ghij.
</p>

将由两个向量表示:

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

您将在chars上使用normal regexp在props向量上标记跨距区域:

1
2
3
chars:"abc def. ghij."
props:  ssssaaaaaaaaaa  
            ssss sssss

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

1
2
3
4
5
6
7
8
9
props: [
  [s],
  [s],
  [s],
  [s],
  [a,s],
  [a,s],
  ...
]

转换树dom<->平面dom可以使用简单的状态自动机。

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

1
2
3
<p>
<s>abc </s><s>def.</s> <s>ghij.</s>
</p>

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