TextCleaningUtil.java
package com.yumu.noveltranslator.util;
import org.apache.commons.text.StringEscapeUtils;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
import java.util.regex.Pattern;
/**
* 文本清洗工具类
* 提供文本清洗、格式化、规范化等功能
* 注意:不处理HTML标签(由Jsoup或其他工具处理)
* 重要:清洗后的文本保持与原文相同的展示形式,不删除标点符号、换行、空格等格式
* 重要:不会删除网页标签(如 <div>, <p> 等)
*/
public class TextCleaningUtil {
// 零宽字符和特殊不可见字符(不影响显示但可能造成问题)
// 零宽空格 \u200B、零宽非连接符 \u200C、零宽连接符 \u200D
// 双向控制字符 \u200E-\u200F, \u202A-\u202E
// BOM和非法字符 \uFEFF, \uFFFE, \uFFFF
private static final Pattern SPECIAL_INVISIBLE_CHARS = Pattern.compile(
"[\\u200B\\u200C\\u200D\\u200E\\u200F\\uFEFF\\uFFFE\\uFFFF\\u202A-\\u202E]+"
);
// 非打印控制字符(ASCII 0-31)
// 注意:换行符(\n)、回车符(\r)、制表符(\t)会被保留
// 移除的是真正有害的控制字符:\x00-\x08, \x0B, \x0C, \x0E-\x1F
private static final Pattern CONTROL_CHARS = Pattern.compile(
"[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]+"
);
// 不合法的Unicode字符(代理对错误的字符)
private static final Pattern INVALID_UNICODE = Pattern.compile(
"[\\uD800-\\uDFFF]"
);
// 连续的零宽空格(多个连续的零宽字符)
private static final Pattern MULTIPLE_ZERO_WIDTH = Pattern.compile(
"(\\u200B){2,}|(\\u200C){2,}|(\\u200D){2,}"
);
// 用于检测不可见字符的模式(组合)
private static final Pattern INVISIBLE_CHARS = Pattern.compile(
"[\\p{Cntrl}\\p{Cf}]"
);
/**
* 清洗文本:仅移除真正有害的不可见字符,保持原文的展示形式
* 保留所有标点符号、空格、换行、制表符、网页标签等格式字符
*
* @param text 待清洗的文本
* @return 清洗后的文本
*/
public static String cleanText(String text) {
if (text == null || text.isEmpty()) {
return text;
}
// 1. 移除零宽字符和特殊不可见字符
text = removeSpecialInvisibleChars(text);
// 2. 移除非法控制字符
text = removeControlChars(text);
// 3. 移除不合法的Unicode字符
text = removeInvalidUnicode(text);
// 4. 移除多个连续的零宽字符
text = removeMultipleZeroWidthChars(text);
return text;
}
/**
* 移除特殊不可见字符(零宽空格、零宽连接符、BOM、双向控制字符等)
* 这些字符不会在页面上显示,但可能影响文本处理
*
* @param text 文本
* @return 处理后的文本
*/
public static String removeSpecialInvisibleChars(String text) {
if (text == null) {
return null;
}
return SPECIAL_INVISIBLE_CHARS.matcher(text).replaceAll("");
}
/**
* 移除非法控制字符(保留换行符、回车符、制表符)
* 移除的是真正有害的非打印字符(ASCII 0-8, 11, 12, 14-31)
*
* @param text 文本
* @return 处理后的文本
*/
public static String removeControlChars(String text) {
if (text == null) {
return null;
}
return CONTROL_CHARS.matcher(text).replaceAll("");
}
/**
* 移除不合法的Unicode字符(代理对错误的字符)
*
* @param text 文本
* @return 处理后的文本
*/
public static String removeInvalidUnicode(String text) {
if (text == null) {
return null;
}
return INVALID_UNICODE.matcher(text).replaceAll("");
}
/**
* 移除多个连续的零宽字符
*
* @param text 文本
* @return 处理后的文本
*/
public static String removeMultipleZeroWidthChars(String text) {
if (text == null) {
return null;
}
return MULTIPLE_ZERO_WIDTH.matcher(text).replaceAll("");
}
/**
* 白名单 HTML 净化:仅允许安全的排版标签,移除所有恶意标签和属性。
* 使用 Jsoup Safelist,防止 LLM 返回的 <script>、<iframe> 等在前端渲染为 XSS。
*
* 允许标签: p, br, b, strong, i, em, u, s, blockquote, ul, ol, li, h1-h6, hr, pre, code, span, div
* 允许属性: 安全标签的 class 和 id(不含 onclick、style 等事件/样式属性)
*
* @param text 待净化的文本(可能包含任意 HTML)
* @return 净化后的文本(仅保留安全标签,移除所有危险内容)
*/
public static String sanitizeHtml(String text) {
if (text == null || text.isEmpty()) {
return text;
}
Safelist safelist = Safelist.relaxed()
.addTags("hr")
.removeProtocols("a", "href", "javascript:", "vbscript:");
return Jsoup.clean(text, safelist);
}
/**
* 转义HTML特殊字符(& < > " ')
*
* @param text 文本
* @return 转义后的文本
*/
public static String escapeHtml(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.escapeHtml4(text);
}
/**
* 反转义HTML实体
*
* @param text 转义后的文本
* @return 反转义后的文本
*/
public static String unescapeHtml(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.unescapeHtml4(text);
}
/**
* 转义XML特殊字符(& < > " ')
*
* @param text 文本
* @return 转义后的文本
*/
public static String escapeXml(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.escapeXml10(text);
}
/**
* 反转义XML实体
*
* @param text 转义后的文本
* @return 反转义后的文本
*/
public static String unescapeXml(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.unescapeXml(text);
}
/**
* 转义CSV特殊字符
*
* @param text 文本
* @return 转义后的文本
*/
public static String escapeCsv(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.escapeCsv(text);
}
/**
* 反转义CSV
*
* @param text 转义后的文本
* @return 反转义后的文本
*/
public static String unescapeCsv(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.unescapeCsv(text);
}
/**
* 转义Java字符串(\ \n \r \t 等)
*
* @param text 文本
* @return 转义后的文本
*/
public static String escapeJava(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.escapeJava(text);
}
/**
* 反转义Java字符串
*
* @param text 转义后的文本
* @return 反转义后的文本
*/
public static String unescapeJava(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.unescapeJava(text);
}
/**
* 转义JSON特殊字符
*
* @param text 文本
* @return 转义后的文本
*/
public static String escapeJson(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.escapeJson(text);
}
/**
* 反转义JSON
*
* @param text 转义后的文本
* @return 反转义后的文本
*/
public static String unescapeJson(String text) {
if (text == null) {
return null;
}
return StringEscapeUtils.unescapeJson(text);
}
/**
* 移除文本两端的空白字符(包括全角空格)
*
* @param text 文本
* @return 处理后的文本
*/
public static String trimFullWidth(String text) {
if (text == null) {
return null;
}
return text.replaceAll("^[\\s\\u3000]+", "").replaceAll("[\\s\\u3000]+$", "");
}
/**
* 将全角字符转换为半角字符
*
* @param text 文本
* @return 转换后的文本
*/
public static String fullWidthToHalfWidth(String text) {
if (text == null) {
return null;
}
StringBuilder result = new StringBuilder(text.length());
for (char c : text.toCharArray()) {
if (c == '\u3000') {
// 全角空格
result.append(' ');
} else if (c >= '\uFF01' && c <= '\uFF5E') {
// 全角标点和字母数字
result.append((char) (c - '\uFEE0'));
} else {
result.append(c);
}
}
return result.toString();
}
/**
* 将半角字符转换为全角字符
*
* @param text 文本
* @return 转换后的文本
*/
public static String halfWidthToFullWidth(String text) {
if (text == null) {
return null;
}
StringBuilder result = new StringBuilder(text.length());
for (char c : text.toCharArray()) {
if (c == ' ') {
// 空格
result.append('\u3000');
} else if (c >= '!' && c <= '~') {
// 半角标点和字母数字
result.append((char) (c + '\uFEE0'));
} else {
result.append(c);
}
}
return result.toString();
}
/**
* 移除BOM(Byte Order Mark)
*
* @param text 包含BOM的文本
* @return 移除BOM后的文本
*/
public static String removeBom(String text) {
if (text == null) {
return null;
}
if (text.startsWith("\uFEFF")) {
return text.substring(1);
}
return text;
}
/**
* 规范化所有空白字符(包括制表符、换行符)为单个空格
*
* @param text 文本
* @return 处理后的文本
*/
public static String normalizeAllWhitespace(String text) {
if (text == null) {
return null;
}
return text.replaceAll("\\s+", " ");
}
/**
* 清理文本中的多余空格(保留换行)
*
* @param text 文本
* @return 处理后的文本
*/
public static String cleanupSpaces(String text) {
if (text == null) {
return null;
}
// 行内多个空格压缩为一个
String result = text.replaceAll(" +", " ");
// 移除行首行尾空格(保留换行符)
result = result.replaceAll("(?m)^[ \\t]+", "");
result = result.replaceAll("(?m)[ \\t]+$", "");
return result;
}
/**
* 移除文本中的制表符(\t)
*
* @param text 文本
* @return 处理后的文本
*/
public static String removeTabs(String text) {
if (text == null) {
return null;
}
return text.replace("\t", "");
}
/**
* 检查文本是否包含不可见字符
*
* @param text 文本
* @return true 如果包含不可见字符
*/
public static boolean hasInvisibleChars(String text) {
if (text == null || text.isEmpty()) {
return false;
}
return INVISIBLE_CHARS.matcher(text).find() || SPECIAL_INVISIBLE_CHARS.matcher(text).find();
}
}