fix(symbol): 私有区(PUA)数学符号处理修复 + 去掉 jby CDN 依赖

问题:docx 里的私有区符号(教辅排版软件把 ⊆/⇌ 等写成 PUA)输出成豆腐块。
实测语料:46 份教辅里 \ue089 共 11 处(3 份文件)全部丢失。

根因:
1. SYMBOL 数组沿用反编译产物的字面量写法 {"\\ue0fc", "\\ue100", "\\ue101"}
   (反斜杠+ue0fc 共 7 字符),而文档里是 1 个 PUA 字符,replace 永远不命中
   → 整个符号占位符机制从未生效;连带 sym.substring(2) 在真字符下取不到编号。
2. 只有 3 个硬编码码位,没有通用 PUA→Unicode 映射。

修复:
- SYMBOL 改为真 PUA 字符;编号统一走 symbolCode()(%04X,与 static/symbols 文件名一致)
- 新增 PUA_TO_UNICODE(证据驱动):\ue089 → ⊆
- 新增 warnUnknownPua():扫描 E000–F8FF 未映射码位并 WARN,便于持续扩充
- 新增配置 wordparse.symbol-base-url(默认我们自己的 RustFS
  https://fs.code-lab.cn/temp/wordparse-symbols/),不再引用 resource.xinjiaoyu.com
- ⇌(\ue225\ue226)替换移出循环体

验证:含真 PUA 字节的 docx 过服务后 —— E0FC/E100/E101 输出我们托管的 svg 图、
\ue089→⊆、\ue225\ue226→\(\rightleftharpoons\)、标准 ⊆ 原样保留、残留 PUA=0;
3 份受影响教辅已重新生成并覆盖线上(exercise-html3)
This commit is contained in:
2026-09-29 15:19:58 +08:00
parent 3271fee5cc
commit 09c58defa3
3 changed files with 47 additions and 7 deletions

View File

@@ -13,12 +13,16 @@ public class WordParseConfig {
private Map<String, String[]> cleanStyleMap = Map.of(); private Map<String, String[]> cleanStyleMap = Map.of();
private String transformScriptsPath = "/opt/transform"; private String transformScriptsPath = "/opt/transform";
private String pythonCommand = "python3"; private String pythonCommand = "python3";
/** 私有区符号图的基址(默认我们自己的 RustFS,不依赖 jby CDN) */
private String symbolBaseUrl = "https://fs.code-lab.cn/temp/wordparse-symbols/";
public String getTmpPath() { return tmpPath; } public String getTmpPath() { return tmpPath; }
public Double getImageScalingFactor() { return imageScalingFactor; } public Double getImageScalingFactor() { return imageScalingFactor; }
public Map<String, String[]> getCleanStyleMap() { return cleanStyleMap; } public Map<String, String[]> getCleanStyleMap() { return cleanStyleMap; }
public String getTransformScriptsPath() { return transformScriptsPath; } public String getTransformScriptsPath() { return transformScriptsPath; }
public String getPythonCommand() { return pythonCommand; } public String getPythonCommand() { return pythonCommand; }
public String getSymbolBaseUrl() { return symbolBaseUrl; }
public void setSymbolBaseUrl(String symbolBaseUrl) { this.symbolBaseUrl = symbolBaseUrl; }
public void setTmpPath(String v) { this.tmpPath = v; } public void setTmpPath(String v) { this.tmpPath = v; }
public void setImageScalingFactor(Double v) { this.imageScalingFactor = v; } public void setImageScalingFactor(Double v) { this.imageScalingFactor = v; }

View File

@@ -69,8 +69,39 @@ public class WordParseUtil {
this.ossService = ossService; this.ossService = ossService;
} }
/** PUA 字符 → 4 位十六进制编号(与 static/symbols 下的文件名一致) */
private static String symbolCode(String sym) {
return String.format("%04X", (int) sym.charAt(0));
}
/** 记录未映射的私有区字符,便于后续扩充 PUA_TO_UNICODE */
private static void warnUnknownPua(String content) {
java.util.Set<String> unknown = new java.util.TreeSet<>();
for (int i = 0; i < content.length(); i++) {
char c = content.charAt(i);
if (c >= 0xE000 && c <= 0xF8FF) {
String s = String.valueOf(c);
if (!PUA_TO_UNICODE.containsKey(s) && !java.util.Arrays.asList(SYMBOL).contains(s)) {
unknown.add(String.format("U+%04X", (int) c));
}
}
}
if (!unknown.isEmpty()) {
log.warn("存在未映射的私有区符号(建议补充 PUA_TO_UNICODE): {}", String.join(",", unknown));
}
}
private static final String MATH_PLACEHOLDER = "<w:rPr><w:rFonts w:hint=\"eastAsia\"/><w:lang w:val=\"en-US\" w:eastAsia=\"zh-CN\"/></w:rPr><w:t>%s</w:t>"; private static final String MATH_PLACEHOLDER = "<w:rPr><w:rFonts w:hint=\"eastAsia\"/><w:lang w:val=\"en-US\" w:eastAsia=\"zh-CN\"/></w:rPr><w:t>%s</w:t>";
private static final String[] SYMBOL = {"\\ue0fc", "\\ue100", "\\ue101"}; private static final String[] SYMBOL = {"\ue0fc", "\ue100", "\ue101"};
/**
* 私有区(PUA)符号码位 → 标准 Unicode。
* 教辅排版软件把 ⊆ 等数学符号写成 PUA,docx4j 会原样输出,前端显示成豆腐块。
* 证据驱动映射:未收录的码位由 warnUnknownPua 记日志,确认含义后再补。
*/
private static final Map<String, String> PUA_TO_UNICODE = Map.of(
"\ue089", "⊆" // 10阶段滚动检测(一):B?A / {2,3}?A,与同文档内真 ⊆ 用法完全一致
);
// ---------- Class-level constants (brand-neutral replacements for jby Constants) ---------- // ---------- Class-level constants (brand-neutral replacements for jby Constants) ----------
private static final Set<String> RETAIN_CLASS_VALUE_SET = Set.of( private static final Set<String> RETAIN_CLASS_VALUE_SET = Set.of(
@@ -158,8 +189,13 @@ public class WordParseUtil {
content = content.replace("\u00A0", " "); content = content.replace("\u00A0", " ");
content = content.replace("\uf028", "("); content = content.replace("\uf028", "(");
content = content.replace("\uf029", ")"); content = content.replace("\uf029", ")");
// PUA 符号 → 标准 Unicode(先于 SYMBOL 占位符,避免冲突)
for (Map.Entry<String, String> pua : PUA_TO_UNICODE.entrySet()) {
content = content.replace(pua.getKey(), pua.getValue());
}
warnUnknownPua(content);
for (String sl : SYMBOL) { for (String sl : SYMBOL) {
content = content.replace(sl, "@@@" + sl.substring(2).toUpperCase() + "@@@"); content = content.replace(sl, "@@@" + symbolCode(sl) + "@@@");
} }
content = content.replace("<w:shd w:val=\"pct15\" w:color=\"auto\" w:fill=\"FFFFFF\"/>", content = content.replace("<w:shd w:val=\"pct15\" w:color=\"auto\" w:fill=\"FFFFFF\"/>",
"<w:shd w:val=\"clear\" w:color=\"FFFFFF\" w:fill=\"D9D9D9\"/>"); "<w:shd w:val=\"clear\" w:color=\"FFFFFF\" w:fill=\"D9D9D9\"/>");
@@ -1200,14 +1236,13 @@ public class WordParseUtil {
"$1"); "$1");
for (String sym : SYMBOL) { for (String sym : SYMBOL) {
Element imgElement = new Element("img"); Element imgElement = new Element("img");
imgElement.attr(HtmlAttr.SRC.getValue(), imgElement.attr(HtmlAttr.SRC.getValue(), config.getSymbolBaseUrl() + symbolCode(sym) + ".svg");
"https://resource.xinjiaoyu.com/prod/office/unicode-symbol/"
+ sym.substring(2).toUpperCase() + ".svg");
imgElement.attr(HtmlAttr.WIDTH.getValue(), "14"); imgElement.attr(HtmlAttr.WIDTH.getValue(), "14");
imgElement.attr(HtmlAttr.HEIGHT.getValue(), "14"); imgElement.attr(HtmlAttr.HEIGHT.getValue(), "14");
result = result.replace("@@@" + sym.substring(2).toUpperCase() + "@@@", imgElement.outerHtml()); result = result.replace("@@@" + symbolCode(sym) + "@@@", imgElement.outerHtml());
result = result.replace("\ue225\ue226", "\\(\\rightleftharpoons\\)");
} }
// ⇌(U+E225 U+E226 成对出现)直接转 LaTeX
result = result.replace("\ue225\ue226", "\\(\\rightleftharpoons\\)");
writeHtml(result); writeHtml(result);
return result; return result;
} }

View File

@@ -14,6 +14,7 @@ wordparse:
image-scaling-factor: 2.0 image-scaling-factor: 2.0
transform-scripts-path: /opt/transform transform-scripts-path: /opt/transform
python-command: python3 python-command: python3
symbol-base-url: https://fs.code-lab.cn/temp/wordparse-symbols/
oss: oss:
endpoint: https://fs.code-lab.cn endpoint: https://fs.code-lab.cn
access-key: ${WORDPARSE_OSS_AK:} access-key: ${WORDPARSE_OSS_AK:}