RTF规范深度解析:从控制字到Unicode,避开解析器翻车的五个坑 简介RTF 规范 v1.9 官方技术文档面向需要实现或解析富文本格式的开发者、编辑器与办公软件工程师。RTF 由 Microsoft 于 1987 年推出用于描述文本格式与布局可承载文字、图片、表格、列表、超链接等元素实现跨平台、跨应用的文本交换。本规范系统讲解 RTF 的历史演进、格式特点、文件结构头部、正文、尾部、控制符与 Unicode 编码语法以及字体、颜色、字号、样式等格式控制和图片、表格、超链接的嵌入排版细节并说明 v1.9 相对旧版新增的 Unicode 5.0 支持与图片、超链接格式等内容。资源包为 1 个 PDF 文件约 1.76MB共 278 页内容完整、结构清晰便于按章节查阅。已有 737 人学习适合作为开发实现与格式兼容性排查的案头参考。1. 拆开一份 278 页的 RTF 规范为什么你的解析器总在奇怪的地方翻车如果你写过文档转换工具大概率遇到过这种场景一份 Word 导出的 RTF 文件用现成库解析出来文字全对但表格错位、图片丢失、中文变成乱码。你翻遍代码找不到问题最后发现是某个控制字的作用域没搞对。这类问题的根子往往不在你的代码而在你没把 RTF 规范读透。RTFRich Text Format是 1987 年推出的一种跨平台文档交换格式用纯 ASCII 描述带格式的文本。它的核心设计思路是控制字以反斜杠开头用花括号划分作用域所有格式信息都嵌在文本流里。这份 v1.9.1 规范共 278 页2008 年发布覆盖了从基础实体定义到绘图对象、数学公式、表单域的全部细节。它适合三类人写文档解析器的开发者、做格式转换的工程师、以及需要精确控制 RTF 输出的工具作者。如果你只是偶尔导出一份 RTF那用现成库就够了但如果你要处理复杂排版、自定义样式或排查解析异常这份规范就是绕不开的底稿。2. RTF 的语法骨架控制字、分组与目标标识怎么读2.1 控制字与分隔符一个空格引发的血案RTF 的控制字Control Word是解析器最先要处理的东西。格式是反斜杠加字母序列后面可以跟一个可选的有符号数字参数最后用一个空格或非字母数字字符终止。这个终止符本身不属于控制字但它是分隔符解析时必须跳过。# RTF 控制字解析的最小实现 import re def tokenize_rtf(text): 将 RTF 文本切分为控制字、控制符号、分组符和普通文本 返回 token 列表每个 token 是 (类型, 值) 元组 tokens [] i 0 while i len(text): ch text[i] if ch \\: # 控制符号反斜杠后跟单个非字母字符 if i 1 len(text) and not text[i1].isalpha(): tokens.append((control_symbol, text[i1])) i 2 else: # 控制字反斜杠 字母序列 可选数字参数 j i 1 while j len(text) and text[j].isalpha(): j 1 word text[i1:j] # 解析可选数字参数可带负号 num if j len(text) and (text[j] - or text[j].isdigit()): k j if text[k] -: k 1 while k len(text) and text[k].isdigit(): k 1 num text[j:k] j k # 跳过分隔符空格 if j len(text) and text[j] : j 1 tokens.append((control_word, (word, int(num) if num else None))) i j elif ch {: tokens.append((group_start, None)) i 1 elif ch }: tokens.append((group_end, None)) i 1 else: # 普通文本累积到下一个特殊字符 j i while j len(text) and text[j] not in \\{}: j 1 tokens.append((text, text[i:j])) i j return tokens这段代码的关键在于控制字终止符的处理。规范里明确写了控制字后的空格是分隔符不参与渲染。但很多人会忽略一点——如果控制字后面跟的是数字参数空格在数字之后同样要跳过。我见过一个解析器把\b0里的空格当成了文本内容结果每个加粗段落后面都多一个空格。参数说明word是控制字名称num是可选整数参数没有参数时为None。实际解析时\b表示加粗开始\b0表示加粗结束数字参数就是开关。2.2 分组与作用域花括号里的继承规则RTF 用花括号{}划分分组每个分组是一个独立的作用域。分组内的格式属性会继承父分组的设置但分组结束后子分组里的修改不会影响父分组。这个规则听起来简单但实际解析时很容易搞混。def parse_group(tokens, pos, inherited_attrs): 递归解析分组返回 (属性字典, 文本内容, 新位置) inherited_attrs 是父分组继承下来的属性 attrs dict(inherited_attrs) # 复制父属性 content [] while pos len(tokens): typ, val tokens[pos] if typ group_start: # 递归解析子分组传入当前属性作为继承 sub_attrs, sub_text, pos parse_group(tokens, pos 1, attrs) content.append(sub_text) elif typ group_end: return attrs, .join(content), pos 1 elif typ control_word: name, num val # 根据控制字更新属性这里只演示几个常见控制字 if name b: attrs[bold] (num ! 0) elif name i: attrs[italic] (num ! 0) elif name fs: attrs[font_size] num / 2 # RTF 字号是半磅 pos 1 elif typ text: content.append(val) pos 1 else: pos 1 return attrs, .join(content), pos这里的关键是inherited_attrs的传递方式。子分组拿到的是父属性的副本修改只影响自己。但有个坑RTF 规范里有些控制字是“目标标识”Destination比如\fonttbl、\colortbl、\stylesheet它们标记的是文档的元数据区域不是正文内容。解析时如果把这些目标标识里的文本当成正文输出就会多出一堆乱码。常见做法是维护一个“当前是否在目标标识内”的状态遇到\fonttbl就跳过整个分组直到匹配的}结束。2.3 目标标识与文档结构头部、正文、尾部怎么分RTF 文件的结构可以粗略分为三部分头部Header、正文Document Area、尾部。头部包含版本号\rtf1、字符集\ansi、字体表\fonttbl、颜色表\colortbl、样式表\stylesheet等。正文是实际的文本内容尾部就是最后的}。规范里把头部又细分为 RTF 版本、字符集、Unicode 设置、默认字体和语言、主题数据、颜色方案映射、字体表、文件表、颜色表、默认属性、样式表、列表表、修订标记、生成器信息等。这些区域大部分是可选的但\rtf1和\ansi基本是必须的。# 用 grep 快速查看一份 RTF 文件的头部结构 # 提取所有控制字按出现顺序排列 grep -oP \\\\[a-z][0-9]* sample.rtf | head -50这个命令能帮你快速定位文件里用了哪些控制字。如果看到\rtf1\ansi\deff0开头说明是标准 RTF 1.9 格式。如果看到\pict说明有嵌入图片。如果看到\fonttbl后面跟着一堆\f定义那就是字体表。实际排查时我一般会先看头部有没有异常控制字再看正文里有没有未闭合的分组。3. 从规范到代码字符编码、字体表与颜色表的落地处理3.1 Unicode 与代码页中文乱码的根源在哪RTF 规范 v1.9 支持 Unicode 5.0但实际文件里可能混用多种编码。\ansi表示 ANSI 代码页\mac表示 Macintosh 代码页\pc表示 IBM PC 代码页\pca表示 IBM PC 代码页 850。对于中文常见的是\ansi\ansicpg936表示简体中文 GBK 代码页。Unicode 字符在 RTF 里用\uN表示N 是十进制有符号整数。如果 N 是负数实际码点是 N 65536。比如\u20013表示“中”字\u-10179表示码点 55357对应某个 emoji 的高位代理。def decode_unicode_escape(text): 处理 RTF 中的 \uN 转义返回解码后的字符串 注意\uN 后面通常跟一个替代字符如 ?需要跳过 result [] i 0 while i len(text): if text[i] \\ and i 1 len(text) and text[i1] u: j i 2 # 解析数字可能带负号 num_str if j len(text) and text[j] -: num_str - j 1 while j len(text) and text[j].isdigit(): num_str text[j] j 1 if num_str: code int(num_str) if code 0: code 65536 result.append(chr(code)) # 跳过替代字符通常是 ? 或空格 if j len(text) and text[j] in ? : j 1 i j else: result.append(text[i]) i 1 else: result.append(text[i]) i 1 return .join(result)这段代码处理的是\uN转义。关键点负数码点要加 65536替代字符要跳过。我见过一个解析器没跳过替代字符结果每个 Unicode 字符后面都多一个问号。另外如果文件声明了\ansicpg936那非 Unicode 部分就要按 GBK 解码不能统一按 UTF-8 处理。3.2 字体表与颜色表解析顺序和引用方式字体表\fonttbl和颜色表\colortbl是 RTF 头部的两个关键区域。字体表定义字体编号和字体名颜色表定义颜色编号和 RGB 值。正文里用\fN引用字体用\cfN引用前景色用\highlightN引用背景色。def parse_font_table(tokens, pos): 解析字体表返回 {字体编号: 字体名} 字典 字体表格式{\fonttbl{\f0\fnil\fcharset0 Arial;}{\f1\fnil\fcharset134 SimSun;}} fonts {} # 跳过 \fonttbl 控制字 while pos len(tokens) and tokens[pos][0] ! group_start: pos 1 pos 1 # 跳过 { while pos len(tokens): typ, val tokens[pos] if typ group_end: break if typ group_start: # 解析单个字体定义 font_id None font_name [] pos 1 while pos len(tokens): t, v tokens[pos] if t group_end: break if t control_word: name, num v if name f: font_id num # 其他控制字如 \fnil \fcharset 忽略 elif t text: font_name.append(v) pos 1 if font_id is not None: fonts[font_id] .join(font_name).rstrip(;) pos 1 else: pos 1 return fonts, pos字体表的解析要注意每个字体定义是一个子分组里面用\fN指定编号后面跟字体名最后用分号结束。字体名可能包含空格所以不能简单按空格切分。颜色表类似但格式是\redN\greenN\blueN;分号分隔每个颜色定义。def parse_color_table(tokens, pos): 解析颜色表返回 {颜色编号: (R, G, B)} 字典 颜色表格式{\colortbl;\red0\green0\blue0;\red255\green255\blue255;} 注意第一个分号前是空定义对应自动颜色 colors {} color_index 0 while pos len(tokens) and tokens[pos][0] ! group_start: pos 1 pos 1 # 跳过 { current {red: 0, green: 0, blue: 0} while pos len(tokens): typ, val tokens[pos] if typ group_end: break if typ control_word: name, num val if name in (red, green, blue): current[name] num elif typ text and ; in val: # 分号表示一个颜色定义结束 colors[color_index] (current[red], current[green], current[blue]) color_index 1 current {red: 0, green: 0, blue: 0} pos 1 return colors, pos颜色表的坑在于第一个分号。规范里颜色表的第一个分号前是空的对应“自动颜色”编号为 0。如果你从 0 开始编号那第一个实际颜色是 1。很多解析器在这里搞错导致所有颜色偏移一位。3.3 段落与字符属性样式继承和覆盖RTF 的段落属性用\pard重置字符属性用\plain重置。段落属性包括对齐\ql\qc\qr\qj、缩进\li\ri\fi、间距\sb\sa\sl等。字符属性包括加粗\b、斜体\i、下划线\ul、字号\fs、字体\f、颜色\cf等。def apply_paragraph_properties(attrs, control_word, num): 根据控制字更新段落属性 if control_word ql: attrs[align] left elif control_word qc: attrs[align] center elif control_word qr: attrs[align] right elif control_word qj: attrs[align] justify elif control_word li: attrs[indent_left] num # 单位缇twip1/20 磅 elif control_word ri: attrs[indent_right] num elif control_word fi: attrs[indent_first] num elif control_word sb: attrs[space_before] num elif control_word sa: attrs[space_after] num elif control_word sl: attrs[line_spacing] num return attrs单位换算是个容易翻车的地方。RTF 里的长度单位是缇twip1 缇 1/20 磅 1/1440 英寸。字号\fs的单位是半磅所以\fs24表示 12 磅。如果你直接把\fs24当成 24 磅那所有文字都会大一倍。我一般会在解析层统一转成磅或像素避免后续计算混乱。提示\pard重置的是段落属性不影响字符属性。\plain重置的是字符属性不影响段落属性。两者要分开处理。4. 避坑与排查RTF 解析中最容易翻车的五个地方4.1 现象中文全部变成乱码英文正常原因文件声明了\ansicpg936但解析器按 UTF-8 解码。或者文件里混用了\uN转义和 GBK 编码解析器没有区分处理。解决先读头部控制字确认代码页。如果有\ansicpg936非 Unicode 部分按 GBK 解码。\uN转义单独处理不要和原始字节混在一起。常见做法是先把 RTF 文本按字节读入再根据代码页分段解码。4.2 现象表格线错位单元格内容跑到一起原因RTF 表格用\trowd\cellx\cell\row控制。\cellx定义单元格右边界单位是缇。如果解析时忽略了\cellx的累积值或者把\cell当成了普通文本表格结构就会乱。解决解析表格时维护一个单元格边界列表遇到\cellx就追加边界值遇到\cell就结束当前单元格遇到\row就结束当前行。注意\cellx的值是相对于行起点的累积位置不是单元格宽度。4.3 现象图片丢失或显示为空白原因RTF 图片用\pict目标标识嵌入数据是十六进制字符串。如果解析器把\pict分组当成了普通文本或者没有正确解码十六进制图片就会丢失。解决遇到\pict时读取整个分组内容提取\pngblip\jpegblip\wmetafile等图片类型控制字然后收集后续的十六进制字符按字节还原。注意十六进制字符串里可能夹杂换行和空格要先清理。4.4 现象嵌套分组属性继承错误加粗范围不对原因RTF 分组是树形结构子分组继承父分组属性。如果解析器用全局状态而不是栈来管理属性嵌套分组就会互相干扰。解决用递归或显式栈来管理分组。进入分组时复制父属性退出时丢弃子属性。不要用全局变量存当前属性否则嵌套一深就乱。4.5 现象文件末尾多出乱码或解析器崩溃原因RTF 文件末尾可能有多余的}或空字节。如果解析器没有做边界检查就会越界或输出多余内容。解决解析前先检查文件是否以}结尾去掉尾部空白和空字节。解析时维护位置索引每次访问前检查是否越界。常见做法是先用正则提取最外层分组再递归解析内部。5. 进阶技巧用规范里的冷门控制字做精确排版控制5.1 用\sbknone和\sbkpage控制分页RTF 规范里有一组分页控制字\sbknone不分页\sbkcol分栏\sbkpage分页\sbkeven分到偶数页\sbkodd分到奇数页。这些控制字用在段落属性里可以精确控制分页行为。def apply_section_break(attrs, control_word): 处理分页控制字 if control_word sbknone: attrs[section_break] none elif control_word sbkcol: attrs[section_break] column elif control_word sbkpage: attrs[section_break] page elif control_word sbkeven: attrs[section_break] even_page elif control_word sbkodd: attrs[section_break] odd_page return attrs实际用的时候\sbkpage等价于插入一个分页符但比\page更规范。\page是 Word 的扩展控制字不是 RTF 标准的一部分。如果你要生成跨平台兼容的 RTF优先用\sbkpage。5.2 用\listtext和\listlevel控制列表编号RTF 的列表系统比较复杂涉及\listtable\listoverridetable\listtext\listlevel等多个控制字。简单来说\listtable定义列表模板\listoverridetable定义覆盖规则正文里用\listtext标记列表项文本用\listlevel指定层级。def parse_list_table(tokens, pos): 解析列表表返回列表定义 列表表格式{\listtable{\list\listtemplateidN\listhybrid...}{\listlevel...}} lists {} while pos len(tokens) and tokens[pos][0] ! group_start: pos 1 pos 1 # 跳过 { while pos len(tokens): typ, val tokens[pos] if typ group_end: break if typ group_start: # 解析单个列表定义 list_id None levels [] pos 1 while pos len(tokens): t, v tokens[pos] if t group_end: break if t control_word: name, num v if name listtemplateid: list_id num elif name listlevel: levels.append(num) pos 1 if list_id is not None: lists[list_id] levels pos 1 else: pos 1 return lists, pos列表解析的难点在于\listoverridetable的覆盖规则。同一个列表模板可以在不同位置用不同的起始编号覆盖表就是用来记录这些差异的。如果你只解析\listtable不解析\listoverridetable列表编号可能会从 1 开始而不是从指定值开始。5.3 用\rsid做修订追踪\rsid是修订标识符用于追踪文档的修改历史。每个\rsid对应一个唯一的修订会话。规范里\rsid可以出现在段落属性、字符属性、表格属性等多个位置。def extract_rsid(tokens): 提取所有 rsid 控制字返回修订标识列表 rsids [] for typ, val in tokens: if typ control_word: name, num val if name rsid and num is not None: rsids.append(num) return rsids实际用的时候\rsid主要用于文档比较和合并。如果你要做版本追踪可以按\rsid分组把同一修订会话的修改归到一起。但要注意\rsid不是必须的很多 RTF 文件里根本没有这个控制字。5.4 验证方法用 Word 和 LibreOffice 交叉比对写完解析器后怎么验证结果对不对我一般会做两件事一是用 Word 打开原始 RTF截图保存二是用 LibreOffice 打开同一份文件截图保存。然后对比两份截图的排版差异。如果 Word 和 LibreOffice 显示一致但你的解析器输出不一致那问题大概率在你的代码。如果 Word 和 LibreOffice 显示就不一样那可能是 RTF 文件本身用了某些非标准控制字。# 用 LibreOffice 命令行转换 RTF 为 PDF方便比对 libreoffice --headless --convert-to pdf sample.rtf这个命令能把 RTF 转成 PDF然后你可以用 PDF 阅读器查看排版。如果 PDF 和你的解析结果差异很大就逐段排查控制字。常见做法是先用最小化 RTF 文件测试逐步增加复杂度定位到具体哪个控制字导致问题。从那以后我每次写 RTF 解析器都会先用一份只包含\rtf1\ansi\deff0和纯文本的最小文件跑通再逐步加入字体表、颜色表、表格、图片每加一个功能就交叉比对一次。这样虽然慢但能避免最后一次性调试的崩溃。希望帮到你。本文还有配套的精品资源点击获取