背景

之前有尝试过使用正则提取一篇文章中的标题信息,并还原其中的层级,碰到以下几个问题:

  • 正则规则复杂,调试不方便
  • 正则对于匹配到标题中的编号信息需要在代码中进行二次处理,处理的步骤也很麻烦,需要考虑多种边界条件
  • 当正则变动时,对应的代码也需要进行变动

突发奇想是否可以使用ANTLR4通过构建语法树的方式来解决这几个问题

构建语法树

标题识别的思路

常见标题样式可分为以下两种:

  • (左侧分隔符) 编号 右侧分隔符 正文,比如:

    • 第一章
    • 第一节
    • 1:
    • 2:

    左侧分隔符不一定存在,但是右侧分隔符会存在

  • 编号.编号.编号 右侧分隔符 正文,比如:

    • 1
    • 1.1
    • 1.1.1

对于列举式,像带有如下列所述等字眼,通过a 、b、c等序号一条一条列举出来的暂时不在文本考虑范围内。

开始构建语法树

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
grammar TitleDetect;

//代表整个段落,有3个部分
expr:
title_begin ? num_seg splite
;
//代表段落第一部分,标题的开始
title_begin:
TITLE_BEGIN
;
//代表段落第二部分,编号部分
num_seg:
(num_cn|num) (multive_level_num_splite (num_cn|num))*
;
//代表段落第三部分,分隔符
splite:
(SPLITE_CHAR|SPLITE_SYMBLE|WS|MULTIVE_LEVEL_NUM_SPLITE|NOT_NATURE_CHAR)+
;

num_cn:
NUM_CN
;
num:
NUM
;

multive_level_num_splite:
MULTIVE_LEVEL_NUM_SPLITE
;

WS : [ \t\r] ; // spaces, tabs
TITLE_BEGIN : '第'; // 匹配开头
NUM : [0-9]+; // 匹配阿拉伯数字
NUM_CN : [一二三四五六七八九十]+; // 匹配中文阿拉伯数字
MULTIVE_LEVEL_NUM_SPLITE : [.]+;
SPLITE_SYMBLE : [。.,、,::]+;
SPLITE_CHAR : [条章节话目]+;
NOT_NATURE_CHAR: ~[a-zA-Z0-9\u4e00-\u9fa5];
CHAE: .;

使用antlr4-maven-plugin插件进行编译

插件文档地址: https://www.antlr.org/api/maven-plugin/latest/antlr4-mojo.html

在pom.xml中加入antlr4-maven-plugin插件

注意g4文件所编译代码中的包名,此处演示使用antlr4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<plugin>
<groupId>org.antlr</groupId>
<artifactId>antlr4-maven-plugin</artifactId>
<version>${antlr4.version}</version>
<executions>
<execution>
<goals>
<goal>antlr4</goal>
</goals>
</execution>
</executions>
<configuration>
<visitor>true</visitor>
<arguments>
<argument>-package</argument>
<argument>antlr4</argument> //此处填写包名
</arguments>
</configuration>
</plugin>

用于测试的文件

1
2
3
4
5
6
7
第一章:这是第一章
第二章:这是第二章
第三章:这是第三章

1、 这是一级标题
1.2、 这是二级标题
1.2.3、 这是三级标题

演示代码

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
85
86
87
88
89
90
91
92
93
94
95
96
package org.example;


import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Scanner;


import antlr4.TitleDetectBaseVisitor;
import antlr4.TitleDetectLexer;
import antlr4.TitleDetectParser;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;

public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(Main.class.getClassLoader().getResourceAsStream("input.txt"));
while (scanner.hasNext()) {
String input = scanner.nextLine();
TitleDetectLexer lexer = new TitleDetectLexer(CharStreams.fromString(input));
lexer.removeErrorListeners(); //去除默认的错误处理工具,否则会在stdout中答应错误信息
CommonTokenStream tokens = new CommonTokenStream(lexer);
TitleDetectParser parser = new TitleDetectParser(tokens);
parser.removeErrorListeners(); //去除默认的错误处理工具,否则会在stdout中答应错误信息
ResultWrapper result = parser.expr().accept(new TitleDetectVisitorImpl());
if (parser.getNumberOfSyntaxErrors() == 0
&& !result.level.isEmpty()) {
System.out.format("%s : %s\n", input, result);
}
}
scanner.close();
}
}

class TitleDetectVisitorImpl extends TitleDetectBaseVisitor<ResultWrapper> {
Map<String, Integer> cn2int = Map.of("一", 1, "二", 2, "三", 3, "四", 4, "五", 5, "六", 6, "七", 7, "八", 8, "九", 9, "十", 10);

List<Integer> level = new ArrayList<>();
StringBuilder sb = new StringBuilder();

@Override
public ResultWrapper visitTitle_begin(TitleDetectParser.Title_beginContext ctx) {
sb.append(ctx.getText());
return super.visitTitle_begin(ctx);
}

@Override
public ResultWrapper visitSplite(TitleDetectParser.SpliteContext ctx) {
sb.append(ctx.getText());
return super.visitSplite(ctx);
}

@Override
public ResultWrapper visitNum(TitleDetectParser.NumContext ctx) {
sb.append("num");
level.add(Integer.valueOf(ctx.getText()));
return super.visitNum(ctx);
}

@Override
public ResultWrapper visitNum_cn(TitleDetectParser.Num_cnContext ctx) {
sb.append("num_cn");
level.add(cn2int.get(ctx.getText()));
return super.visitNum_cn(ctx);
}

@Override
public ResultWrapper visitMultive_level_num_splite(TitleDetectParser.Multive_level_num_spliteContext ctx) {
sb.append(ctx.getText());
return super.visitMultive_level_num_splite(ctx);
}

public ResultWrapper defaultResult() {
return new ResultWrapper(sb.toString(), level);
}
}

class ResultWrapper {
public final String patten;
public final List<Integer> level;


ResultWrapper(String patten, List<Integer> level) {
this.patten = patten;
this.level = level;
}

@Override
public String toString() {
return "ResultWrapper{" +
"patten='" + patten + '\'' +
", level=" + level +
'}';
}
}

演示结果

此处拿到了标题的patten,并且可以拿到对应的标题中编号数值及编号层级,在此基础上可以进行对标题层级的还原。

基于语法树的方式相较基于正则的方式在后续维护过程中更为直观,并且在灵活性和性能上也更高。