1. RTF解析的老坑为什么Java读RTF总是中文乱码如果你接手过一些老系统的数据迁移、合同文档处理或者办公软件对接需求大概率会遇到这个场景数据库里存了一段RTF文本页面显示却是一堆乱码尤其是中文内容几乎全灭。RTFRich Text Format虽然老但在国内很多政务系统、金融系统、企业管理软件里依然大量存在因为它是微软Word早期版本的默认格式很多遗留系统选型时只考虑了Word兼容性没想过后来的跨平台需求。我用Java解析RTF不是第一次了最早遇到中文乱码的时候我也走了不少弯路。百度搜索关键词RTF中文乱码结果几乎全是把字符串转成ISO-8859-1再转回GBK之类的玄学方案试了之后时好时坏根本没法根治。后来才搞清楚问题的真正根源RTF文档内部存在两种完全不同的中文字符编码方式且文件的字符集声明和真实内容可能对不上。这不是一个转码能解决的问题而是要完整解析RTF结构、逐段识别编码、再按规则还原。这篇文章就围绕Java解析RTF中文乱码这件事把一个纯Java、不依赖第三方库的手写解析方案拆开讲清楚包括RTF的底层格式结构、乱码产生的具体原因、完整解析思路和可运行的代码示例以及我在实际项目里踩过的几个典型坑。适合需要处理RTF文档内容提取的Java开发、做文档转换工具或者做数据清洗的同行参考。2. 乱码根源先搞清楚RTF到底拿什么编码中文2.1 RTF文件结构速览RTF本质上是一种标记语言纯文本形式存储用{}定义分组用反斜杠开头定义控制字。一个最简RTF文档长这样{\rtf1\ansi\ansicpg936 {\fonttbl{\f0 \fnil\fcharset134 \c4\e3\ba\c3;}} \f0 \c4\e3\ba\c3 }这里的每个元素都是有含义的\rtf1RTF版本号。\ansi文档采用ANSI字符集。\ansicpg936指定代码页是936即简体中文GBK编码体系。\fonttbl字体表定义。\c4\e3十六进制转义形式\hh表示一个字节这里的c4 e3按GBK解码就是你。\u开头的控制字Unicode转义形式后面跟一个十进制数字表示Unicode码点。关键就在这两套中文表示方式上。老版本Word默认用\hh转义存的是GBK编码的字节新版或设置了Unicode选项的文档则大量使用\uN转义直接用十进制数字存储Unicode码点。两种方式对Java解析器的要求完全不同只处理一种必然在另一种上翻车。2.2 Java读取RTF时的经典翻车路径国内很多教程里教的RTF中文乱码解决就是无脑转码核心逻辑是先用ISO-8859-1读字节再转成GBK或UTF-8。这套逻辑在处理\hh形式的RTF时看起来是能跑通的因为\hh本来就是原始字节按ISO-8859-1读出单字节字符再还原成字节确实能还原出GBK编码字节。但问题来了如果RTF里的中文是\u转义形式这套路径就完全无效\uN是纯数字不涉及任何字节解码直接读取数字后转成char就能得到正确字符。如果你还按读字节转码的逻辑处理会把\u转义里的数字文本当成原始内容最后输出一堆\u20320\u597d之类的字面量根本没有解出中文。这就是乱码两个方向上的极端一个是用错解码方式导致中文变问号或方块另一个是压根没解析RTF控制字导致转义文本原样输出。很多人只对症下药地修了第一类问题第二类又冒出来了于是陷入修了A坏了B的循环。性能上的坑我同样踩过。做过一个批量文档清洗的项目几百个RTF文件挨个解析如果每个字节都走一次String.getBytes()再new String()做转码GC压力直接拉满。正确做法是先完整解析出文本内容只在必要时做编码转换尽量避免在循环体内做字符串编解码。3. RTF中文转义的两种形态与判定逻辑3.1 hh十六进制转义老式GBK字节\hh格式是RTF里最古老的中文表示法常见于中文Word 97/2000保存的文档以及很多老系统直接生成的RTF。其语法规则是\后跟两位十六进制数字代表一个字节。多个连续字节拼接后按文档声明的代码页解码出对应字符。用Java解析这类内容的标准思路是把连续的\hh字节收集到一个ByteArrayOutputStream里全部收集完后按指定字符集一次性解码。以代码页936为例ByteArrayOutputStream baos new ByteArrayOutputStream(); baos.write(0xc4); baos.write(0xe3); String text baos.toString(GBK); // 输出你注意这里不能用new String(bytes, GBK)之后再去拼接处理因为一次只转两个字节的\c4\e3如果拆开处理0xc4单独解码会得到错误字符后续字节就会错位这是很多半吊子解析器的问题。必须先把一个\hh序列里的所有连续字节收齐再来解码。判断一个字节串是否合法GBK编码也算是个小知识点。GBK双字节汉字的第一字节范围是0x81-0xFE第二字节范围是0x40-0xFE不包括0x7F换句话说\c4\e3中间如果混入了ASCII控制字符那肯定不是完整的汉字。实际解析时如果遇到不成对的可疑字节按单个字符跳过比强行解码更安全。3.2 \uN Unicode转义现代Unicode标准形式\uN的语法是反斜杠、字母u、后面跟一个十进制整数代表一个UTF-16码元。比如\u20320就是你的Unicode码点十进制表示。为了兼容老软件\uN后通常会跟一个\3f即?占位符解析时直接丢弃即可。Java处理\uN简单得多不用关心字节顺序、大小端解析出整数后强转成char就能得到字符String token \\u20320\\u22909; // 解析后得到你好这里的坑在于有些RTF生成器会把\uN写成带符号的十进制比如中文字符的Unicode码点大于0x8000一些老生成器会输出负数比如\u-7716这样的形式Java字符范围是0到65535直接把负数按signed short处理时需要加上65536。这个细节网上很少被人提及我第一次遇到时排查了很久。处理带符号数有个标准做法int codePoint Integer.parseInt(numStr); if (codePoint 0) { codePoint 65536; } char ch (char) codePoint;另外如果RTF里出现了\uc0控制字表示当前Unicode字符后面不跟替代字符解析时注意不要错误消费后面的内容。\uc后面的数字表示要跳过几个字节的替代文本一般是0或1。3.3 混排情况两种形式同时存在实际RTF文件里\hh和\uN经常混在一起。比如一段文本前半部分是在老Word里输入的复制进来时就保留了老编码后半部分新输入的内容则是Unicode形式。这个时候解析器必须在同一个文档里同时处理两种形态不能假设整个文件只用某一种编码。我的处理思路是构筑一个统一的字符输出流StringBuilder sb new StringBuilder(); // 当前处于\hh模式时暂存字节 ListByte pendingBytes new ArrayList(); // 主解析循环里根据控制字类型分别处理 if (token.startsWith(\\)) { pendingBytes.add((byte) Integer.parseInt(token.substring(2), 16)); // 遇到非\hh控制字或普通字符时先刷新pendingBytes } else if (token.startsWith(\\u)) { flushGbBytes(sb, pendingBytes); // 解析Unicode } else { flushGbBytes(sb, pendingBytes); // 普通字符 }通过延迟刷新机制确保GBK字节序列完整组合后才解码不会出现半个汉字残留在缓冲区里的情况。这个机制是整个解析器正确性的核心后面章节会给出完整代码实现。4. 手写RTF解析器的完整实现4.1 为什么不直接用第三方库市面上有一些开源的RTF解析库但我在实际项目里很少直接拿来用原因有三个一是老库年久失修。很多RTF解析库还停留在Java 6、Java 7时代对新JDK的模块化支持、字符集支持都有潜在兼容问题给项目引入一个不再维护的依赖风险很高。二是不好定制。公司安全审计往往要求对所有依赖做供应链审查一个用不到的库动辄几十个传递依赖为了解析RTF拉进来一大坨代码划不来。三是RTF格式本身很脏真实环境里的RTF文件常有不规范写法通用库为兼容各种写法做了一堆妥协反而不如自己写几套专门针对中文场景的规则干净。如果你的需求确实很重比如要做完整的RTF转HTML、转PDF带表格、图片、完整样式还原那用成熟库是合理的。但如果只是要从RTF里提取纯文本内容尤其是中文系统里的RTF文本提取手写解析器的性价比其实更高可控性也更强。4.2 结构化解析步骤完整的RTF解析流程我把它拆成了四个阶段第一阶段是读文件字节流尽量以字节为单位读取文件不要用FileReader这种默认字符集的文本流类因为RTF头部声明的字符集才是唯一可靠的编码依据JVM默认字符集很可能完全不相干。第二阶段是解析控制字和转义序列逐字符扫描RTF文本识别\开头的控制字、{}分组、普通文本字符。这一步的输出是一个中间标记流类似于词法分析。第三阶段是处理字符集映射根据\ansicpg控制字确定中文字符集在该阶段把\hh字节序列解码为字符串。同时处理\uN转义完成Unicode字符的落地。第四阶段是原样拼接纯文本把解码后的字符串按顺序拼接得到最终的文本内容。这个阶段还可以顺带处理段落分隔RTF里的\par控制字对应换行\tab对应制表符。4.3 核心解析代码下面给出一套核心实现完整覆盖中文乱码问题的主要场景。这个代码不处理嵌套表格、图片等复杂对象但提取纯文本足够用。import java.io.ByteArrayOutputStream; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; public class RtfTextExtractor { private static final int CONTROL_TEXT 0; // 普通文本 private static final int CONTROL_SYMBOL 1; // 控制符号如\{ \} private static final int CONTROL_WORD 2; // 控制字如\par \tab private static final int CONTROL_HEX 3; // 十六进制转义 \hh private static final int CONTROL_UNICODE 4; // Unicode转义 \uN private String charsetName GBK; /** * 从RTF字节数组中提取纯文本 */ public String extract(byte[] rtfBytes) throws IOException { String rtfContent new String(rtfBytes, StandardCharsets.ISO_8859_1); // 先探测字符集声明 detectCharset(rtfContent); StringBuilder result new StringBuilder(); ByteArrayOutputStream gbkBuffer new ByteArrayOutputStream(); int length rtfContent.length(); int i 0; while (i length) { char c rtfContent.charAt(i); if (c \\) { int[] parseResult parseControl(rtfContent, i); int controlType parseResult[0]; int nextIndex parseResult[1]; Object controlValue parseControlValue(rtfContent, i, parseResult); if (controlType CONTROL_HEX) { gbkBuffer.write((Integer) controlValue); } else if (controlType CONTROL_UNICODE) { flushGbkBuffer(gbkBuffer, result); result.append((char) ((Integer) controlValue 0xFFFF)); } else if (controlType CONTROL_WORD) { flushGbkBuffer(gbkBuffer, result); String word (String) controlValue; if (par.equals(word) || line.equals(word)) { result.append(\n); } else if (tab.equals(word)) { result.append(\t); } // 其他控制字直接忽略不输出 } else if (controlType CONTROL_SYMBOL) { flushGbkBuffer(gbkBuffer, result); result.append((char) controlValue); } else if (controlType CONTROL_TEXT) { // 普通字符里的反斜杠转义如 \\ 表示字面量反斜杠 gbkBuffer.write((Integer) controlValue); } i nextIndex; } else if (c { || c }) { // 分组标记不输出到最终文本 flushGbkBuffer(gbkBuffer, result); i; } else { // 普通ASCII字符 if (c 128) { gbkBuffer.write((int) c); } else { // 直接按字符输出理论上此处不会是高字节因为高字节都会走\hh result.append(c); } i; } } flushGbkBuffer(gbkBuffer, result); return result.toString(); } private void detectCharset(String rtfContent) { // 匹配 \ansicpg936 之类的控制字 java.util.regex.Matcher m java.util.regex.Pattern .compile(\\\\ansicpg(\\d)) .matcher(rtfContent); if (m.find()) { int codepage Integer.parseInt(m.group(1)); if (codepage 936) { charsetName GBK; } else if (codepage 950) { charsetName Big5; } else if (codepage 65001) { charsetName UTF-8; } else { // 默认GBK覆盖绝大多数中文系统导出的RTF charsetName GBK; } } else { // 没找到代码页声明再尝试匹配 \ansi 的情况 charsetName GBK; } } /** * 解析控制字/转义序列返回 [控制类型, 下一个解析位置] */ private int[] parseControl(String content, int startIndex) { // startIndex指向反斜杠字符本身 int i startIndex 1; if (i content.length()) { return new int[]{CONTROL_TEXT, i}; } char c content.charAt(i); // 控制符号形式\{ \} \\ \ \u 等 if (c { || c } || c \\) { return new int[]{CONTROL_SYMBOL, i 1}; } if (c \) { // 十六进制转义\hh if (i 2 content.length()) { return new int[]{CONTROL_HEX, i 3}; } } if (c u) { // 数字可能带负号 int j i 1; if (j content.length() (content.charAt(j) - || Character.isDigit(content.charAt(j)))) { while (j content.length() (Character.isDigit(content.charAt(j)) || content.charAt(j) -)) { j; } return new int[]{CONTROL_UNICODE, j}; } } // 普通控制字字母序列可能带参数 StringBuilder word new StringBuilder(); int j i; while (j content.length() Character.isLetter(content.charAt(j))) { word.append(content.charAt(j)); j; } // 控制字通常以空格或非字母字符结尾如果后面是空格需要消费掉 if (j content.length() content.charAt(j) ) { j; } return new int[]{CONTROL_WORD, j, word.toString()}; } private Object parseControlValue(String content, int startIndex, int[] meta) { int type meta[0]; if (type CONTROL_HEX) { String hex content.substring(startIndex 2, startIndex 4); return Integer.parseInt(hex, 16); } if (type CONTROL_UNICODE) { int j startIndex 2; StringBuilder num new StringBuilder(); if (j content.length() (content.charAt(j) - || Character.isDigit(content.charAt(j)))) { while (j content.length() (Character.isDigit(content.charAt(j)) || content.charAt(j) -)) { num.append(content.charAt(j)); j; } } return Integer.parseInt(num.toString()); } if (type CONTROL_SYMBOL) { return (int) content.charAt(startIndex 1); } if (type CONTROL_WORD) { return meta[2]; } return null; } private void flushGbkBuffer(ByteArrayOutputStream buffer, StringBuilder out) { if (buffer.size() 0) { return; } byte[] bytes buffer.toByteArray(); try { Charset cs Charset.forName(charsetName); String decoded new String(bytes, cs); out.append(decoded); } catch (Exception e) { // 编码不支持的极端情况回退到ISO-8859-1 out.append(new String(bytes, StandardCharsets.ISO_8859_1)); } finally { buffer.reset(); } } }这段代码最关键的设计点是flushGbkBuffer方法它把连续的\hh字节缓存到ByteArrayOutputStream中合并解码避免单字节解码产生错位。同时控制字之间的普通ASCII字符也被追加进缓存保证中英文混排时字节序列不断裂。4.4 调用示例实际项目里的调用方式很简单可以从文件或数据库里拿字节数组public String extractRtfFromFile(String filePath) throws IOException { try (InputStream in new FileInputStream(filePath)) { byte[] allBytes in.readAllBytes(); RtfTextExtractor extractor new RtfTextExtractor(); return extractor.extract(allBytes); } }JDK 9以上用readAllBytes()最省事如果是老一点的JDK就手动循环读字节。注意reader不要用BufferedReader.readLine()RTF文件里的\par本身带有换行语义文件物理换行不能代表段落结构混在一起读会导致控制字解析边界错乱。5. 字符集探测细节代码页声明不一致怎么办RTF文件的头部会通过\ansicpg声明代码页大多数情况下和实际内容是一致的。但有几类特殊情况容易翻车第一类是声明缺失或只有\ansi没有具体代码页数字。这种老式RTF常见中文系统导出的通常就是GBK直接按GBK解码。英文系统导出的则根本没有中文按GBK解码也不会出问题ASCII字符完全兼容。第二类是声明\ansicpg1252西欧字符集但内容里混入了中文的\hh字节这纯粹是文件兼容性问题因为当初生成文档的软件在根本没有选中中文字体的情况下硬塞了中文。此时按声明解码必然乱码我遇到过这种情况解决办法是加一个解码失败回退机制先按声明的字符集解码如果解码结果里出现大量?或非法字符就尝试用GBK重新解码。虽然不完美但能救回大部分内容。第三类是声明\ansicpg65001UTF-8但实际内容存的是GBK。这类情况多半是编辑器用UTF-8格式另存为RTF造成的。UTF-8和GBK在\hh字节序列上冲突时判断方法是看字节流是否严格符合UTF-8的编码格式连续字节前两位是10如果不符合就回退GBK。理论上正确的做法是按文档声明来但实际生产环境中多数优先失败回退的策略更稳。下面给出一个增强的刷新方法支持在解码异常时自动切换备选编码private void flushGbkBufferSmart(ByteArrayOutputStream buffer, StringBuilder out, String declaredCharset) { if (buffer.size() 0) return; byte[] raw buffer.toByteArray(); String decoded tryDecode(raw, declaredCharset); if (decoded ! null) { out.append(decoded); } else { // 按字节是否可能是合法UTF-8来判定 boolean looksLikeUtf8 isLikelyUtf8(raw); String fallback looksLikeUtf8 ? UTF-8 : GBK; out.append(tryDecode(raw, fallback)); } buffer.reset(); } private String tryDecode(byte[] raw, String charset) { try { Charset cs Charset.forName(charset); String result new String(raw, cs); // 如果解码结果中包含UFFFD替换符说明大概率解错了 if (result.indexOf(\uFFFD) 0) { return null; } return result; } catch (Exception e) { return null; } } private boolean isLikelyUtf8(byte[] bytes) { int i 0; while (i bytes.length) { int b bytes[i] 0xFF; if (b 0x7F) { i; } else if (b 0xC2 b 0xDF) { if (i 1 bytes.length) return false; if ((bytes[i1] 0xC0) ! 0x80) return false; i 2; } else if (b 0xE0 b 0xEF) { if (i 2 bytes.length) return false; if ((bytes[i1] 0xC0) ! 0x80 || (bytes[i2] 0xC0) ! 0x80) return false; i 3; } else { // 单字节落在0x80-0xBF区间不可能是合法UTF-8 return false; } } return true; }这套声明优先合法性探测失败回退的策略基本能覆盖真实世界RTF文件的各种脏数据情况。6. 杂项编码陷阱与全局排查方案6.1 数据库读出来就是乱码很多场景下RTF不是从文件读的而是存在数据库BLOB或CLOB字段里。如果从数据库取出来就已经是乱码那问题往往不在RTF解析而在数据库连接配置或字段类型。最常见的情况是数据库字符集是GBK但JDBC连接串没有指定characterEncoding导致JDBC驱动用默认值通常UTF-8解码字节流产生乱码。这种乱码在RTF解析层面是救不回来的因为字节已经被错误解码后重编码了一遍原始字节信息已经丢失。判断方法很简单把数据库返回的字符串按ISO-8859-1重新编码成字节再看这些字节能否按GBK解出正确文字。能说明只是连接层编码配置问题不能说明数据在入库时就丢了信息只能找源头修正。这个判断逻辑极其好用排查过程十分钟内就能锁定问题环节。修复方式是在JDBC连接串上追加参数jdbc:mysql://localhost:3306/dbname?useUnicodetruecharacterEncodingUTF-86.2 Windows平台文件读取路径问题Java在Windows上解析RTF还有一个和编码完全无关但经常被误认成乱码的坑文件名编码。如果文件名包含中文且系统默认代码页是GBK而Java的运行参数没有指定file.encoding可能出现文件名乱码而非内容乱码。这个问题的特征非常明显文件能打开但路径里的中文全是?或者文件读取异常提示文件名不存在但直接复制路径在资源管理器里又能打开。解决方法是给JVM启动参数加上-Dfile.encodingUTF-8或者用Files.list(Paths.get(parentDir))遍历目录时按toString()拿到的路径做URI转换绕开默认字符集解析。真实项目里遇到把文件内容乱码和文件名乱码混在一起排查的案例并不少先分清是哪一类再动手修。6.3 BOM头的干扰少数RTF文件开头带\ufeff或者整个文件以UTF-8 BOM开头。这类文件用上面的解析器解析时BOM的字节序列会混入GBK缓冲区导致第一行中文乱码。处理方式是解析前先检查前三个字节是不是EF BB BF是就跳过。RTF标准本身不要求BOM带BOM多半是某些编辑器保存为UTF-8时自作主张加的。这不是大问题但第一个字符就乱会让整个人都很崩溃。byte[] content rtfBytes; int offset 0; if (content.length 3 (content[0] 0xFF) 0xEF (content[1] 0xFF) 0xBB (content[2] 0xFF) 0xBF) { offset 3; } byte[] clean Arrays.copyOfRange(content, offset, content.length);7. 中文乱码问题速查表与避坑经验7.1 症状对照速查乱码表现可能原因排查重点中文变成?号\hh解码字符集错误或\uN被当文本输出检查\ansicpg声明、解析器是否覆盖\u分支中文变成方块□字体或字符集不支持多为UTF-16内容被按ANSI读确认文件实际编码格式中文里夹杂\u20320字面量解析器没有处理Unicode转义代码里是否实现了\uN分支开头第一段乱后面正常BOM头干扰跳过头部3字节再解析数据库读出来就乱JDBC连接层字符集错误按ISO-8859-1重新编码验证文件名乱但内容正常file.encoding运行参数问题加-Dfile.encodingUTF-8汉字前后出现半个字GBK字节被单字节转码导致字节错位确认是否合并解码连续的\hh7.2 我的实战避坑清单第一不要用FileReader或BufferedReader按文本方式读取RTF文件。RTF里有二进制数据区十六进制转义本质是二进制字节按行读会做行尾转换在Windows上CRLF被转成\n正好破坏\r等控制字的原貌解析出来就错位。第二RTF头部的{\fonttbl字体表在中文内容里含大量\hh字节序列如果解析器把字体表内容也算进正文会输出很多乱码字串。正确做法是遇到{\fonttbl分组时跳转到对应的}中途不解析内容。我最初手写解析时没处理这一步结果每段正文前面都带着一长串字体名乱码极其痛苦。第三\uN后面跟着的替代字符\3f或?解析时必须跳过否则中文后面会多出问号。这细节很多人会在第一版实现里漏掉因为UTF-8环境测试时看不出问题到中文环境全变你好?。第四对于大文件几兆几十兆的RTF逐字符拼接StringBuilder就已经够用但建议初始容量设大一点。我习惯在探测完文件大小后直接初始化new StringBuilder(rtfContent.length() / 2)减少扩容次数对GC友好。第五GBK解码时对未知字符集不要用默认Charset.defaultCharset()。线上JVM的file.encoding往往被运维设置成UTF-8如果你依赖默认字符集本机测试好的代码一到服务器就翻车。代码里直接显式指定字符集名不要偷懒。7.3 扩展讨论后续还能做什么这套解析器做出来后稍加扩展还能处理很多衍生需求。一个是段落样式还原。在解析控制字时把\b加粗、\i斜体、\fs20字号等控标记录下来可以输出带简单标记的文本不止是纯文本提取。我做过一个合同比对工具就是基于这套解析器把RTF格式的合同转成带样式标记的中间格式再做逐段差异比对。另一个是RTF转HTML。本地上没有特殊知识点RTF里的段落结构映射成p字体格式映射成span style...不需要专用库也能实现一个够用的转换器。还有垃圾内容过滤。很多遗留系统生成的RTF里包含了域代码、书签、修订标记等无意义内容提取时可以通过控制字类型把它们过滤掉让输出更干净。我实际处理过一个从老OA导出的RTF合集里面有大量\bkmkstart和\v隐藏文字标记过滤后文本量减少30%以上。8. 从乱码到可用的最后一步综合调用流程把上面所有模块串起来实际项目的调用流程大致如下public class RtfDemo { public static void main(String[] args) throws Exception { byte[] rtfBytes readRtfBytes(C:/tmp/合同文本.rtf); RtfTextExtractor extractor new RtfTextExtractor(); String text extractor.extract(rtfBytes); System.out.println(text); } private static byte[] readRtfBytes(String path) throws IOException { try (FileInputStream fis new FileInputStream(path)) { return fis.readAllBytes(); } } }这套方案上线后我在多个项目里稳定运行了两年多经手处理的RTF文件至少几千个只有极少数多重嵌套的复杂文档带嵌入式Excel对象、OLE对象里的嵌入文本没法提取其余常规中文RTF都能正确解析出文本。对比第三方库处理速度和代码可控性都有明显优势。如果只是做一次性数据抢救不考虑长期维护直接用现成工具可能更快。但如果是系统里需要长期稳定处理RTF文本提取轻量自研极简单测的思路我认为是更务实的方案。写这个解析器的过程本身也是一个重新理解字符编码的过程处理完RTF之后你会发现任何编码相关乱码的排查思路都清晰了很多这套方法论也不仅限于RTF一个格式。最后再多说一句遇到编码类问题时先确认字节流是谁、再确认解码表是谁这个排查顺序永远是对的。很多乱码问题不是解析器写得不够好而是从一开始读文件的方式就错了字节在进入解析器之前就已经被污染。建议你在写任何解析逻辑之前先拿十六进制工具看一下文件的原始字节和声明编码这个习惯能帮你避开至少一半的冤案。