ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RTF规范解析实战:控制字、组与字符集避坑指南

RTF规范解析实战:控制字、组与字符集避坑指南

简介:这份资源是微软官方发布的《Rich Text Format (RTF) Specification》1.9.1 版技术规范文档,面向需要解析、生成或转换 RTF 文档的开发者、文本编辑器与办公软件研发人员,以及希望深入理解富文本格式底层机制的技术读者。压缩包内共 1 个 PDF 文件,约 1.76MB,完整收录了 2008 年 3 月发布的规范正文,涵盖 RTF 的历史沿革、文件头部与正文尾部结构、控制符与 Unicode 编码语法、字体颜色字号样式等格式控制,以及图片、表格、列表、超链接等元素的嵌入与排版细节,并说明了 1.9 版本相较旧版新增的 Unicode 5.0 支持等内容。已有 741 人学习下载,适合作为实现 RTF 读写、排查格式兼容问题或进行跨平台文本交换时的案头参考,可帮助读者系统掌握规范条文与实现要点。

1. 拿到一份 278 页的 RTF 规范,先别急着翻语法表

上周有个做文档转换的同行丢给我一个压缩包,说里面是 RTF 规范 v1.9.1,问值不值得啃。我打开一看,278 页,2008 年 3 月定稿,覆盖 2007 Office 套件和 Word 2007 的读写行为。如果你正在做富文本导入导出、邮件正文渲染、老系统文档迁移,或者被客户塞过来一堆.rtf文件要求“原样还原格式”,这份规范基本是绕不开的底稿。它不讲故事,全是控制字、目标组、字符集和表格定义的硬描述,但恰恰是这些东西决定了你的解析器在遇到嵌套表格、Unicode 混排、列表编号时会不会翻车。适合谁?写过或准备写 RTF 解析器的人、需要排查“为什么 Word 打开正常我的库打开乱码”的人、以及要给文档转换链路定边界的人。不适合只想调个库三行代码搞定的人。

2. RTF 的骨架:控制字、组和目标怎么读

2.1 控制字与控制符号:解析器的第一道分水岭

RTF 用纯 ASCII 描述格式,所有指令以反斜杠开头。控制字是\加字母序列,后面可跟一个可选的有符号数字参数,再跟一个分隔符(空格或非字母非数字字符)。控制符号则是\加单个非字母字符,比如\{、\}、\\。这里第一个坑就来了:分隔符空格属于控制字的一部分,不输出到正文。很多人手写解析器时把\b0后面的空格当正文吐出来,结果每个加粗段落后面多一个空格,肉眼看不出来,一做文本比对就炸。

import re # 控制字:反斜杠 + 字母 + 可选数字参数 + 可选分隔符空格 CONTROL_WORD = re.compile(r"\\([a-zA-Z]+)(-?\d+)?[ ]?") def tokenize_control_words(rtf_text): tokens = [] pos = 0 while pos < len(rtf_text): m = CONTROL_WORD.match(rtf_text, pos) if m: name, param = m.group(1), m.group(2) tokens.append((name, int(param) if param else None)) pos = m.end() else: # 非控制字部分按字符推进,交给正文收集逻辑 tokens.append(("TEXT", rtf_text[pos])) pos += 1 return tokens

这段只做控制字切分,param为None表示该控制字不带参数,比如\par、\b。注意正则里[ ]?只吃掉一个空格,这是规范行为:控制字后的分隔符最多一个空格,多余空格属于正文。参数范围也要留意,规范里不少控制字参数是 16 位有符号,遇到\fs字号这种值别用无界整数直接乘。

2.2 组与目标:状态栈才是解析器的核心

RTF 用花括号{}划分组,组可以嵌套。每个组继承父组的状态,组结束后状态回滚。真正让解析器复杂起来的是“目标”(destination):某些控制字声明后续内容不是正文,而是字体表、颜色表、样式表、图片数据等。比如{\fonttbl ...}里的内容不能当正文输出,{\*\generator ...}这种带\*前缀的可忽略目标,遇到不认识的应当整组跳过。

class RtfState: def __init__(self): self.bold = False self.italic = False self.font_size = 24 # 半磅单位,24 即 12pt self.destination = None # None 表示正文 def parse_groups(tokens): stack = [RtfState()] output = [] for name, param in tokens: if name == "TEXT" and param == "{": stack.append(RtfState()) # 复制父状态,实际实现需逐字段拷贝 stack[-1].__dict__.update(stack[-2].__dict__) elif name == "TEXT" and param == "}": if len(stack) > 1: stack.pop() elif name == "b": stack[-1].bold = (param != 0) elif name == "i": stack[-1].italic = (param != 0) elif name == "fs" and param is not None: stack[-1].font_size = param elif name == "fonttbl": stack[-1].destination = "fonttbl" elif name == "par": output.append("\n") elif name == "TEXT" and stack[-1].destination is None: output.append(param) return "".join(output)

这里RtfState用__dict__.update做浅拷贝,字段都是不可变类型所以够用;一旦状态里挂列表或字典,必须深拷贝,否则子组改字体表会污染父组。destination的判断顺序也有讲究:先看当前组是否处于非正文目标,再决定是否输出字符。\*可忽略目标的处理要在进入组时标记,整组内容丢弃,而不是逐字符判断。

2.3 头部三张表:字体、颜色、样式缺一不可

RTF 头部通常包含\rtf1版本声明、字符集声明、\deff默认字体、\fonttbl字体表、\colortbl颜色表、\stylesheet样式表。字体表里每个\fN定义字体编号,正文用\fN引用;颜色表按分号分隔的\redN\greenN\blueN顺序定义,正文用\cfN引用颜色索引。样式表定义段落和字符样式,\sN引用。

表控制字索引引用常见坑
字体表\fonttbl\fN字体名含空格需用\'xx转义或分组
颜色表\colortbl\cfN第一个分号前可能是空项,索引从 0 起
样式表\stylesheet\sN样式可继承,需按\sbasedon链解析

颜色表那个空项是血泪经验:{\colortbl;\red255\green0\blue0;}里第一个分号前没有颜色定义,索引 0 是“自动颜色”,真正的红色是索引 1。不少解析器把索引 0 当红色,结果默认文字全变红。字体表里字体名如果含空格或特殊字符,规范允许用\'hh十六进制转义,也可能直接放在组里,读的时候要按\fN后的内容一直收集到下一个\f或组结束。

3. 从规范到代码:字符集、Unicode 与特殊字符处理

3.1 字符集声明与代码页:乱码的根源在这里

RTF 头部用\ansi、\mac、\pc、\pca声明字符集,配合\ansicpgN指定代码页。规范里 Unicode RTF 的处理方式是:\uN表示一个 Unicode 码位,后面通常跟一个替代字符(用于不支持 Unicode 的读者),替代字符的字节数由\ucN控制,默认\uc1。也就是说\u20013?里的?是给老读者的占位,现代解析器应当跳过它。

def decode_unicode_control(param, following_text, uc_skip=1): # param 是 \uN 的 N,可能是负数,需加 65536 转无符号 code = param if param >= 0 else param + 65536 # 跳过 uc_skip 个替代字符 return chr(code), following_text[uc_skip:]

\ucN是组作用域状态,进入子组可能变,退出要回滚。常见翻车是全局用一个uc_skip,遇到嵌套组里\uc0就多跳或少跳字符。另一个坑是\'hh十六进制转义:它表示当前代码页下的一个字节,必须结合\ansicpgN解码,不能直接当 Latin-1。规范里明确\'hh在\ansi下按代码页解释,中文环境常见\ansicpg936,这时\'d6\'d0才是“中”。

3.2 特殊字符与转义:反斜杠、花括号和换行

正文里\\、\{、\}是转义后的字面字符,\~是不间断空格,\-是可选连字符,\_是不间断连字符,\tab、\line、\par分别对应制表、软换行、段落结束。这些控制符号没有参数,解析时直接映射。容易忽略的是\binN:后面跟 N 个字节的二进制数据,必须按字节数跳过,不能按文本扫描,否则图片数据里的{、}会把组结构搅乱。

def skip_binary(data: bytes, pos: int, n: int) -> int: # \binN 后紧跟 N 字节原始数据,直接跳过 return pos + n

处理.rtf文件时建议先按字节读入,再在字节层面识别\bin,不要先整体 decode 成字符串。图片、嵌入对象都走\bin或\pict目标,\pict里还有\pngblip、\jpegblip、\wmetafile等类型声明,十六进制数据以\'hh形式出现时按字节拼接。规范里图片部分篇幅不小,实际落地时先支持 PNG/JPEG 两种就能覆盖大多数场景。

3.3 段落、表格与列表:结构还原的优先级

段落属性由\pard重置后跟一系列控制字,比如\ql左对齐、\qc居中、\liN左缩进、\fiN首行缩进。表格用\trowd开始一行,\cellxN定义单元格边界,\cell结束单元格,\row结束行。列表在 Word 97 到 2007 的 RTF 里走\listtable、\listoverridetable和\lsN、\ilvlN引用,比早期\pntext方案复杂得多。

结构关键控制字还原要点
段落\pard、\ql/\qc/\qr、\li/\ri/\fi\pard重置所有段落属性,必须逐组维护
表格\trowd、\cellx、\cell、\row\cellx是累计边界,不是宽度
列表\listtable、\ls、\ilvl列表定义和覆盖表要分开解析再关联

表格的\cellxN是相对于左边距的累计位置,单位是 twip(1/1440 英寸),不是单元格宽度。算宽度要相邻\cellx相减。列表的\lsN引用列表覆盖表里的条目,覆盖表再指向列表表里的定义,中间隔一层,直接按\ls找定义会找不到。规范里列表部分从第 30 页开始,值得单独抽半天读。

4. 避坑与排查:五个真实翻车现场

4.1 现象:解析出的文本每个加粗段落后多一个空格

原因:控制字分隔符空格被当正文输出。\b0里的空格是分隔符,不属于内容。解决:控制字正则匹配时吃掉一个尾随空格,或在 tokenizer 里标记分隔符不进入正文流。验证方法:拿一段纯加粗文本解析后做strip()比对长度。

4.2 现象:中文全部变成问号或乱码

原因:\'hh按 Latin-1 解码,没看\ansicpgN;或\uN后的替代字符没跳过。解决:先读头部\ansicpg,用对应代码页解码\'hh;\uN按\ucN跳过替代字符,且\uc状态随组回滚。排查时打印头部前 200 字节,确认字符集声明和代码页。

4.3 现象:图片数据把组结构搞崩,解析器报括号不匹配

原因:\binN后的二进制数据被当文本扫描,里面的{、}被误认为组边界。解决:字节层面识别\bin,按 N 跳过;\pict里的十六进制数据按\'hh成对读取,不要逐字符判断花括号。建议解析器分两层:字节层切\bin,文本层处理控制字。

4.4 现象:表格列宽全错,最后一列挤成一条线

原因:把\cellxN当单元格宽度用。解决:\cellx是累计边界,宽度等于当前\cellx减前一个\cellx,第一个减左边距。另外\trgaph单元格半间距会影响实际渲染,规范里默认 108 twip,解析时可以忽略但渲染时要加回。

4.5 现象:列表编号全部变成 1,层级也不对

原因:只解析了\listtable,没处理\listoverridetable,或\ls引用没关联到\ilvl。解决:先建列表定义表(\listid为键),再建覆盖表(\listoverride指向\listid),正文\lsN查覆盖表,\ilvlN查层级。排查时把\ls和\ilvl的值打印出来,对照覆盖表条目。

5. 进阶验证:用往返比对给解析器上保险

规范读到这里,最容易犯的错是“看起来能跑就收工”。我的习惯是搭一个往返比对流程:拿 Word 另存一份.rtf,用自己解析器转成中间结构再序列化回 RTF,然后用 Word 打开比对渲染结果,或者至少做文本层 diff。文本层 diff 能抓住控制字分隔符、Unicode 替代字符、特殊字符转义这三类高频错误;渲染比对则能暴露表格边界、列表编号、段落缩进的问题。

具体做法分三步。第一步,准备一组覆盖用例:纯文本、加粗斜体混排、中英混排、嵌套表格、多级列表、含 PNG 图片的文档,每类各一份,全部由 Word 2007 或兼容版本另存为 RTF。第二步,写一个最小序列化器,只输出解析器认识的子集,不追求全量还原,目的是让 diff 有可比基准。

def serialize_minimal(doc): # doc 是解析后的中间结构,只序列化文本和段落 parts = [r"{\rtf1\ansi\ansicpg936\deff0"] for para in doc.paragraphs: for run in para.runs: if run.bold: parts.append(r"\b ") parts.append(escape_rtf(run.text)) if run.bold: parts.append(r"\b0 ") parts.append(r"\par ") parts.append("}") return "".join(parts) def escape_rtf(text): return (text.replace("\\", r"\\") .replace("{", r"\{") .replace("}", r"\}") .replace("\n", r"\line "))

escape_rtf的顺序不能反,先转反斜杠再转花括号,否则会把刚加的反斜杠再转一遍。\ansicpg936按目标环境改,序列化出的文件用 Word 打开,如果中文正常、加粗正常、段落正常,说明解析器的文本层和字符层基本可信。第三步,把 diff 结果按错误类型归类,控制字分隔符错误归一类,Unicode 跳过错误归一类,每修一类补一个回归用例。这套流程跑下来,比对着 278 页规范逐条猜要快得多。

还有一个容易被忽略的验证点:\*\generator这类可忽略目标。规范要求不认识的\*目标整组跳过,但很多解析器只跳控制字不跳组内容,结果生成器字符串混进正文。验证方法是找一份带{\*\generator ...}的 RTF,解析后搜生成器名称,搜到就是没跳干净。从那以后我每次接文档转换需求,都强制先跑一遍往返比对再谈功能,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进