feat(symbol): 正文标准 Unicode 数学符号 → LaTeX(与公式同一条渲染路径)

背景:公式(OMML/MathType/EQ)已转 LaTeX,但正文里的裸 Unicode 符号
(⊆ ∈ ∪ ≤ × → ∅ ∞ ∑ √ …)docx4j 只原样输出,靠系统字体渲染,字形/字号
与公式不一致;且 EqToLatexUtil 未覆盖的符号会以裸字符留在公式 LaTeX 内部。

实现 UnicodeSymbolLatex:
- 53 条映射,取自 jby omml_mathml2latex/data/symbols.py,保证与公式内转换一致
  (⊆→\subseteq、∈→\in、≤→\leqslant、×→\times、→→\rightarrow、∅→\emptyset …)
- 文本节点内:符号 → \( … \)(MathJax 渲染)
- 已在 \(…\)/\[…\] 内的片段:只换命令不加包裹(修 LaTeX 内部裸字符)
- 跳过 <script>/<style>/<textarea> 与所有标签属性
- 开关 wordparse.convert-unicode-symbols(默认 true)

效果:46 份教辅重新生成后,正文裸 Unicode 符号 0 处,公式片段 5394 处
This commit is contained in:
2026-09-29 15:33:33 +08:00
parent 09c58defa3
commit 6071a2d98c
4 changed files with 170 additions and 0 deletions

View File

@@ -15,6 +15,8 @@ public class WordParseConfig {
private String pythonCommand = "python3";
/** 私有区符号图的基址(默认我们自己的 RustFS,不依赖 jby CDN) */
private String symbolBaseUrl = "https://fs.code-lab.cn/temp/wordparse-symbols/";
/** 是否把正文里的标准 Unicode 数学符号转成 LaTeX(默认开) */
private Boolean convertUnicodeSymbols = Boolean.TRUE;
public String getTmpPath() { return tmpPath; }
public Double getImageScalingFactor() { return imageScalingFactor; }
@@ -23,6 +25,8 @@ public class WordParseConfig {
public String getPythonCommand() { return pythonCommand; }
public String getSymbolBaseUrl() { return symbolBaseUrl; }
public void setSymbolBaseUrl(String symbolBaseUrl) { this.symbolBaseUrl = symbolBaseUrl; }
public Boolean getConvertUnicodeSymbols() { return convertUnicodeSymbols; }
public void setConvertUnicodeSymbols(Boolean v) { this.convertUnicodeSymbols = v; }
public void setTmpPath(String v) { this.tmpPath = v; }
public void setImageScalingFactor(Double v) { this.imageScalingFactor = v; }

View File

@@ -0,0 +1,161 @@
package com.wordparse.core;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 正文里的标准 Unicode 数学符号 → LaTeX(供前端 MathJax 渲染)。
*
* 为什么需要:公式(OMML/MathType/EQ)已转成 LaTeX,但正文里的裸 Unicode 符号
* (⊆ ∈ ∪ ≤ × …)docx4j 只会原样输出,靠系统字体渲染,字形/字号与公式不一致。
* 这里把正文符号也包成 \(...\),与公式走同一条渲染路径。
*
* 映射表取自 jby 的 omml_mathml2latex/data/symbols.py,保证与公式内转换一致。
* 跳过:<script>/<style> 内容、标签属性、以及已处于 \(...\) / \[...\] 内的文本。
*/
public final class UnicodeSymbolLatex {
private static final Map<String, String> MAP;
static {
Map<String, String> m = new LinkedHashMap<>();
m.put("±", "\\pm");
m.put("×", "\\times");
m.put("÷", "\\div");
m.put("←", "\\leftarrow");
m.put("↑", "\\uparrow");
m.put("→", "\\rightarrow");
m.put("↓", "\\downarrow");
m.put("⇌", "\\rightleftharpoons");
m.put("⇒", "\\Longrightarrow");
m.put("⇔", "\\Longleftrightarrow");
m.put("∅", "\\emptyset");
m.put("∈", "\\in");
m.put("∉", "\\notin");
m.put("∏", "\\prod");
m.put("∑", "\\sum");
m.put("√", "\\sqrt{}");
m.put("∝", "\\propto");
m.put("∞", "\\infty");
m.put("∠", "\\angle");
m.put("∥", "\\parallel");
m.put("∩", "\\cap");
m.put("∪", "\\cup");
m.put("∫", "\\int");
m.put("∴", "\\therefore");
m.put("∵", "\\because");
m.put("∽", "\\backsim");
m.put("≈", "\\approx");
m.put("≌", "\\cong");
m.put("≠", "\\neq");
m.put("≡", "\\equiv");
m.put("≤", "\\leqslant");
m.put("≥", "\\geqslant");
m.put("⊂", "\\subset");
m.put("⊃", "\\supset");
m.put("⊆", "\\subseteq");
m.put("⊇", "\\supseteq");
m.put("⊥", "\\perp");
m.put("⊙", "\\odot");
m.put("△", "\\triangle");
m.put("∼", "\\sim");
m.put("≅", "\\cong");
m.put("⇐", "\\Longleftarrow");
m.put("⟹", "\\Longrightarrow");
m.put("⟸", "\\Longleftarrow");
m.put("⟺", "\\Longleftrightarrow");
m.put("∀", "\\forall");
m.put("∃", "\\exists");
m.put("∂", "\\partial");
m.put("∇", "\\nabla");
m.put("ℝ", "\\mathbb{R}");
m.put("ℕ", "\\mathbb{N}");
m.put("ℤ", "\\mathbb{Z}");
m.put("ℚ", "\\mathbb{Q}");
MAP = Map.copyOf(m);
}
private static final Pattern MATH_SPAN =
Pattern.compile("\\\\\\(.*?\\\\\\)|\\\\\\[.*?\\\\\\]", Pattern.DOTALL);
private static final Pattern TOKEN = Pattern.compile("<[^>]*>|[^<]+");
private static final Set<String> SKIP_TAGS = Set.of("script", "style", "textarea");
private UnicodeSymbolLatex() {}
public static boolean containsSymbol(String text) {
for (String key : MAP.keySet()) {
if (text.contains(key)) {
return true;
}
}
return false;
}
/** 转换整段 HTML:只处理文本节点,跳过属性/脚本/已数学化的片段 */
public static String convert(String html) {
Matcher tokens = TOKEN.matcher(html);
StringBuilder out = new StringBuilder(html.length() + 256);
String skipUntil = null;
while (tokens.find()) {
String t = tokens.group();
if (t.startsWith("<")) {
out.append(t);
String lower = t.toLowerCase();
if (skipUntil == null) {
for (String tag : SKIP_TAGS) {
if (lower.startsWith("<" + tag) && !lower.endsWith("/>")) {
skipUntil = "</" + tag;
}
}
} else if (lower.startsWith(skipUntil)) {
skipUntil = null;
}
continue;
}
out.append(skipUntil != null ? t : convertText(t));
}
return out.toString();
}
/** 单个文本片段:保护已有 \(...\) 区间,其余部分替换符号 */
private static String convertText(String text) {
if (!containsSymbol(text)) {
return text;
}
StringBuilder sb = new StringBuilder(text.length() + 64);
int last = 0;
Matcher m = MATH_SPAN.matcher(text);
while (m.find()) {
sb.append(replaceSymbols(text.substring(last, m.start())));
// 数学区内部同样规范化:裸 ∈ / ⊆ 等 → LaTeX 命令(EqToLatexUtil 未覆盖的字符)
sb.append(replaceSymbolsBare(m.group()));
last = m.end();
}
sb.append(replaceSymbols(text.substring(last)));
return sb.toString();
}
private static String replaceSymbols(String s) {
String r = s;
for (Map.Entry<String, String> e : MAP.entrySet()) {
if (r.contains(e.getKey())) {
r = r.replace(e.getKey(), "\\(" + e.getValue() + "\\)");
}
}
return r;
}
/** 数学区内部:只换命令,不加 \( \) 包裹 */
private static String replaceSymbolsBare(String s) {
String r = s;
for (Map.Entry<String, String> e : MAP.entrySet()) {
if (r.contains(e.getKey())) {
r = r.replace(e.getKey(), e.getValue());
}
}
return r;
}
}

View File

@@ -1243,6 +1243,10 @@ public class WordParseUtil {
}
// ⇌(U+E225 U+E226 成对出现)直接转 LaTeX
result = result.replace("\ue225\ue226", "\\(\\rightleftharpoons\\)");
// 正文里的标准 Unicode 数学符号(⊆ ∈ ∪ ≤ × …)→ LaTeX,与公式走同一条渲染路径
if (Boolean.TRUE.equals(config.getConvertUnicodeSymbols())) {
result = UnicodeSymbolLatex.convert(result);
}
writeHtml(result);
return result;
}

View File

@@ -15,6 +15,7 @@ wordparse:
transform-scripts-path: /opt/transform
python-command: python3
symbol-base-url: https://fs.code-lab.cn/temp/wordparse-symbols/
convert-unicode-symbols: true
oss:
endpoint: https://fs.code-lab.cn
access-key: ${WORDPARSE_OSS_AK:}