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 返回的 &lt;script&gt;、&lt;iframe&gt; 等在前端渲染为 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特殊字符(&amp; &lt; &gt; " ')
     *
     * @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();
    }
}