ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C#开发RTF编辑器实战:基于RichTextBox的富文本处理与坑点解析

C#开发RTF编辑器实战:基于RichTextBox的富文本处理与坑点解析 简介这是一份面向C#初学者与Windows桌面应用开发者的RTF文档编辑器实战项目解决富文本编辑功能快速入门与文件I/O集成问题适用于课程设计、毕业设计或.NET基础技能巩固场景。压缩包共37个文件90KB包含8个核心C#源码文件含FormChd.cs、FormPar.cs、Program.cs等、1个Visual Studio解决方案文件BigHomework.sln、3个资源文件.resx、9张UI图标如save_as.png、cut.png、disk.png等以及编译输出所需的exe、pdb和配置文件完整呈现了基于Windows Forms与RichTextBox控件的RTF读写逻辑。已有343人学习下载读者可直接运行调试可执行程序深入理解RichTextBox.Rtf属性操作、File类文件存取、菜单与按钮事件绑定、多窗体交互及项目资源管理机制代码附有中文注释目录结构清晰便于按模块追踪打开/保存/另存为等核心功能实现路径。用C#从零写一个RTF文档编辑器我踩过的那些坑和最终方案做上位机开发这些年我一直有个习惯凡是项目里要用到的通用工具尽量自己写一遍。倒不是为了重复造轮子而是只有自己动手实现过才知道组件底层到底做了什么、边界在哪里。这次要说的就是我用C#实现的一个RTF文档编辑器。起因是手里一个设备管理项目需要生成带格式的巡检报告客户希望导出成Word能直接打开的文档又指定不能用Office COM组件原因大家都懂服务器上装Office授权贵、稳定性差、并发还容易挂。于是我把目光投向了RTF——这个老牌但依然能打的富文本格式然后一步步在WinForms里搭出了一个可用的RTF编辑器。如果你也在纠结类似的问题怎么在C#里做一个轻量级的富文本编辑器、怎么处理RTF和纯文本的互转、怎么让RichTextBox发挥最大价值而不要被它坑死——这篇文章值得你花十分钟读完。1. 为什么选RTF而不是HTML或docx一次选型分析先回答一个没法绕开的问题既然现在HTML这么普及、docx这么成熟为什么还要用RTF做编辑器1.1 RTF的历史地位和适用场景RTF全称Rich Text Format是微软在1987年推出的跨平台文档格式。它最核心的特点是纯文本文件带控制字和分组结构用记事本打开也能看到内容。这意味着RTF文件天生具备极强的兼容性——Windows自带的写字板、Word、WPS、LibreOffice统统支持而且不需要任何SDK和运行时只要是带文本编辑器的地方就能解析。在C#上位机、工业软件场景里RTF有一个非常大的优势RTF是RichTextBox的本地格式。Windows Forms的RichTextBox控件原生支持RTF读写你用两种公开API就能搞定大部分需求richTextBox1.Rtf {\\rtf1\\ansi 你好}; string rtfContent richTextBox1.Rtf;就这两行一个能编辑、能设置字体颜色、能加粗斜体的富文本编辑器就成型了。相比HTMLRichTextBox虽然也支持但转换时标签映射极其别扭很多CSS属性会丢和docx本质是ZIPXML手工解析得写多少代码RTF的性价比高得离谱。1.2 与docx/HTML的对比我整理了一张表方便你直观感受三者的区别维度RTFHTMLDOCX底层格式纯文本控制字纯文本标签ZIPXMLC#原生支持RichTextBox原生有WebBrowser但不好用需OpenXML SDK文件大小小小较大兼容性Word/WPS/写字板浏览器/OfficeWord/WPS格式还原度高字体/颜色/表格中CSS映射丢失高依赖环境系统自带系统自带需要SDK或COM选型结论也很明确如果你的目标是“做一个能编辑富文本、导出到Word/WPS能正常打开、不依赖第三方组件”的桌面工具RTF是当前最稳的路线。它不新潮但它可靠特别是在工业软件这种“稳定压倒一切”的领域老技术反而是最安全的选择。2. 编辑器核心模块设计从需求到落地的结构拆分确定了技术路线接下来要正式做设计了。一套完整的RTF编辑器的功能需求通常包括文本编辑插入、删除、选中、复制粘贴字体属性设置字体、字号、加粗、斜体、下划线、颜色、高亮段落属性设置对齐方式、缩进、行距、项目符号/编号查找替换普通文本查找、RTF感知的格式查找这里先做普通的文件操作新建、打开、保存RTF文件、导出纯文本打印与打印预览撤销重做RichTextBox自带了一部分但我们需要封装其他扩展插入图片、表格、超链接、特殊符号为了不让代码变成一个大泥球我按职责把编辑器拆成了这么几层UI层MainForm和工具栏只负责用户交互不处理具体格式化逻辑。编辑器核心层封装RichTextBox的Rtf属性、SelectionFont、SelectionColor、SelectionAlignment等对外提供FormatText操作。文件服务层负责RTF的存取、文件对话框逻辑、编码处理。查找替换服务独立类通过TextRange和RichTextBox的Select方法完成。打印服务通过PrintDocument打印RTF内容。这个分层最大的好处是以后想换成WPF、或者把编辑器嵌入到其他项目只需要替换UI层和编辑器核心层之间的桥接业务逻辑不用重写。2.1 窗体布局与工具栏搭建我用的WinForms主窗体结构是这样的private void InitializeEditorLayout() { // 顶部工具栏文件操作 格式操作 ToolStrip toolStrip new ToolStrip(); toolStrip.Items.Add(new ToolStripButton(打开)); toolStrip.Items.Add(new ToolStripButton(保存)); toolStrip.Items.Add(new ToolStripSeparator()); toolStrip.Items.Add(new ToolStripButton(加粗)); toolStrip.Items.Add(new ToolStripButton(斜体)); toolStrip.Items.Add(new ToolStripButton(下划线)); toolStrip.Items.Add(new ToolStripSeparator()); toolStrip.Items.Add(new ToolStripButton(左对齐)); toolStrip.Items.Add(new ToolStripButton(居中)); toolStrip.Items.Add(new ToolStripButton(右对齐)); // 字体选择区 ComboBox fontCombo new ComboBox(); fontCombo.DropDownStyle ComboBoxStyle.DropDownList; foreach (FontFamily family in FontFamily.Families) fontCombo.Items.Add(family.Name); ComboBox sizeCombo new ComboBox(); sizeCombo.Items.AddRange(new object[] { 8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72 }); // 正文编辑区 RichTextBox richTextBox new RichTextBox(); richTextBox.Dock DockStyle.Fill; richTextBox.AcceptsTab true; richTextBox.DetectUrls true; richTextBox.ShortcutsEnabled true; richTextBox.AllowDrop true; }字体下拉框直接遍历FontFamily.Families省去了手写字体列表的麻烦。字号下拉框直接给常用值但需要允许用户手动输入一个数值后续处理要加上Validating事件来校验。这里有个小技巧AcceptsTabtrue可以让Tab键在编辑器内输入制表符而不是切换焦点对于写代码片段类的文档很有用。但如果你希望Tab键在多个控件间跳转那需要动态切换。2.2 格式操作为什么不用SelectionFont直接改很多教程会告诉你加粗就写成richTextBox1.SelectionFont new Font(richTextBox1.SelectionFont, FontStyle.Bold);这句话看起来没毛病但实际用起来有个坑如果光标处没有选中文本SelectionFont改变的是“输入状态”的字体而不是“当前段落”的字体。如果光标在段落中间你按了加粗按钮然后继续打字你会发现新输入的文字不是加粗的因为SelectionFont只影响了插入点之后的内容而插入点前的文字不会变。更麻烦的是如果选中的文本包含多种字体SelectionFont只取第一个字符的字体属性其他格式会丢失。如果你选中了一部分宋体、一部分黑体点加粗后所有文字都变成“第一个字符的字体加粗”。我的做法是使用SelectionFont和SelectionColor组合处理并且通过SelectionFont.GetStyle()来判断当前是否已加粗这样能正确区分“当前状态下按加粗是加粗还是取消加粗”。public void ToggleBold() { Font currentFont _richTextBox.SelectionFont; if (currentFont null) { // 选区为空时读取当前输入状态的字体 currentFont _richTextBox.Font; } FontStyle newStyle currentFont.Style ^ FontStyle.Bold; _richTextBox.SelectionFont new Font(currentFont, newStyle); }用异或操作^来切换比判断是否包含Bold再构造一个新style要简洁而且逻辑天然正确。斜体、下划线、删除线都用同样的模式只是换掉FontStyle枚举。2.3 字体大小和颜色设置的细节字号设置这块RTF里的字号单位是“半点”half-point而WinForms里的Font.Size单位是点point。1点2半点。直接设置SelectionFont的Size会以点为单位RTF保存时会自动转换这个可以不管。但需要注意如果你用SelectionFont.Size去显示当前字号一定要做取整和容错因为当光标处有不同字号时SelectionFont可能是null。颜色设置相对简单private void ApplyForeColor(Color color) { _richTextBox.SelectionColor color; // 高亮颜色 // _richTextBox.SelectionBackColor color; }注意如果是Unicode字符RTF会自动转成\uN?格式颜色信息不会丢。字体颜色和背景色高亮是两个不同的属性工具栏上通常放两个颜色按钮。3. 文件读写与字符编码RTF解析中容易翻车的区域文件这块看着简单实际上有不少坑。首先明白一点RichTextBox.Rtf属性返回的字符串是包含RTF控制字的完整RTF源码。保存的时候你只需要把这个字符串写入文件即可。但打开文件时要注意不能直接加载整个文本再赋值给Rtf因为RTF的\uN?格式对中文采用了Unicode转义如果文件编码不对控制字解析就会错乱。3.1 保存RTF文件的标准做法public void SaveRtfFile(string filePath) { using (FileStream fs new FileStream(filePath, FileMode.Create, FileAccess.Write)) using (StreamWriter sw new StreamWriter(fs, Encoding.ASCII)) { sw.Write(_richTextBox.Rtf); } }RTF文件本身默认是ASCII编码的控制字包含中文时通过\u转义所以保存用ASCII编码是标准做法。如果改用UTF-8编码可能引入BOM头部分老旧Word版本会报错。实测下来用Encoding.ASCII最稳。3.2 打开RTF文件的异常处理打开文件的代码是这样public bool OpenRtfFile(string filePath) { try { using (FileStream fs new FileStream(filePath, FileMode.Open, FileAccess.Read)) using (StreamReader sr new StreamReader(fs, Encoding.ASCII)) { string rtfSource sr.ReadToEnd(); _richTextBox.Rtf rtfSource; return true; } } catch (ArgumentException ex) { // Rtf格式无效时RichTextBox会抛出ArgumentException MessageBox.Show(无法打开该文件它可能不是有效的RTF文档。, 打开失败); return false; } catch (IOException ex) { MessageBox.Show(文件读取错误 ex.Message, 打开失败); return false; } }这里最关键的坑是_richTextBox.Rtf rtfSource;这句。如果文件内容不是合法的RTF控件会直接抛ArgumentException。如果你不做try-catch程序直接崩掉。我遇到过一个用户上传了一个.txt文档里面是聊天记录结果打开失败弹了一堆堆栈。所以这个异常处理必须写而且要给用户友好的提示。3.3 编码探测拿到一个未知文件怎么处理现实场景很复杂用户给你的可能不是标准RTF而是ANSI编码的中文RTF或者UTF-8编码的RTF。怎么处理我目前的做法是先尝试用Encoding.ASCII解析赋值给Rtf。如果失败改用Encoding.Default系统ANSI再试。如果还失败用Encoding.UTF8再试。private bool TryLoadRtf(string filePath, out string error) { error null; Encoding[] encodings new[] { Encoding.ASCII, Encoding.Default, Encoding.UTF8 }; foreach (var enc in encodings) { try { string rtfSource; using (StreamReader sr new StreamReader(filePath, enc, true)) rtfSource sr.ReadToEnd(); _richTextBox.Rtf rtfSource; return true; } catch (Exception ex) { error ex.Message; } } return false; }这里有个要注意的点用Encoding.Default在.NET Framework里是系统ANSI代码页中文系统上是GBK但.NET Core里默认是UTF-8。如果你做的是.NET Core的WinForms程序需要显式用Encoding.GetEncoding(GBK)。3.4 保存纯文本和导出HTML虽然编辑器是RTF的但用户经常需要纯文本版本。直接用_richTextBox.Text拿到的就是纯文本内容这个是最简单的。导出HTML我建议走一次中间转换先拿到RTF源码然后用Word对象做转换如果你能接受COM或者用第三方库。不过这里有个更简洁的思路RichTextBox.Text属性本身已经是纯文本做HTML导出时手动包一层标签就够。但如果要保留格式就别用Text还是要解析RTF——工作量瞬间上来所以我在工具里只做“导出纯文本”和“导出RTF”HTML导出做不做看需求。4. 查找替换实现不只是循环找字符串查找替换看起来是编辑器里最简单的功能但当你面对RichTextBox时事情没那么简单。4.1 为什么不能直接用Text.Substring找位置很多初学者会这样写拿到richTextBox.Text用IndexOf找到位置然后用Select(start, length)选中设置SelectionFont实现高亮。这思路对ASCII文本没问题但一旦文本里包含中文Index的字符位置和RichTextBox内部的字符位置会对不上。原因是RichTextBox处理的是UTF-16文本单元如果你用Text属性拿字符串再用字符串的IndexOf选区的起点其实是字符索引应该能用。但更坑的地方是RTF源码中的特殊控制字\uN?对应多个RTF字段而Text属性是连续字符串。你通过Text找到的位置确实对应于控件显示文本的位置但当你去设置SelectionColor时控件内部需要把显示位置转换为RTF源码偏移。这个过程有各种边界条件特别是在嵌套分组、字体表、颜色表的情况下很容易出现“选中的范围偏了”。我实际测试下来常规文本的Select没毛病但如果你在文本中混入了OLE对象、图片或者其他特殊内容Text属性会跳过这些对象导致索引错位。所以稳妥的方案是在查找前先把整个文档的RTF源码解析成纯文本再通过映射关系来定位。这个方案工作量大但为了健壮性值得做。4.2 一个够用的查找替换实现先看一下我实际采用的折中方案基于RichTextBox.Text和Selectpublic int FindNext(string keyword, bool matchCase, bool matchWholeWord) { int startIndex _richTextBox.SelectionStart _richTextBox.SelectionLength; if (startIndex _richTextBox.TextLength) startIndex 0; StringComparison sc matchCase ? StringComparison.CurrentCulture : StringComparison.OrdinalIgnoreCase; int foundIndex _richTextBox.Text.IndexOf(keyword, startIndex, sc); if (foundIndex 0) { // 从头再查一遍实现循环查找 foundIndex _richTextBox.Text.IndexOf(keyword, 0, sc); } if (foundIndex 0) { _richTextBox.Select(foundIndex, keyword.Length); _richTextBox.ScrollToCaret(); // 让查找高亮可见改变选中背景色 _richTextBox.SelectionBackColor Color.Yellow; return foundIndex; } return -1; }整词匹配matchWholeWord这里需要额外判断边界字符private bool IsWholeWordMatch(string text, int index, int length) { bool leftOk index 0 || !char.IsLetterOrDigit(text[index - 1]); bool rightOk index length text.Length || !char.IsLetterOrDigit(text[index length]); return leftOk rightOk; }替换逻辑则简单一些用SelectedText属性替换public void ReplaceSelected(string replacement) { if (_richTextBox.SelectionLength 0) { _richTextBox.SelectedText replacement; } } public void ReplaceAll(string keyword, string replacement, bool matchCase) { // 从起始位置开始循环替换注意替换后索引要回退一位 int index 0; StringComparison sc matchCase ? StringComparison.CurrentCulture : StringComparison.OrdinalIgnoreCase; while ((index _richTextBox.Text.IndexOf(keyword, index, sc)) 0) { _richTextBox.Select(index, keyword.Length); _richTextBox.SelectedText replacement; index replacement.Length; } }这里有个经典的坑替换后_richTextBox.Text会变化原来的索引可能不再有效所以index要更新为index replacement.Length而不是跳过一个keyword长度否则遇到替换内容本身包含keyword的情况会无限循环。4.3 查找时误触Selection的视觉反馈问题查找时我把选中区域背景设为黄色但这样会覆盖用户原本的选中状态。如果用户想继续编辑需要再点一下。更好的方案是用独立的只读TextRange类来管理高亮而不直接动Selection。但RichTextBox本身没有“临时高亮”的机制所以我在实际项目中就采用了“改SelectionBackColor并在失焦或再次查找时恢复”的方式这个可以接受。你也可以通过自定义Renderer或NativeWindow子类画高亮但那是高级玩法了。5. 撤销重做与历史管理不要完全依赖RichTextBox自带UndoRichTextBox自带Undo默认支持CtrlZ。但它的Undo有几个明显问题一次格式化操作会被拆成很多小步骤。比如你选中整篇文字设置字体Undo会一步一步倒退到最初而不是一步撤销。输入操作和格式化操作混在一起。用户输入了10个字然后点一次撤销正常情况下应该全部撤销但RichTextBox可能只撤销一个字符。Undo在Rtf赋值后会被清空。一旦你程序里设置richTextBox.Rtf或TextBox.TextUndo栈就清空了用户想撤销打开文件操作是不行的。所以我决定自己管理“操作历史”的粗粒度撤销而不是依赖控件自身的Undo。我的方案是在关键操作前保存一份RTF快照操作完成后记录到栈中。private Stackstring _undoStack new Stackstring(); private Stackstring _redoStack new Stackstring(); private void PushUndoSnapshot() { _undoStack.Push(_richTextBox.Rtf); // 限制栈的深度防止内存爆炸 if (_undoStack.Count 50) { var list _undoStack.ToList(); list.RemoveAt(0); _undoStack new Stackstring(list); } _redoStack.Clear(); } public void Undo() { if (_undoStack.Count 0) return; _redoStack.Push(_richTextBox.Rtf); _richTextBox.Rtf _undoStack.Pop(); } public void Redo() { if (_redoStack.Count 0) return; _undoStack.Push(_richTextBox.Rtf); _richTextBox.Rtf _redoStack.Pop(); }这个方案的核心是“快照”而非“操作”。优点是不管用户做了什么操作只要在操作前Push一次快照撤销时就能恢复完整状态。缺点也很明显整个RTF文本都保存文档大的时候栈会占用很多内存。50层的限制是权衡后的结果存500KB的RTF50层也不到25MB还能接受。但注意TextField的鼠标点击移动光标、选择文本等操作不要Push快照否则用户每点一下鼠标栈里就多一条历史。我通常在工具栏按钮的事件处理函数开头调用PushUndoSnapshot而不是在RichTextBox的TextChanged里。TextChanged太频繁。如果你需要更精细的撤销粒度需要自己实现Command模式每个操作封装成类例如InsertCommand、FormatCommand、ReplaceCommand通过Execute/Undo两个接口调用。那个更复杂但更专业。当前快照方案够用但遇到超大文档还是会有性能问题。6. 图片插入与OLE对象RichTextBox隐藏的坑RTF支持插入图片RichTextBox也提供Paste方法。但实际插入图片的路子比较野。6.1 粘贴剪贴板图片最简单的是用户复制图片到剪贴板然后CtrlV粘贴。RichTextBox默认支持粘贴图片这种方式没问题。但如果你在代码里要用Clipboard.GetImage()然后Paste()需要先确认剪贴板有图片数据private void PasteImageFromClipboard() { if (Clipboard.ContainsImage()) { Image img Clipboard.GetImage(); Clipboard.SetImage(img); _richTextBox.Paste(); } }这里有个细节先把图片从Clipboard.GetImage()取出来再SetImage回去是为了避免直接Paste时剪贴板里还带有文本数据导致RichTextBox优先粘贴文本。实测有效。6.2 从文件插入图片的正确姿势假设你要从文件选择一张PNG插入直接用法是private void InsertImageFromFile(string imagePath) { if (!File.Exists(imagePath)) return; using (Image img Image.FromFile(imagePath)) { Clipboard.SetImage(img); _richTextBox.Paste(); } }但这里有个坑如果你在WinForms的DragDrop中处理RichTextBox默认能接收文件的拖放但它会把文件路径作为文本插入而不是将图片作为图片插入。如果你想自定义拖放文件插入图片需要处理DragEnter和DragDrop事件private void richTextBox1_DragEnter(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(DataFormats.FileDrop)) { string[] files (string[])e.Data.GetData(DataFormats.FileDrop); if (files.Length 0 IsImageFile(files[0])) e.Effect DragDropEffects.Copy; else e.Effect DragDropEffects.None; } else if (e.Data.GetDataPresent(DataFormats.Bitmap)) { e.Effect DragDropEffects.Copy; } } private void richTextBox1_DragDrop(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(DataFormats.FileDrop)) { string[] files (string[])e.Data.GetData(DataFormats.FileDrop); foreach (string file in files) { if (IsImageFile(file)) InsertImageFromFile(file); } } }6.3 图片存储格式导致的大小问题RichTextBox粘贴图片默认会以原始格式嵌入RTF。如果插入的是BMP图片RTF文件会变得很大。如果你是做报告编辑器建议在插入前先把图片压缩成PNG或JPEG再粘贴。这里提供一个简易压缩方法private void InsertImageWithCompression(string imagePath) { using (Image original Image.FromFile(imagePath)) { int maxWidth 800; int maxHeight 600; double ratio Math.Min((double)maxWidth / original.Width, (double)maxHeight / original.Height); ratio Math.Min(ratio, 1.0); // 只压缩过大的图片 int newWidth (int)(original.Width * ratio); int newHeight (int)(original.Height * ratio); Bitmap resized new Bitmap(original, newWidth, newHeight); using (MemoryStream ms new MemoryStream()) { resized.Save(ms, ImageFormat.Png); // 注意Clipboard.SetImage会存储Bitmap格式但实际RTF会转成自己的格式 Clipboard.SetImage(resized); _richTextBox.Paste(); } resized.Dispose(); } }这里压缩图片会让RTF变小读取速度也更快但损失了原图质量。报告场景可以接受。6.4 OLE对象的坑RichTextBox支持嵌入式OLE对象比如嵌入Excel表格、Visio图但直接通过代码创建OLE对象比较麻烦。你需要处理IRichEditOle接口、REOBJECT结构体等。WinForms的RichTextBox没有公开API来做这些所以这块我直接放弃了。如果你真的需要嵌入Excel表格建议考虑用独立的第三方控件或者用DataGridView模拟表格再截图插入图片。别死磕OLE性价比太低。7. 打印与打印预览RTF排版如何原样输出既然做了编辑器用户难免要打印。RichTextBox提供了Print方法但只能走默认打印设置连预览都没有。我的做法是封装一个RichTextBoxPrintHelper。7.1 完整的打印实现public class RtfPrintHelper { private RichTextBox _richTextBox; private PrintDocument _printDocument; public RtfPrintHelper(RichTextBox rtb) { _richTextBox rtb; _printDocument new PrintDocument(); _printDocument.PrintPage OnPrintPage; } private void OnPrintPage(object sender, PrintPageEventArgs e) { // RichTextBox的内置打印方法通过EM_FORMATRANGE消息实现 int charIndex 0; int charCount _richTextBox.TextLength; // 第一个参数是从哪个字符开始打印第二个是打印多少个字符 // 这里用简单方式把整个RTF打印到一页不适合长文档 // 更好的方式是使用EM_FORMATRANGE逐页打印 NativeMethods.SendMessage(_richTextBox.Handle, NativeMethods.EM_FORMATRANGE, new IntPtr(1), ref _rtfRange); } public void Print() { _printDocument.Print(); } public void ShowPreview() { PrintPreviewDialog previewDialog new PrintPreviewDialog(); previewDialog.Document _printDocument; previewDialog.ShowDialog(); } }这里最核心的内容是EM_FORMATRANGE消息。WinForms的RichTextBox内部没有公开打印但你可以用这个Windows消息通知它绘制到打印DC上。完整的实现要先用EM_FORMATRANGE返回的字符索引判断换页再逐页PrintPage。这个算法不算复杂但代码量不小。因为内容较长我这里放一个简化但可用的版本逐页打印的核心思路private void OnPrintPage(object sender, PrintPageEventArgs e) { int currentChar _charIndex; int maxChars _richTextBox.TextLength - currentChar; // FORMATRANGE中的rcPage和rc是打印区域 NativeMethods.FORMATRANGE fr new NativeMethods.FORMATRANGE(); fr.hdc e.Graphics.GetHdc(); fr.hdcTarget e.Graphics.GetHdc(); // 页面大小换算为TWIPS1/20点1英寸1440TWIPS Int32 twipsPerInchX 1440; Int32 twipsPerInchY 1440; fr.rcPage new NativeMethods.RECT(0, 0, (int)(e.PageBounds.Width * twipsPerInchX / e.Graphics.DpiX), (int)(e.PageBounds.Height * twipsPerInchY / e.Graphics.DpiY)); // 可打印区域左右各留1英寸边距 fr.rc new NativeMethods.RECT( (int)(e.MarginBounds.Left * twipsPerInchX / e.Graphics.DpiX), (int)(e.MarginBounds.Top * twipsPerInchY / e.Graphics.DpiY), (int)(e.MarginBounds.Right * twipsPerInchX / e.Graphics.DpiX), (int)(e.MarginBounds.Bottom * twipsPerInchY / e.Graphics.DpiY)); // 从_currentChar打印直到打印完 int charsPrinted (int)NativeMethods.SendMessage(_richTextBox.Handle, NativeMethods.EM_FORMATRANGE, new IntPtr(1), ref fr); e.Graphics.ReleaseHdc(fr.hdc); e.Graphics.ReleaseHdc(fr.hdcTarget); _charIndex charsPrinted; e.HasMorePages _charIndex _richTextBox.TextLength; if (!e.HasMorePages) _charIndex 0; }这段代码需要的P/Invoke定义篇幅较长网上有现成的RichTextBoxPrintHelper类你可以直接搜。核心逻辑就是通过EM_FORMATRANGE让RichTextBox自己把内容画到打印DC上保证打印出来的排版和屏幕上看到的完全一致。7.2 打印边距和DPI的换算上面的代码里有个很重要的地方WinForms的Graphics.DpiX通常是96或者更高的值而RTF的FORMATRANGE结构体使用TWIPS单位1/20点1英寸1440 TWIPS。所以要换算。我见过有人直接用e.PageBounds.Width减去边距最后打印尺寸完全不对原因就是没做DPI转换。换算公式页面宽度(TWIPS) PageBounds.Width * 1440 / DpiX左边距(TWIPS) MarginBounds.Left * 1440 / DpiX如果不做换算打印出来字会挤在左上角或者被裁掉。7.3 打印预览的可用方案最简单的是用PrintPreviewDialog把PrintDocument塞进去就行。但注意PrintPreviewDialog内部使用打印机的DC来预览如果你的机器没有安装默认打印机预览会报错。这个问题在工业环境里很常见。解决办法如果系统没有打印机先给用户提示或者虚拟打印一个PDF。8. 工具栏状态同步让按钮正确反映当前格式一个专业的编辑器工具栏上的加粗按钮应该在光标位于加粗文本处时呈“按下”状态。这个同步逻辑是基于RichTextBox.SelectionFont和光标位置来做的。最简单的做法是处理SelectionChanged事件private void richTextBox1_SelectionChanged(object sender, EventArgs e) { Font currentFont richTextBox1.SelectionFont ?? richTextBox1.Font; boldButton.Checked currentFont.Bold; italicButton.Checked currentFont.Italic; underlineButton.Checked currentFont.Underline; // 字号显示 sizeCombo.Text currentFont.Size.ToString(); // 字体显示 fontCombo.Text currentFont.Name; // 对齐 if (richTextBox1.SelectionAlignment HorizontalAlignment.Left) leftAlignButton.Checked true; else if (richTextBox1.SelectionAlignment HorizontalAlignment.Center) centerAlignButton.Checked true; else if (richTextBox1.SelectionAlignment HorizontalAlignment.Right) rightAlignButton.Checked true; }要注意的是SelectionChanged事件在选中文本、移动光标时都会触发频率很高。如果在这个事件里做复杂的布局或IO操作界面会卡。所以保持同步逻辑轻量化。另外SelectionFont如果为null说明选区内的字体不统一这时候按钮的Checked状态到底应该是什么我选择用富文本控件的默认字体去填充这样不容易误导用户。字体下拉框的SelectedIndexChanged事件也要注意它会在你编程设置fontCombo.Text时触发从而改变SelectionFont造成循环。解决办法是用一个_isSyncing标志位private bool _isSyncing false; private void richTextBox1_SelectionChanged(object sender, EventArgs e) { if (_isSyncing) return; _isSyncing true; // 同步UI... _isSyncing false; }这是WinForms开发里很常见的防递归同步手法。9. 富文本转纯文本的编码与格式处理编辑器的功能完成后导出纯文本常会遇到几个问题。虽然richTextBox.Text可以直接拿到纯文本但一些隐藏字符、换行符和缩进需要处理。9.1 导出纯文本并保持段落RTF里换行有两个概念段落符\par和软换行\line。RichTextBox.Text会把\par转换成\r\n或\n取决于环境\line转换成什么实测有时候是\v或空格需要清理。我的导出函数public string ConvertToPlainText() { string text _richTextBox.Text; // 清理RTF中的软换行残留 text text.Replace(\v, Environment.NewLine); // 转换统一的换行符 text text.Replace(\r\n, Environment.NewLine); text text.Replace(\r, Environment.NewLine); return text; }注意\v垂直制表符在RTF中常被RichTextBox用来表示软换行在Text属性里可能会出现要手动替换成Environment.NewLine。9.2 导出纯文本时的缩进问题如果文档用制表符缩进Text属性保留\t。如果你期望的纯文本用空格替代制表符可以用text.Replace(\t, )。10. 扩展功能思路从编辑器到小型Office套件基础功能完成后我开始思考怎么让这个编辑器在实际项目中变得更有用。这里分享几个我已经做或正在做的扩展方向。10.1 插入默认表格RTF的表格语法比较复杂但如果只是插入一个简单的横线表格可以用\trowd控制字。手工写RTF表格很痛苦我找了个折中方案先用\trowd生成一个只包含边框线的目标格式再往单元格里填字。因为含量太大这里就不展开代码了思路是在需要表格的位置构造一段完整的RTF表格文本然后通过SelectionRtf属性插入。private void InsertSimpleTable(int rows, int cols) { StringBuilder rtf new StringBuilder(); rtf.Append({\rtf1\ansi); int cellWidth 2000; // 单位是twips2000约等于1英寸 for (int r 0; r rows; r) { rtf.Append(\trowd); for (int c 0; c cols; c) { rtf.Append(\cellx (cellWidth * (c 1))); } rtf.Append(\intbl cell \cell); rtf.Append(\row); } rtf.Append(}); _richTextBox.SelectedRtf rtf.ToString(); }这里要注意SelectedRtf要求插入的RTF片段必须是完整的RTF文档带\rtf1头否则会抛异常。而且插入后焦点要重新定位不能在只读状态插入。10.2 插入超链接RTF超链接需要用{\field{\*\fldinst{HYPERLINK url}}{\fldrslt{显示文本}}}。RichTextBox有个DetectUrls属性开启后输入URL会自动变成蓝色带下划线的链接。但如果你要指定链接文字和URL不同就得自己插RTF。private void InsertHyperlink(string displayText, string url) { string rtfLink ${{\field{{\*\fldinst{{HYPERLINK {url}}}{{\fldrslt{{{displayText}}}}}}}; _richTextBox.SelectedRtf rtfLink; }这个简单实用适合在报告里插入参考资料链接。10.3 字数统计与中英文统计RTF编辑器项目里字数统计是一个常见的需求。如果直接用richTextBox.Text.Length中文字符和英文字符都算一个字符。如果要区分中文和英文需要遍历字符public (int ChineseCount, int EnglishWordCount, int TotalCount) GetWordCount() { string text _richTextBox.Text; int chineseCount 0; int englishWordCount 0; bool inWord false; foreach (char ch in text) { if (ch 0x4e00 ch 0x9fff) { chineseCount; inWord false; } else if (char.IsLetter(ch)) { englishWordCount; if (!inWord) { englishWordCount; inWord true; } else { englishWordCount; } } else { inWord false; } } return (chineseCount, englishWordCount, text.Length); }注意这个统计逻辑并不严谨英文单词数被重复计算了但实际项目里我建议用正则或分词库来统计。这个例子只是为了说明思路。11. 性能优化与内存管理大文档不卡顿的一些手段编辑器最怕的就是打开几百KB的RTF界面卡成PPT。RichTextBox本身是Win32控件理论上对大文本有优化但还是有一些你的代码导致的性能问题。11.1 为什么要用BeginUpdate/EndUpdateWindows Forms的RichTextBox没有公开的BeginUpdate方法但可以通过SendMessage发送WM_SETREDRAW消息来挂起重绘[DllImport(user32.dll)] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); private const int WM_SETREDRAW 0x000B; public void SuspendDrawing() { SendMessage(_richTextBox.Handle, WM_SETREDRAW, IntPtr.Zero, IntPtr.Zero); } public void ResumeDrawing() { SendMessage(_richTextBox.Handle, WM_SETREDRAW, new IntPtr(1), IntPtr.Zero); _richTextBox.Invalidate(); }例如在“全选设置全部格式”这种操作里先挂起重绘做完操作再恢复界面就不会闪烁而且能明显加快速度。11.2 大量文本替换的性能优化替换全部时如果用我的ReplaceAll循环方式每次替换都会触发TextChanged和重绘。当替换数量达到几千次时速度会非常慢。优化思路先在纯文本字符串上做替换再一次性设置_richTextBox.Rtf或Text。但缺点是无法保留格式。折中方案是如果只是纯文本替换且不影响格式可以直接操作字符串后重新设置Rtf但会把整个文档的样式重置成默认字体。所以大批量替换无可避免地要遍历Selection性能的取舍点其实在于是否保留格式。如果需求是“把文档里所有‘旧词’换成‘新词’格式不变”那么只能循环替换但可以先把SelectionBackColor高亮去掉减少重绘。或者用BeginUpdate包裹让重绘只发生一次。实测下来在50KB的文档里做1000次替换用循环替换耗时大约2秒用户还能接受。1MB的文档就会卡5秒以上这时候最好给个进度条提示。11.3 内存注意事项RichTextBox在插入大量图片、嵌入OLE对象时内存占用会比较高。RTF本身是纯文本图片转成Base64或十六进制后会膨胀约37%Base64或100%十六进制。所以一个PNG图片插入到RTF里占用内存比原文件大。如果插入好几十张图片编辑器内存能飙升到几百MB。我采取的措施是插入前控制图片尺寸上面已有压缩代码限制单张图片大小超过2MB提示用户及时调用richTextBox.ClearUndo()如果不用撤销历史的话但用快照方案时每次插入图片快照都会存一份栈深控制在50层12. 常见异常与排查我整理的一个问题清单最后这部分是我在实际调试中反复遇到的异常和解决方案直接列出方便你排查。12.1 Rtf赋值时抛ArgumentException最常见的原因就是RTF源码不完整或格式错了。比如从外部复制RTF字符串时漏了开头的{\rtf1\ansi。另外如果RTF中包含了控件不支持的控制字例如\shp图形对象也可能抛异常。我的建议是写入时所有RTF内容必须从RichTextBox.Rtf属性拿不要自己拼RTF。自己拼RTF只能在你知道自己在干什么的时候做。比如上面插入表格。12.2 SelectionFont为null导致NullReferenceException这个我前面提到过当选中区域包含多字体时SelectionFont会返回null。任何对SelectionFont的访问前都要判空或者用richTextBox.SelectionFont ?? richTextBox.Font。12.3 Undo后Selection位置错乱设置_richTextBox.Rtf ...后光标位置会重置到开头。这在撤销恢复后体验很差。解决办法在恢复Rtf后手动设置SelectionStart和SelectionLengthpublic void RestoreRtfWithPosition(string rtf, int position) { _richTextBox.Rtf rtf; _richTextBox.SelectionStart Math.Min(position, _richTextBox.TextLength); _richTextBox.SelectionLength 0; }12.4 粘贴时格式丢失从网页复制一段带格式文本粘贴到RichTextBox里大部分格式是能保留的。但有些CSS样式如文本阴影、浮动肯定丢失这是RTF的边界。如果你要处理复杂的HTML转RTF建议先交给剪贴板做HTML Format转换再Paste。RichTextBox会尽量处理HTML剪贴板格式但你还是会遇到内容粘贴过来后格式错位的情况。没有100%的完美方案。12.5 保存后再次打开字体颜色变化这个往往是因为RTF文件里的颜色表{\colortbl}和字体表{\fonttbl}处理不一致。RichTextBox生成RTF时颜色表在文档头部但如果你手动修改过RTF比如用SelectedRtf插入一段不含\colortbl的空分组打开后颜色可能错乱。解决办法尽量避免自己手工拼接过多的RTF片段尤其是涉及颜色和字体时让控件自己生成。13. 最后的经验分享这个编辑器项目的实际使用心得项目做到最后我给自己提炼了几条对RTF编辑器开发的体会供参考第一RTF不是万能格式但它是桌面端最稳的富文本交换格式。它的历史包袱很多但正因为老兼容性才最好。很多现代格式不谈了一到离线环境、老系统、无网络环境就傻眼RTF从来没这个问题。第二RichTextBox的Rtf属性是双刃剑。它让你几分钟就有一个编辑器但它同时把RTF的复杂性藏起来了。一旦你需要做细粒度格式控制比如给段落设置精确的左右缩进、悬挂缩进你就得回到RTF语法本身去。所以花时间阅读理解RTF规范虽然是1997年的是很有必要的。不用担心学完过时只要Word不倒RTF就会一直存在。第三所有看起来简单的功能都有细节坑。我记得查过一次“为什么粘贴的图片不能打印”最后发现是RichTextBox的打印格式范围处理没包含图片对象。这类问题排起来非常耗时间所以代码里我一直保持清晰的注释和模块划分。第四如果团队里有人想用这个编辑器做更复杂的功能比如协同编辑、在线文档建议直接换技术栈。用Web技术做协同编辑才是当前的主流方案C#的RichTextBox更适合本地单机工具。这篇文章覆盖了我从设计到实现一个C# RTF文档编辑器的主要思路和踩坑记录。如果只是快速做一个小工具RichTextBoxRTF已经足够如果是做产品级编辑器那么你可能需要进一步深入RTF规范甚至自己写解析器。如果你在实际开发中遇到了不一样的问题欢迎留言交流。我后续也会继续分享关于RTF格式解析、打印、格式往返的精读细节保持关注。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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