简介:一份基于C#语言编写的RTF文档编辑器完整工程,面向C#初学者、Windows桌面应用开发者及需要处理复杂富文本格式的学员。项目基于Windows Forms实现,支持打开、保存与另存为RTF格式文件,涵盖文件I/O、RichTextBox控件应用、事件处理与界面设计等关键知识点;RTF作为通用格式,可在不同文字处理软件间交换带格式文本。压缩包共37个文件,资源包约90KB,主要包含8个.cs源码文件、9个PNG界面图标、Visual Studio解决方案与配置文件,以及编译生成的EXE和PDB等;目录结构清晰,带注释的源代码便于直接打开研读。目前已有345人学习下载。通过阅读源码和工程目录,可快速理解RTF文档编辑器的完整实现思路,掌握从界面搭建、菜单交互到文件读写的实际开发流程,也能了解C#桌面应用的项目组织方式;无论是刚接触C#语法,还是希望完善自身桌面工具开发经验,这套资源都能提供有价值的参考,适合作为课程设计或入门练习的案例使用。
1. C# 编写 RTF 文档编辑器:从 RichTextBox 起步,别急着写解析器
接到“做一个带格式的文档编辑器”这个需求时,如果限定在 C# 生态里,最容易被低估的方案就是 C# 编写的 RTF 文档编辑器:它不要求你从零实现文本布局,只要在 WinForms 里拖一个 RichTextBox,半小时就能拿到输入、选择和基础格式能力。但真把它当文档工具交付时,图片粘贴、中文编码、撤销栈、字体兼容这些坑会挨个找上门。本文面向想把 RTF 编辑器快速落地成内部工具、又不打算引入重型第三方控件的桌面开发者,从选型、最小实现、格式化、存取到避坑,一路讲到能直接拿出去用的状态。RTF 本身是纯文本加控制字的结构,这一点决定了它在 C# 里能做得很轻,也能被玩得很花。
2. 渲染层选型:WinForms、WPF 还是自绘,三笔账算清楚再动手
2.1 自绘文本编辑器为什么在 RTF 场景容易失控
很多人一开始会想:我自己画文本,不依赖任何控件,所有格式自己做。这个方向放到 RTF 上,基本等于给自己挖了一个无底洞。RTF 文档不只是一行行带颜色的字,它包含字体表、颜色表、段落属性、嵌套组、图片二进制块,甚至还能内嵌 OLE 对象。自绘编辑器要处理的字形度量、行高、自动换行、分页、IME 输入框、OLE 激活,每一项都是独立的子系统。单独做纯文本编辑器尚可一战,一旦开始支持复杂的 RTF 混排,开发周期会从“几周”变成“几个月”,而且最麻烦的是边界情况极多:同一段文字里一半是粗体一半不是、一个段落里有三种字号、图片后面跟着一个表格。这些情况在 RichTextBox 里是默认行为,自绘就全变成你的 bug。
我见过一个团队为了“完全可控”选择自绘,最后花了三个月还没把撤销栈做对。这不是说自绘不行,而是说你在动手之前得算清楚:你的核心价值是编辑器本身,还是编辑器承载的业务功能?如果答案是后者,直接用现成渲染层。
2.2 WinForms 最小实现:20 行代码把 RichTextBox 变成可用编辑器
WinForms 的 RichTextBox 是 C# 里做 RTF 编辑成本最低的选项。它底层封装了微软的 RichEdit 控件,内置对 RTF 1.x 的解析和生成能力,我们只需要把它放到窗体上,配置几个关键属性就能承担编辑面的职责。
var rtb = new RichTextBox(); rtb.Dock = DockStyle.Fill; // 铺满窗体,随窗口缩放 rtb.WordWrap = true; // 软换行,不出现横向滚动条 rtb.AcceptsTab = true; // 允许 Tab 键作为缩进输入 rtb.Multiline = true; // 多行编辑,关闭则退化成单行框 rtb.Font = new Font("Microsoft YaHei UI", 10.5f); rtb.HideSelection = false; // 焦点离开时仍能看到选区这里HideSelection是个容易被忽略的细节:默认值为 true,编辑器失焦后选区高亮会消失,用户会以为内容被清掉了。AcceptsTab为 true 时,Tab 键会进入文本而不是切换焦点,这对写文档的人很重要,但对做界面的开发者来说,记得给其他按钮配好快捷键,否则用户会卡在编辑器里出不去。Font属性设置的是默认字体,RichTextBox 会把它写进 RTF 的默认字体表里,后续加载文档时如果文档没声明字体,就会回落到这个字体。
加载与保存是编辑器最核心的两个动作,但也有一个坑:必须区分 RTF 文件和纯文本文件,不能一律用LoadFile(path, RichTextBoxStreamType.RichText),否则纯文本里恰好出现类似 RTF 的字符会被错误解析。
string path = "demo.rtf"; if (File.Exists(path)) { byte[] header = new byte[6]; using (var fs = File.OpenRead(path)) fs.Read(header, 0, header.Length); if (Encoding.ASCII.GetString(header).StartsWith("{\\rtf")) rtb.LoadFile(path, RichTextBoxStreamType.RichText); else rtb.LoadFile(path, RichTextBoxStreamType.PlainText); }RTF 文件无论编码如何,开头固定是 ASCII 字符{\rtf,所以读前 5 到 6 个字节就能可靠判断。RichTextBoxStreamType.RichText会按 RTF 语法解析并把格式还原到编辑面,而PlainText只读取字符串内容,忽略所有控制字。如果遇到带 BOM 的文件,File.Read的前几个字节可能被 BOM 占用,判断会失败,这时候要先跳过 BOM 字节再检查签名。这一步看起来很基础,但恰恰是很多“打开文件乱码”问题的根源。
2.3 WPF 路线:TextRange 和 FlowDocument 的转换代价
WPF 里也有 RichTextBox,但它内部存储的是 FlowDocument,而不是 RTF 字符串。这意味着你在 WPF 里做编辑时,看到的文档模型和 RTF 不是一一对应的,需要通过 TextRange 做转换。
// 把 RTF 字符串载入 WPF RichTextBox using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(rtfText))) { var range = new TextRange(richTextBox.Document.ContentStart, richTextBox.Document.ContentEnd); range.Load(stream, DataFormats.Rtf); }TextRange.Load接受一个 Stream 和 DataFormats.Rtf,把 RTF 数据解析成 FlowDocument。反过来导出则用range.Save(stream, DataFormats.Rtf)。这个往返在大多数场景下是可靠的,但 FlowDocument 的模型对表格、图片、内嵌对象的支持不如 RichEdit 完整,某些在 WinForms 里能正常显示的复杂 RTF,转到 WPF 后会丢样式或被简化。还有一点,WPF 的 RichTextBox 默认输入体验和 WinForms 不一样,比如回车换行产生的是Paragraph,而不是 RTF 里的\par。从 WinForms 迁到 WPF 的团队,最常翻车的就是这里。
如果项目已经基于 WPF,没必要为了 RTF 特意切回 WinForms;但如果你评估的结论是“这个编辑器需要处理很多 Word 生成的复杂 RTF”,WinForms 的兼容性会更省心。WPF 的优势在界面定制自由度,不在 RTF 兼容度。
2.4 第三方控件和自我解析的判断标准:看完这几条再拍板
第三方案件不是不能用,而是要先分清你想省的是哪部分成本。市面上成熟的富文本控件通常有自己的文档格式,对 RTF 只是“能导入导出”而非“原生支持”,导入导出之间可能会丢东西。需求里如果明确要求 RTF 作为主要编辑格式,我的建议是优先用 RichEdit 系控件,因为 RTF 是它们的母语。
要不要手写 RTF 解析器,取决于你是否需要读 RTF 里的结构化语义。比如你要提取文档里所有加粗段落生成摘要,或者要统计每个颜色的使用面积,这时候 RichTextBox 的黑盒 API 就不够用,因为属性只能拿到“当前选区”的信息,拿不到全文档的结构。我在这种场景下才会考虑自己解析 RTF。还有一种情况是编辑器要嵌入到服务端,没有 UI 可用,那也只能自己解析。
下面这个判断表可以帮你快速定位:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 桌面工具内嵌编辑器,格式以粗体/字号/颜色为主 | WinForms RichTextBox | 成本最低,往返稳定 |
| WPF 项目且界面定制要求高 | WPF RichTextBox + TextRange | 保留 RTF 交换能力 |
| 需要处理 Word 表格、图片、分页符等复杂文档 | 商业控件或 RichEdit 原生封装 | 自研代价远高于收益 |
| 需要在服务端解析并统计 RTF 结构 | 手写轻量解析器 | 无 UI 可用,只能靠文本解析 |
注意:不要因为“某某控件功能多”就选择它。先拿 30 份真实 RTF 样本(Word 存的、网页复制的、老系统导出的)测一遍加载和往返,再决定用哪条路线。这一步能帮你省掉至少一周的返工。
3. 格式化操作落地:从按钮点击到 SelectionFont,再看到 RTF 控制字
3.1 一条格式化命令的完整链路:SelectionFont 到底改了什么
界面上的“加粗”按钮按下后,代码要做的不是去构造 RTF 字符串,而是修改当前选区的字体属性。RichTextBox 内部会把文档拆成若干相邻的“运行块”,每个运行块有一致的格式。改变SelectionFont时,控件会自动在这些块之间做切割与合并,然后更新对应的 RTF 控制字。
private void ToggleBold(RichTextBox rtb) { Font current = rtb.SelectionFont; // 读取当前选区字体,混合格式时为 null if (current == null) return; FontStyle next = (current.Style & FontStyle.Bold) == FontStyle.Bold ? current.Style & ~FontStyle.Bold // 已加粗则去掉粗体 : current.Style | FontStyle.Bold; // 未加粗则追加粗体 rtb.SelectionFont = new Font(current.FontFamily, current.Size, next); }SelectionFont为 null 的情况很常见:当用户选中了一段既有粗体又有正常字体的混合文本时,控件没有办法用一个 Font 对象表示整个选区,于是返回 null。此时如果直接调用rtb.SelectionFont.Style会抛空引用异常。所以先判空是必须的。加粗切换用的是位运算,FontStyle是一个枚举组合,直接赋值会覆盖原有风格,比如原本是斜体,赋一个只带 Bold 的 Style 会把斜体丢掉,所以要做位运算保留其他位。
字号设置同样的逻辑,但要注意单位。RichTextBox 的SelectionFont.Size用的是美式点单位(pt),RTF 里的字号控制字\fsN单位是半点,也就是说字体大小 12pt 在 RTF 代码里写的是\fs24。控件内部会自动换算,但如果你手写 RTF 生成器,必须按半点算,这是新手最容易算错的地方。
private void ApplyFontSize(RichTextBox rtb, float sizePt) { if (rtb.SelectionFont == null) return; rtb.SelectionFont = new Font(rtb.SelectionFont.FontFamily, sizePt, rtb.SelectionFont.Style); }ApplyFontSize里重新构造了 Font,字体族和风格都从原字体继承,只改字号。这样可以避免字号下拉框改变后,用户的粗体、斜体被意外清掉。如果SelectionFont为 null,更稳妥的做法是遍历选区里的每个字符逐个设置,但那个成本高,一般只在高级排版功能里用到。
3.2 颜色与高亮:SelectionColor 和 SelectionBackColor 的使用差异
文字颜色和背景高亮在界面上是两个按钮,但背后的属性和行为逻辑有细微差别。SelectionColor设置前景色,SelectionBackColor设置的是高亮背景色,对应到 RTF 分别是颜色表里的\cfN和高亮控制字。
rtb.SelectionColor = Color.FromArgb(0xCC, 0x00, 0x00); // 深红色文字 rtb.SelectionBackColor = Color.FromArgb(0xFF, 0xFF, 0xE0); // 淡黄色高亮这两行代码在没有选区时,效果是“设置接下来输入文字的颜色”,也就是说光标闪烁处后续键入的字符会带有这个颜色。这其实是编辑器最需要的行为:用户在工具栏选了红色,期望接下来打的字就是红的。但问题也会出现在这:如果用户选了红色,然后移动光标到别的位置继续打字,颜色不会自动取消,因为他没把颜色切回黑色。这不是 bug,但需要你在产品逻辑上决定,要不要在光标位置变化时重置颜色。很多商业编辑器选择保留,我一般也保留,因为用户更讨厌选好的颜色突然消失。
SelectionBackColor在 RTF 里的表现还牵扯到\highlightN与字符背景的差异,RichTextBox 默认用的是高亮属性,而在 Word 的 RTF 里,字符背景色还可以通过颜色表实现。这两者显示效果接近,但语义不同。如果你做的编辑器要跟 Word 交换文件,建议只依赖 RichTextBox 自己的属性,不要去手动改 RTF 里的颜色映射,否则往返一次容易出现背景色丢失。
3.3 段落格式:列表、缩进与对齐在 RTF 里对应的控制字
段落格式跟字符格式在 RTF 里是两套系统。字符格式写在运行块内,段落格式则挂在整个段落上,以\pard开始,\par结尾。RichTextBox 把这一层封装成了几个属性:SelectionAlignment、SelectionBullet、SelectionIndent和SelectionHangingIndent。
rtb.SelectionBullet = true; // 圆点列表 rtb.SelectionIndent = 20; // 左缩进 rtb.SelectionHangingIndent = -10; // 悬挂缩进 rtb.SelectionAlignment = HorizontalAlignment.Center; // 居中对齐SelectionBullet生成的只是简单的圆点符号,不会自动编号,也没有多级列表。如果你需要的是一份可以自增编号的正式文档,RichTextBox 原生的SelectionBullet不够用,常见做法是自己在文档里插入“1.”“2.”这样的序号文本,并保持段落格式一致。SelectionIndent的单位是像素,但在 RTF 代码里会被换算成\liN,N 的单位是缇(twip)。像素到缇的换算受当前显示 DPI 影响,在 96 DPI 下 1 像素约等于 15 缇,所以同一个缩进值在不同分辨率机器上保存出的 RTF 会有微小差异,但通常不影响阅读。
下面这张表是调试时对照用的,当你在文件里看到这些控制字,就能知道 RichTextBox 对你的操作做了什么:
| 操作 | RTF 控制字 | 说明 |
|---|---|---|
| 加粗 | \b/\b0 | 粗体开关 |
| 斜体 | \i/\i0 | 斜体开关 |
| 下划线 | \ul/\ul0 | 下划线开关 |
| 前景色 | \cfN | N 是颜色表索引 |
| 背景高亮 | \highlightN | N 是高亮色索引 |
| 居中对齐 | \qc | qc 即 center |
| 左缩进 | \liN | N 单位是缇 |
| 悬挂缩进 | \fiN | 首行缩进 |
| 圆点列表 | \bullet | 配合段落使用 |
SelectionHangingIndent设置的是负数时,表现是段落首行向左突出,这在“首行缩进、其余行对齐”的排版里很有用。实现首行缩进两字符的常见做法是SelectionIndent = 0且SelectionHangingIndent = 首行缩进量,反之每种搭配都会改变段落观感。建议把这些参数在界面上做成可调项,别写死在代码里。
3.4 混合格式黑匣子:一次操作把相邻文本样式改没了的排查方法
RichTextBox 内部维护着一棵运行块结构树,每次选区操作都有可能触发运行块的切分与合并。切分是好理解的,比如一个整段黑色文字,你把中间几个字变红,控件会把这段切成三段。合并则容易引发困惑:你把红色文字的一段变回黑色,控件可能把它与相邻的黑色运行块合并,而这个结果会覆盖掉原本细微的格式边界。
我遇到的一个典型情况是:用户选中一段文字调整字号后,相邻段落的字距和行距发生了肉眼可见的变化。原因就是SelectionFont赋值时,控件把选区边界的运行块重写了,边界上原本的字体属性被当前字体整体覆盖。另一个常见问题是:选区包含了段落末尾的换行符,这时设置的字体会被应用到整个段落,结果段落里所有文字的字号都变了。
排查这类问题,最可靠的手段是把操作前后的 Rtf 字符串存下来做比对。
string before = rtb.Rtf; // 操作前先存一份 rtb.SelectionFont = new Font("Arial", 12f); string after = rtb.Rtf;用文本比对工具去看\par附近控制字的变化,能非常直观地看出边界被谁影响了。如果发现格式丢失,直接还原备份再换一种操作方式:
try { rtb.SelectionFont = new Font("Arial", 12f); } catch { rtb.Rtf = before; // 后悔药:回到操作前 }这个“先备份再操作”的习惯,在开发编辑器时应当贯穿始终。RTF 字符串本身就是纯文本,这种对格式的“事后回滚”并不昂贵,能省去大量在 UI 黑匣子里猜原因的时间。我几乎每个按钮事件里都会先存一份Rtf字符串,不是为性能,而是为查问题时有据可依。
4. 保存与加载闭环:文件签名、三种 SaveFile 流类型与中文编码
4.1 打开文件先看签名:RTF 判定与 BOM 处理
上一章里已经提到了签名判断。这里把逻辑补完整,因为文件加载的很多怪问题都出在“文件是不是 RTF”这一步。RTF 文件的第一行以{\rtf1开头,版本号可能大于 1,所以判断时匹配{\\rtf前缀就可以。最容易被忽略的是 BOM。
private static bool LooksLikeRtf(byte[] header) { int offset = 0; // 跳过 UTF-8 BOM if (header.Length >= 3 && header[0] == 0xEF && header[1] == 0xBB && header[2] == 0xBF) offset = 3; if (header.Length <= offset + 5) return false; return Encoding.ASCII.GetString(header, offset, 5) == "{\\rtf"; }处理完 BOM 之后才算得了准确。另一个细节是 ANSI 编码的 RTF 文件,内部以\ansicpgN声明代码页,N 可能是 936(简体中文)、1252(西欧)、950(繁体中文)。RichTextBox 的LoadFile拿到文件后,自己会处理代码页映射,不需要你手动转码。但如果你的文件是从网上抓下来的文本,带着\rtf1字样但实际是普通文本,签名判断会把它当成 RTF 载入,结果界面显示出一堆控制字。这种情况要把加载逻辑改成“先尝试 RTF,失败后回退纯文本”。
try { rtb.LoadFile(path, RichTextBoxStreamType.RichText); } catch (ArgumentException) { rtb.LoadFile(path, RichTextBoxStreamType.PlainText); }LoadFile在解析失败时会抛ArgumentException,而不是FormatException,这个异常类型要注意,很多人按格式异常去接,结果漏掉了真正错误。捕获之后再按纯文本加载,至少能保证文档被打开,内容不丢失。
4.2 SaveFile 的三种导出:RichText、UnicodeRichText 与 PlainText 的适用边界
保存文件的选择,直接决定了用户拿到的文件到哪里都能打开还是只有你能打开。RichTextBox.SaveFile 提供了三种流类型,区别在底层序列化方式:
| 流类型 | 行为 | 适用边界 |
|---|---|---|
| RichText | 输出标准 RTF 1.x,非 ASCII 字符用\uN转义,文件体积小 | 兼容性最好的默认选项 |
| UnicodeRichText | 以 UTF-16 形式输出 RTF 内容,控制字仍保持 ASCII | 在非中文系统上避免乱码的首选 |
| PlainText | 丢弃全部格式,只保留文字内容 | 降级导出,或者另存为 txt 时用 |
using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write)) { rtb.SaveFile(fs, RichTextBoxStreamType.RichText); }参数上,RichText保存时会按当前系统 ANSI 代码页生成\ansicpg,中文和非 ASCII 字符会被转成\uN形式。UnicodeRichText则直接用宽字符存储,老版本第三方 RTF 解析器可能不认这种变体,但如果目标机器都是现代 Windows 系统,我通常优先选择 UnicodeRichText,因为它绕开代码页错位这个最大的乱码来源。
PlainText是最容易产生误导的一项。它的名字听起来像“保留换行的纯文本”,但实际上它把段落符转换成了系统的换行符,并且丢弃所有项目符号、缩进和列表结构。如果用户依赖 PlainText 做数据交换,请务必在界面上标注“格式会丢失”的提示,否则用户辛辛苦苦排版完,另存为 txt 发现排版没了,这个责任在开发方。
4.3 中文乱码根源:\ansicpg、\u 转义与代码页的三角关系
中文乱码在 RTF 里从来不是“某一行代码写错”导致的,而是三个层面叠加的结果。第一层是文件头里\ansicpg声明的代码页。第二层是内容里的\uN转义值。第三层是系统当前 ANSI 代码页。RichTextBox 读取文件时,先看代码页,再按代码页把每个字节映射成字符,遇到\uN则直接取 N 对应的 Unicode 码点。
在英文系统上打开一份简体中文 RTF,最常见的结果就是全部汉字变成问号。原因是文件头写的是\ansicpg936,但英文系统的 ANSI 代码页是 1252,控件尝试用 1252 去解码 GBK 字节流,自然全错。RichTextBox 的LoadFile在内部会尽量尊重文件头声明,但如果你用StreamReader自己读文件再赋值给.Rtf属性,编码就得完全由你自己控制,一旦错位就是满屏乱码。
string rtfText = File.ReadAllText(path, Encoding.GetEncoding(936)); rtb.Rtf = rtfText;上面这行手动设置.Rtf的方式,要求字符串本身就是合法的 RTF 代码。如果你用File.ReadAllText读回来的内容恰好是 UTF-8 编码的 RTF,但指定了 936 来解,同样会乱。稳妥的做法是能用LoadFile就别手动读,实在要手动读,就用StreamReader检测 BOM,没有 BOM 时按 UTF-8 读取,因为现代文本基本都往 UTF-8 靠拢。
注意:
.Rtf属性赋值时,RichTextBox 会直接替换整个文档内容,并且清空撤销栈。所以不要用一个“加载”操作去实现“插入”,否则用户会失去撤销历史。
4.4 绕过 RichTextBox 的模板化 RTF 生成器:参数与转义规则
有些场景不需要用户交互,只需要按模板生成一份固定格式的 RTF 文档,比如给每个产品生成一份带标题和加粗价格说明的文档。这时候常见做法是直接手写 RTF 字符串,控件只负责展示。这个方案完全可行,但有两个规则必须记住:大括号、反斜杠必须转义;换行必须用\par而不是\n。
string productName = "智能门锁X1"; string price = "1999"; StringBuilder sb = new StringBuilder(); sb.Append(@"{\rtf1\ansi\ansicpg936\deff0"); sb.Append(@"{\fonttbl{\f0 Microsoft YaHei;}}"); sb.Append(@"{\colortbl;\red255\green0\blue0;}"); sb.Append(@"\fs24 产品名称:"); sb.Append(productName.Replace("\\", "\\\\").Replace("{", "\\{").Replace("}", "\\}")); sb.Append(@"\par \b 价格:\b0 "); sb.Append(price); sb.Append(@" 元"); sb.Append(@"\cf1 (限时优惠)\cf0 "); sb.Append(@"\par }"); rtb.Rtf = sb.ToString();代码里的productName.Replace是把文本中的大括号和反斜杠转成 RTF 的转义形式,否则内容里出现{会让解析器认为开启了新组。\fs24是 12pt 字号,因为前面提过半点的换算关系。\cf1引用颜色表里索引 1 的颜色,索引 0 始终是黑色自动色,不需要显式声明。\b0表示关闭粗体,RTF 的控制字是开关式设计,不关闭会一路粗到段尾。
用 StringBuilder 生成模板时,特别注意顺序:字体表\fonttbl和颜色表\colortbl必须出现在第一次引用它们之前,否则解析器会忽略引用。生成完以后把这串文本直接赋给rtb.Rtf就能预览,我也习惯在生成后立刻调用一次rtb.SaveFile到临时文件,然后重新加载回来验证往返是否一致。这一步能揪出大部分手写 RTF 的结构问题。
5. RTF 编辑器避坑:图片体积、撤销栈、字体表与纯文本导出
5.1 粘贴图片后文件暴涨几 MB:\pict 二进制块与图片格式真相
现象:用户从浏览器复制一张截图,粘贴到编辑器里,原本 10KB 的 RTF 文件保存后变成 5MB。
原因:RichTextBox 粘贴图片时走的是剪贴板默认格式。剪贴板里同时存在位图格式(DIB)、PNG、文件等多种数据,控件优先取它最容易接管的格式,通常是 DIB 位图。DIB 是无压缩的,一张 1920x1080 的截图,DIB 体积约 8MB,RTF 再以十六进制文本形式把它编码进\pict块,体积又膨胀两倍。而且图片每粘贴一次,就会以一份完整副本进入文档,没有去重机制。所以一个 10KB 的文档只要贴两张图,就直奔 20MB。
解决:在粘贴前把剪贴板里的图片统一转成 PNG,再重新放回剪贴板,然后再让 RichTextBox 执行粘贴。
private void PasteImageAsPng(RichTextBox rtb) { if (!Clipboard.ContainsImage()) return; using (var bmp = Clipboard.GetImage()) using (var ms = new MemoryStream()) { bmp.Save(ms, ImageFormat.Png); byte[] pngBytes = ms.ToArray(); var data = new DataObject(); data.SetData(DataFormats.Bitmap, bmp); // 兜底,兼顾老格式 data.SetData(DataFormats.Png, pngBytes); // 主格式,压缩体积 Clipboard.SetDataObject(data, true); rtb.Paste(); } }DataFormats.Png在 WinForms 中可用的,RichTextBox 的 RichEdit 底层能识别 PNG 格式并写入 RTF 的\pict\pngblip块。体积会从“位图原样”降为“按 PNG 压缩后的大小”,一屏截图大概几十 KB,整个文档体积能回到正常水平。但要注意,粘贴进去的图片如果被用户双击编辑,RichTextBox 内部可能又会把它转回 DIB 保存,导致退出编辑时体积再次暴涨。遇到这种情况,需要监听Protected事件或定时检查 Rtf 里的\pict\dibitmap标记,在保存前统一替换。
5.2 撤销栈说没就没:设 Rtf 属性为什么会清空废除记录
现象:用户做了几步操作,然后你的代码对.Rtf属性做了一次整体赋值,这时按 Ctrl+Z 没有一点点反应,甚至更糟,按一次撤销直接跳回到改 Rtf 之前的状态。
原因:RichTextBox 的撤销机制是建立在内部编辑操作日志上的。用户键入一个字符、应用一次加粗,会形成一条可撤销记录。而.Rtf属性赋值等价于“用一整份新的 RTF 文档替换当前文档”,这是一个原子操作,之前的编辑日志全被清空,新文档作为新的起点。如果你的业务逻辑需要频繁读写.Rtf(比如自动保存、格式化回滚、模板替换),用户会明显感觉撤销键失灵。
解决:能用属性完成的修改就不要碰.Rtf。加粗、变色、缩进都走Selection*系列属性,它们产生的操作会进入撤销栈。只有需要整体替换内容时才赋值.Rtf,并且提前告知用户撤销历史会清空。我在内部工具里给这个行为加了一个确认对话框:如果用户撤销栈不为空,执行整体替换前会提示“此操作会清空撤销记录”。
另外,.Rtf赋值后,光标位置会被重置到文档开头。即使你想做“加载后回到之前光标位置”,也需要在赋值前记录SelectionStart,赋值后恢复:
int pos = rtb.SelectionStart; rtb.Rtf = newContent; rtb.SelectionStart = Math.Min(pos, rtb.TextLength);这只是个补丁,救不了撤销栈。所以如果你的编辑器要做“撤销多级回滚”,最好的方案还是别滥用.Rtf整体赋值。
5.3 目标机器上字体错乱:字体表与主题数据的兼容边界
现象:在一台机器上编辑好的 RTF,复制到另一台机器打开,标题字体变成了宋体,字号也变了。
原因:RTF 文档里的字体表\fonttbl只记录字体名称,不嵌入字体文件。目标机器如果没有安装同名同族字体,系统会按字体映射规则替换成默认字体。这在中文和英文字体混排时尤其明显:一台装了微软雅黑的机器和一个只装了宋体的机器,对同一份 RTF 的渲染结果可以差得很远。更隐蔽的是 Word 生成的 RTF 里带有\themedata、\xmlnstbl等私有组,这些内容 RichTextBox 解析时会跳过,导致主题字体信息丢失,规则又被变回“按名称映射”。
解决:首先确认编辑器使用的字体在目标环境一定存在。企业内部分发工具时,最常见做法是随安装包带上字体,或者直接只使用系统自带字体如宋体、黑体、Arial。其次,针对 Word 生成的 RTF,加载时尽量用 RichTextBox 原生的LoadFile,它会丢弃不认识的私有组,但至少不会报错。另外,在工具栏做字号和字体的下拉框时,把当前机器不存在的字体从列表里过滤掉,可以减少“选了个字体,发给别人就变了”的问题。
如果文档要跨平台交换,字体问题的根本解是转 PDF 预览或嵌入字体,但那不是 RTF 本身的能力,已经超出编辑器范围了。在 RTF 范围内能做到的只是:保证所有字体名在目标机上可解析,并在文档属性里留一份字体回退策略。
5.4 保存成纯文本后换行连成一片:PlainText 不保留段落结构的血泪
现象:用户把文档另存为 txt,打开后发现原本有换行的地方全挤在一起,或者换行变成了奇怪的符号。
原因:RichTextBox 内部段落结构用\par表示,段落结束符在用户眼里就是一次回车。SaveFile(path, RichTextBoxStreamType.PlainText)会把\par转换为当前平台的换行符,这个转换在大多数情况下是可靠的。但如果你用的是手动把rtb.Text写入文件的方式,可能被坑:rtb.Text里的换行是\r\n,而段落结构被简化成了普通换行,某些场景下还会混入\v(垂直制表符)等特殊控制字符。于是输出到文本文件里要么连行,要么出现不可见字符干扰排版。
解决:如果目标是 txt,直接调用SaveFile的PlainText模式,不要自己读rtb.Text写文件。如果要做导出预览,先拿到文本内容,再做一次换行规范化:
string plain = rtb.Text; plain = plain.Replace("\r\n", "\n").Replace("\r", "\n"); File.WriteAllText(txtPath, plain, Encoding.UTF8);这里把 Windows 风格的\r\n先统一成\n,再用 UTF-8 写文件,导出的 txt 在任何编辑器里都能正常显示。需要留意的是,rtb.Text会丢失图片和格式,如果文档里有表格,表格内容也会被拼成普通文本行。这些损失不可能通过参数弥补,只能建议用户在导出前确认是在做“内容导出”而不是“格式导出”。
6. 再往前一步:把编辑器做成能打印、能导出、能查找替换的工具
到这里,一个 Rtf 文档编辑器已经能完成新建、编辑、格式化、保存的闭环。但离“能用”还差三个高频操作:打印、导出 HTML、查找替换。这三个功能都不复杂,但都有各自的细节。
打印方面,RichTextBox 自身就带打印能力,它内部通过发送EM_FORMATRANGE消息让 RichEdit 自己完成分页和绘制。不要把rtb.Text拿出来用Graphics.DrawString去画,那样会丢失全部格式。直接调用rtb.Print()就能弹出系统打印对话框并输出带格式的文档。如果想自定义纸张和边距,需要拿到PrintDocument事件去处理,但大部分内部工具用系统默认就够了。
查找替换应该基于rtb.Find做循环。
int index = 0; while ((index = rtb.Find(searchText, index, RichTextBoxFinds.None)) != -1) { rtb.Select(index, searchText.Length); rtb.SelectedText = replaceText; index += replaceText.Length; }Find返回命中位置,替换后重新从替换文本末尾继续查,避免死循环。注意SelectedText替换会保留选区原有的段落格式,如果替换文本里带换行符,会拉长段落结构,查找结果就偏了,所以建议先限制替换文本不得包含\par。
RTF 转 HTML 是很多工具都需要的导出能力。最轻量的做法是做控制字映射:
| RTF 控制字 | HTML 标签 | 说明 |
|---|---|---|
\b | <strong> | 加粗 |
\i | <em> | 斜体 |
\ul | <u> | 下划线 |
\par | <p>或<br> | 段落边界 |
\cfN | color样式 | 需要查颜色表 |
这种映射只能覆盖基础格式,表格、图片、嵌套段落都会让手写转换器失控,所以我的习惯是:转 HTML 只作为导出预览功能,不承诺与 RTF 双向往返。真要完整转换,优先用商业组件或者干脆引导用户另存为 PDF。
做了这么多年 C# 桌面工具,我养成了一个习惯:每次给用户加新功能之前,先拿这段 RTF 做一次“保存后再加载”的往返测试,确认格式没有丢,再继续往下写。编辑器这个东西,功能再多,文档存坏了就是灾难。希望这个从选型到避坑的路线,能帮你在 RTF 文档编辑器这个方向上少走几次弯路,希望帮到你。
本文还有配套的精品资源,点击获取