☰
RTF格式解析:控制词、目的地机制与兼容性设计
2026/10/23 10:43:59 网站建设 项目流程

简介:《Rich Text Format (RTF) Specification Version 1.9.1》是微软 2008 年发布的官方技术规范,面向需要解析、生成、转换或兼容 RTF 格式的桌面办公软件开发者、文档服务开发者及富文本标准研究者,也适合文档兼容性测试人员使用。这份 278 页的规范从 RTF 自 1987 年推出以来的版本演进讲起,将文件头部、正文和尾部的结构拆解清楚,并详述 ASCII 控制符、Unicode 编码、字体、颜色、字号、段落样式等格式控制,以及图片、表格、列表、超链接等对象的嵌入语法、排版规则与实现细节,同时说明 Unicode 5.0 支持和新增图片格式等 1.9.1 更新内容。资源以单个 PDF 形式提供,包体约 1.76MB,无冗余文件,官方原版排版保留了清晰的目录层级,可离线检索并按章节对照阅读。目前已有 740 人学习下载,借助这份规范,既能准确编写 RTF 读写程序、排查跨平台文档兼容问题,也能深入理解 Word 等软件生成 RTF 时的底层逻辑,是富文本格式开发与教学中权威的一手参考资料。

1. RTF 不是标记语言:为什么一份「可打印」的文档格式能活二十年

RTF 最有迷惑性的地方,是它名字里带着 “Rich”,却根本不是 HTML、XML 那种标记语言。打开一个.rtf文件,你会看到满屏的{\rtf1\ansi\ansicpg936...这样的指令流——它本质上是「纯文本正文 + 反斜杠控制词 + 花括号分组」的线性 Token 流。这种设计的直接后果是:任何一个文本编辑器都能打开 RTF 看原始内容,但真正要渲染它,解析器必须按控制词、控制符号、组、目的地四类实体去逐字词法切割。1987 年某字处理厂商推出这种格式,2008 年 3 月发布的 v1.9.1 官方规范已有 278 页。为什么一个「可打印」的老格式能一直活到今天?搞懂词法层和目的地机制,你就能理解它的兼容性设计,也能在解析它的时候少踩坑。

2. 词法层:控制词、控制符号与分组如何构成 RTF 的 Token 流

2.1 控制词:字母序列、可选参数与 32 个字符上限

RTF 控制词的形态非常朴素:一个反斜杠\,后面跟一串字母,再跟一个可选的十进制整数参数。规范里写得很清楚:控制词由不超过 32 个字母组成,区分大小写;参数是 32 位有符号十进制数,范围从-2147483648到2147483647。也就是说\fs24、\f0、\cf2这类东西就是 RTF 的世界里唯一的「指令」。

控制词的终止规则是真正让初学者翻车的地方:它不是用括号或引号收尾,而是「遇到第一个非字母数字字符就算结束」。如果这个结束符是空格,那么这个空格是分隔符,不进入正文。看这个片段:

{\rtf1\ansi\deff0 \fs24 Hello\b world\b0}

这里的\fs24后面跟一个空格,那个空格只用来把控制词和正文Hello分开,渲染出来就是Hello world,不会多一个空格。\b打开粗体,\b0关闭粗体,\b0后面的}既是参数边界,也是组边界,所以不需要额外空格。

一个最小扫描器可以这样写:

def scan_control_word(buf, i): # buf[i] 必须是反斜杠 i += 1 start = i while i < len(buf) and buf[i].isalpha() and (i - start) < 32: i += 1 word = buf[start:i] param = None if i < len(buf) and (buf[i].isdigit() or buf[i] == '-'): j = i while j < len(buf) and (buf[j].isdigit() or buf[j] == '-'): j += 1 param = buf[i:j] i = j return word, param, i

这个扫描顺序很关键:先读字母序列,再判断数字或负号。因为 RTF 里\-是合法的控制符号,如果先判断负号,\-就会被误读成「带参数-的控制词」。参数吞掉负号之后,可选连字符就彻底丢了。很多解析器的怪异行为都来自这一步顺序写反。

2.2 控制符号:用反斜杠表达的不可见字符与 'hh 字节注入

控制符号是另一种词法单元:反斜杠加一个非字母字符。它表达的是「在纯文本里没法直接写的东西」。\~是不可断空格,\-是可选连字符,\_是不可断连字符,\\是反斜杠本身,\{和\}是花括号,\`是反引号。这些符号和 XML 的实体引用思路一致,但更直接:每一个都对应一个字符。

其中最特殊的是\'hh,它后面必须跟两位十六进制数,表示一个 8 位字节,按当前代码页解释。在\ansicpg936下,\'d6\'d0连写就是汉字「中」的 GB2312 字节序列。这个机制让 RTF 能在纯 ASCII 文件里安全携带任意二进制字节,也让它成为「能跨代码页交换文本」的基础。控制符号本身没有参数,遇到\后不是字母的字符,直接查表即可。

2.3 分组:花括号决定嵌套层级,解析器靠一个栈活着

RTF 的分组用一对花括号表示:{...}。组可以嵌套,也必须有正确的闭合。规范约定每个 RTF 文件以{开头、以}结尾,不设独立的文件尾标记。也就是说,最后一个右花括号就是文件的句号。

分组承担两个作用:一是划定某个目的地的内容范围,比如{\header ...}里的所有内容属于页眉;二是构成格式属性的作用域。{\b bold text}在右花括号之后粗体就失效,内层修改属性,外层负责恢复。解析器处理嵌套时,必须维护一个组栈:

stack = [] current = [] for tok in tokenize(rtf_bytes): if tok == '{': stack.append(current) current = [] elif tok == '}': parent = stack.pop() parent.append(current) current = parent

机制上和 XML 的 element 嵌套相似,但 RTF 没有结束标签。<b>...</b>成对出现,错了能校验出来;RTF 只有一组花括号,错了只能靠栈的深度来判断。遇到多余的},栈就弹空;遇到文件末尾仍有未闭合的组,解析器只能自行补全。这种「没有自校验能力」的代价,是第 5 章要展开的重灾区。

2.4 目的地与 * 前缀:前向兼容的核心机制

分组内第一个控制词决定这一组内容的去向,这个控制词叫目的地(destination)。例如:

目的地控制词用途常见形式
\header\footer页眉、页脚{\header ...}
\footnote\endnote脚注、尾注{\footnote ...}
\pict图片{\pict ...}
\object嵌入对象{\object ...}
\shp绘图对象{\shp ...}
\fldinst\fldrslt域指令与域结果,超链接也走这里{\*\fldinst ...}
\info文档信息组{\*\info ...}
\math数学公式{\math ...}

阅读器遇到分组,先读第一个控制词,再决定是渲染、保存到子树,还是整组丢弃。这个路由机制让正文、页眉、脚注、图片、域指令能和平共处,互不污染。

真正让 RTF 保持前向兼容的,是\*前缀。一个未知目的地如果以\*开头,阅读器应当跳过整个组;如果没有\*,未知目的地只能丢弃控制词本身,组内内容还要当正文处理。这个约定的意义重大:2008 年的阅读器遇到 2020 年新工具写入的扩展目的地,不会把它打印到页面上,而是安静地跳过。RTF 能「活二十年」,靠的就是这套允许未知内容安全存在的词法设计。

3. 头部目的地:字体表、颜色表、样式表为什么要写在正文之前

3.1 先决区:\rtf 版本、字符集声明与默认字体编号

一个 RTF 文件的开头是有固定套路的:{\rtf1\ansi\ansicpg936\deff0。\rtf1声明 RTF 版本,\ansi或\mac、\pc、\pca声明基础字符集,\ansicpgN指定具体代码页,\deffN声明默认字体编号。这些控制词必须出现在任何\fN、\cfN、\sN引用之前,因为正文里到处都是编号引用,阅读器看到编号时,对应的表必须已经建立完毕。

官方规范把这一段称为 header,它涵纳的信息远不止版本:字体表\fonttbl、颜色表\colortbl、样式表\stylesheet、列表表\listtable、列表覆盖表\listoverridetable、文档信息组\info、生成器\generator,甚至修订标记\rsid,全部在这一段完成登记。

为什么「先声明、后引用」是硬性要求?因为 RTF 是流式格式,阅读器可能只扫描一遍。如果正文先出现\f1,而字体表在后面,阅读器就得把整个文件缓存下来,或者回退扫描,这在 1987 年是很重的成本。把所有表集中到头部,阅读器可以第一遍读头部建索引,第二遍渲染正文,内存开销极低。这个顺序约束本质上是一种「面向单遍扫描」的设计。

3.2 字体表:一张从 \fN 编号到字体名的间接映射表

字体表是头部里最庞大的目的地之一:

{\fonttbl {\f0\fnil\fcharset134 SimSun;} {\f1\fswiss Arial;} {\f2\fmodern Courier New;} }

每条字体定义以分号结束,格式是\fN加字体族控制词,加可选字符集/代码页,再加字体名。字体族一共有八种,\fnil是继承默认、\froman衬线、\fswiss无衬线、\fmodern等宽、\fscript手写体、\fdecor装饰体、\ftech技术符号、\fbidi双向文字字体。字体名本身是字节串,可能含有空格,所以整条记录必须用花括号包住,分号做终止符,这样解析器就不需要去解析字体名内部结构。

字体名为什么要绕一层编号?因为\fN数字短,正文里每处字符都要指定字体,写数字比写字体名省得多;而且同一字体名在不同系统上映射到的实际字体可以不同,编号让阅读器有机会做本地替换。规范里还描述了字体嵌入和主题字体信息,这些都是在\fonttbl基础上扩展的,所以表结构必须足够稳定:编号是键,字体名、字符集、代码页、字距都是值。

3.3 颜色表与样式表:分号宏与编号引用

颜色表\colortbl的结构更紧凑:

{\colortbl; \red0\green0\blue0; \red255\green0\blue0; \red0\green0\blue255;}

注意开头的第一个分号——它表示索引 0 是「自动颜色」,不写具体 RGB。每个颜色定义由三个 0 到 255 的整数组成,以分号分隔。规范约定分号是这里的宏终止符,读完\blue255后遇到分号,整条颜色定义结束。正文里用\cfN指定前景色、\cbN指定背景色,这些编号越界时,健壮的阅读器会回退到颜色 0,而不是报错。

样式表\stylesheet的登记逻辑相同:

{\stylesheet {\s0\ql Normal;} {\s1\f20\fs24 Heading;} {\cs16\ul 强调字符;} }

\sN是段落样式,\csN是字符样式,还有表格样式等类型。样式表内部的每一条同样以分号结束,样式名可以是任意文本。正文切换样式只写\s1,阅读器把样式中登记的字体、字号、缩进、对齐一次性应用到当前段落。这种「登记即展开」的间接层,让文档可以集中控制格式,也让 RTF 头部变得臃肿——一个样式多的文档光是\stylesheet就能占几十行。

3.4 列表表与编号状态:\listtable、\listoverridetable 与 \listtext

项目符号和编号是 RTF 里最容易被低估的复杂度。简单文档里一个\bullet就能画圆点,但真正要处理多级编号时,规范引入了两个目的地:\listtable定义抽象列表模板,\listoverridetable定义具体文档里的覆盖参数,正文段落再用\lsN引用某个覆盖表项。列表的具体编号文本由\listtext记录,旧式段落编号由\pntext记录。

为什么要拆成「模板」和「覆盖」两层?因为多级编号的状态是跨段落的:第 1 项编号是 1,第 2 项是 2,子项是 1.1、1.2。阅读器渲染到某个段落时,必须知道当前列表的起始编号、每级的缩进、编号格式、是否重新计数。把列表模板登记在头部,正文段落只需要携带一个列表引用编号,阅读器就能在渲染过程中维持编号状态机。这个设计让编号有了「工业级」的管理方式,代价是头部的\listtable结构复杂,普通解析器常常直接跳过——这也是为什么很多阉割版 RTF 阅读器显示的编号是乱的。

4. 字符编码层:\uN 补码、\ucN 与 \upr/\ud 的兼容性博弈

4.1 字符集声明:\ansi、\mac、\pc 与 \ansicpg 代码页

RTF 从一开始就是跨平台交换格式,所以头部必须明确说明文件里的字节按哪一套字符集解释。\ansi指当时各桌面系统通用的 ANSI 代码页,\mac指另一套 Mac 代码页,\pc和\pca对应 IBM PC 代码页。这些声明只确定「基础字符集」,真正精确到代码页靠的是\ansicpgN。例如\ansicpg936表示简体中文 Windows 代码页,\ansicpg1252表示西欧语言。

阅读器处理字符时遵循一个顺序:优先解释\uN这种显式 Unicode 转义,解释不了再用\'hh按代码页读取,最后才落到基础字符集。这种「显式 Unicode 优先、字节回退兜底」的结构,是 RTF 编码层的总纲。字符集声明决定了\'hh字节如何解码,但规范 v1.9.1 已经把 Unicode 支持推进到 Unicode 5.0,所以真正的文字内容应当尽量走\uN。

4.2 内置脚本模型:\loch、\hich、\dbch 与双向文本

RTF 里有一段经常被忽略的字符分类:\loch表示低 ANSI 字符,\hich表示高 ANSI 字符,\dbch表示双字节字符。这个分类的动机来自字体选择:一段中文文本往往需要「西文字体管字母数字、中文字体管汉字」,两者在同一个段落里混合。\dbch标记双字节字符,交给中文字体处理;\hich标记带重音的高位 ANSI 字符,交给西文字体;\loch则是普通拉丁字母。

解析器看到这些控制词时,并不是把它们当作要输出的内容,而是作为「切换渲染字体」的指示。配合字体表里的字体族,一个段落可以做到中文用 A、西文用 B、符号用 C。另外还有\ltrch和\rtlch控制文字方向,用于双向排版。这些都是字符编码层之上的「脚本路由」,如果解析器不处理\dbch,中文和西文混排的文档就会全部落到同一个字体上,视觉上变成中西文大小不一致的难看排版。

4.3 \uN 的补码表示:为什么 32768 以上的码位要写成负数

\uN是 RTF 表示 Unicode 字符的主要手段。N 的取值对应 Unicode 码位,理论上覆盖 0 到 65535。但规范规定 N 要按有符号十进制整数书写,于是大于 32767 的码位不得不写成负数。比如码位0x9FA6的十进制是 40870,写进 RTF 就成了\u-24666,因为40870 - 65536 = -24666。这就是补码。

这段逻辑在 1987 年的整数解析器里能直接复用,但现代解析器很容易踩坑:拿到负号参数后不还原,直接传给chr()或者当作未知控制词丢掉。正确的还原很简单:

def rtf_u_to_codepoint(n): # \uN 的 N 按有符号十进制写,负值表示 32768~65535 的码位 if n < 0: return n + 0x10000 if n > 0xFFFF: raise ValueError("out of BMP: use surrogate pair") return n

补充一个容易忽略的点:\uN只能表示 UTF-16 的一个码元,超出基本多语言平面的字符必须拆成代理对,分别写成两个\uN。规范声称支持到 Unicode 5.0,靠的正是这种「代理对 + 多个\uN拼接」的实现,而不是让 N 突破 65535。凡是直接把 N 当 32 位整数去chr()的实现,在增补平面字符上都会出问题。

4.4 \ucN 替代字节与 \upr/\ud 双轨文本

\uN解决的是「现代阅读器怎么精确显示」,但老阅读器看不懂它。于是规范和\uN一起引入\ucN:声明紧跟在一个\uN后面、用于给老阅读器显示的替代字节数。\uc1表示后面跟 1 个替代字节,\uc0表示没有替代。

{\rtf1\ansi\ansicpg1252 \uc1 \u20013? \u25991? }

这里\u20013是「中」的码位,?是给老阅读器的替代字节。支持 Unicode 的阅读器看到\u20013就输出「中」,并跳过?;不支持\uN的阅读器忽略这个控制词,把?当普通文本显示。\ucN一旦设定,后续全程生效,直到被下一个\ucN覆盖或离开所属分组。

更彻底的双轨方案是\upr和\ud两个目的地:支持 Unicode 的阅读器读\upr组,忽略\ud组;老阅读器跳过\upr组,读\ud组里的旧代码页文本。

{\rtf1\ansi\ansicpg936 {\upr 中文} {\ud \'d6\'d0\'ce\'c4} }

这种设计等于同一段文字保存两份:一份 Unicode 精确版,一份旧代码页近似版。代价是文件体积膨胀,收益是「同一个文件在 1995 年的阅读器和今天的阅读器里都能显示」。RTF 的兼容性哲学在这里暴露得很彻底——不强迫升级,而是让新旧实现各取所需。

4.5 为什么 RTF 不直接内嵌 UTF-8

既然 v1.9.1 已经支持到 Unicode 5.0,为什么文件里还满是\'d6\'d0和\u-24666,而不是直接写 UTF-8?原因是兼容链路不允许。RTF 是字节流格式,老阅读器没有 Unicode 概念,看到多字节序列会按 ASCII 逐字节乱解;而且文件里所有控制词、花括号、反斜杠都建立在 ASCII 之上,把正文切成 UTF-8 会割裂「控制词 ASCII、正文字节流」的两层结构。

更重要的是,\ansicpgN这种代码页声明机制,保证了一个 RTF 文件可以被完全不懂 Unicode 的旧程序读取和修改,Unicode 只是附加层。这种「向后兼容优先」的策略,解释了为什么 2008 年的规范里\'hh仍然存在且必须存在。UTF-8 当然更干净,但会直接切断与 1987 年所有现有实现的兼容。RTF 选择的是增量修补,而不是格式革命——这也是它能延续二十年的根本原因。

5. 避坑排查:RTF 解析最容易翻车的五个现场

读 RTF 的人最常挂在嘴边的一个词是「玄学」:同一个文件,换个阅读器显示就不一样。这不是阅读器偷懒,而是 RTF 的宽容设计把大量歧义留给了实现方。下面五个现场,是我认为最典型的翻车类型,每条都按现象、原因、解决展开。

5.1 负号被当成参数:- 可选连字符与负参数打架

现象:文档里某处本应是可选连字符,解析后却消失了,或者后面的内容整体错位;更隐蔽的是\u-24666这种负数补码,被某些实现当成两个 token 处理,Unicode 字符直接乱码。

原因:控制词参数允许负号,而\-又是合法的控制符号。词法扫描器如果先判断「反斜杠后是不是数字或负号」,就会把\-当成「带负参数的未知控制词」;如果先读字母序列,又把\u之后的-24666拦腰截断,补码就还原不了。

解决:严格按顺序扫描——先读反斜杠后的字母序列得到控制词名,字母结束后才检查数字或负号是否属于参数;反斜杠后不是字母的,直接走控制符号表匹配\{、\}、\-、\~等固定项。补码还原时做n & 0xFFFF或负数加0x10000,不要把负号单独吞掉。

5.2 控制词后的空格进入正文:分隔符与内容分不清

现象:{\fs24 Hello}渲染成了Hello(前面多一个空格),或者{\fs24 Hello}(两个空格)变成了Hello(一个都没留)。同一个文件在两个阅读器里差一个空格,肉眼很难定位。

原因:规范规定控制词后面的分隔空格不属于正文,但实现常常没消费它;反过来,正文里真正需要的空格又会被误当作分隔符丢掉。空格是 RTF 里最容易被冤枉的字符:它既是词法分隔符,又是合法正文。

解决:扫描控制词时,如果终止字符是空格,消费掉它;如果消费后后面还有空格,那些空格保留为正文。对不可断空格,规范提供了\~控制符号,需要精确控制空格的场景优先用\~而不是裸空格。

5.3 未知 * 目的地被当正文输出:页眉悄悄混进正文

现象:打开文件后,正文里凭空多出一大段文档标题、作者名、页眉内容,甚至二进制乱码。常见于实现没有处理{\*\info ...}、{\*\xmldata ...}这类扩展目的地。

原因:阅读器没有实现「可忽略目的地」约定。规范要求:遇到\{后先读第一个控制词,如果它有\*前缀且阅读器不认识这个目的地,整个组必须跳过,不能把组内内容当作正文输出。有的实现只跳过了认识的\*目的地,未知的落入兜底逻辑,于是内容被渲染。

解决:实现一个skip_group函数,配合目的地名单判断。伪代码可以这样写:

def parse_group(tokens, pos): if tokens[pos] != '{': raise ValueError("expected {") pos += 1 while tokens[pos] != '}': if tokens[pos] == '\\*': unknown = is_unknown_destination(tokens, pos) pos = skip_group(tokens, pos) if unknown else pos + 1 else: pos += 1 return pos + 1

只要\*后跟的目的地不在已知列表里,就整组丢弃。这一步做对了,RTF 的扩展内容才不会污染正文。

5.4 \uN 负数只做了有符号转换:补码没还原成 Unicode

现象:中文字符和高码位符号显示成\uffff、\x{fffe}这类错误字符,或者直接抛异常。排查时发现\u-24666被原样当作-24666传给了chr()。

原因:\uN的参数虽然是十进制有符号整数,语义却是 16 位无符号码位。大于0x7FFF的码位以负数书写,解析器必须做补码还原。很多实现只做了「有符号整数解析」,忘了最后一步+ 0x10000。

解决:统一在解码层处理n < 0 ? n + 0x10000 : n,并且对超出 0xFFFF 的情况按代理对处理。附带检查\ucN:如果声明了\uc1,遇到\uN后要跳过后面的替代字节,否则替代字节会以乱码形式混进正文。这两个坑经常同时出现。

5.5 花括号不配对:半篇文档直接蒸发

现象:文件被截断,或者一段文本消失在某个花括号之后。导出的 RTF 用文本编辑器看,括号深度怎么数都对不上;阅读器要么抛「unbalanced braces」,要么静默丢内容。

原因:RTF 没有结束标签,只有括号配对。一个多余的}会让栈提前弹空,一个缺失的}会让后面整个组变成上一个组的子内容。特别常见的是导出时把顶层组的}漏掉,阅读器不知道文件在哪里结束。

解决:解析必须维护组栈;遇到}且栈为空,说明文件损坏,此时可以丢弃后续内容或触发容错分支;遇到 EOF 而栈非空,自动补全所有未闭合的}。导出器侧,建议在写完最后一个字节后做一次「括号计数校验」,数一遍{和}的数量差异,这是我个人写导出器时最后一道例行检查。

6. 设计取舍:为什么 RTF 长成了一棵「文档树」,而不是一段线性流

RTF 的存储形态是线性 Token 流,但语义上必须被理解成一棵树:顶层组是文档根,{\header ...}、{\footer ...}、{\pict ...}、{\shp ...}都是挂在根上的子树,正文段落是子树里的分支。头部里的字体表、颜色表、样式表、列表表,则像是整棵树的「全局索引」,先建立引用关系,正文再去引用它们。这个模型和 XML DOM 很像,但少了最重要的一层保障——schema 校验。

为什么当年不直接上 XML?因为 RTF 诞生在 1987 年,XML 还不存在;用纯 ASCII 控制词,是为了同时满足「任何编辑器都能打开」「二进制传输不失真」「文件损坏还能抢救」三个目标。控制词不闭合是它最大的 trade-off:省掉了结束标签,文件更小、解析更快,但结构错误无法自检。这也导致每个 RTF 阅读器都必须是一个「宽容解析器」:遇到未知控制词跳过、遇到未知\*目的地跳过、遇到括号不配对要能恢复。宽容是它的生存策略,也是所有玄学问题的源头。

想验证自己是否真的吃透了这套机制,建议写一个 200 行的最小 RTF 子集解析器,用五个用例做回归:未知\*目的地是否被跳过、\uN负数补码是否还原、控制词后分隔空格是否被消费、\-是否被误读成负参数、括号不配对时能否恢复。这五个点跑通,你的解析器对真实世界 RTF 文件的健壮程度,就已经接近那些商业阅读器的水平了。

从那以后,我每次接触一种新的文档格式,都强制自己先画两张表:Token 类型表和目的地归类表。先想清楚哪些内容是可以安全丢弃的,再去想怎么渲染,而不是对着示例文件猜行为。RTF 教给我的不是控制词表,而是「兼容性是一种设计决策,不是事后补救」。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询