WPF富文本编辑器:三层架构、格式同步与docx导出

发布时间:2026/10/23 12:39:49
WPF富文本编辑器:三层架构、格式同步与docx导出 简介面向WPF与.NET桌面开发者的开源富文本编辑器示例重点解决在Windows应用中快速搭建类Word编辑环境的问题。项目以可运行的解决方案形式组织包含编辑器核心组件与演示程序适合希望学习XAML界面布局、文档模型、图文表格混排及HTML源码处理的初中级开发者参考。压缩包共289个文件约753KB以76个C#源码文件为主体配合XAML/BAML界面资源、PNG/GIF图示素材、DLL依赖及少量配置与项目文件覆盖界面、逻辑和资源等层次。功能上提供文本编辑、查找替换、HTML源码查看、打印、纯文本导出、插入图片与表格等能力并带有颜色、字体、超链接和表格等交互对话框便于按需裁剪和扩展。目前已有2875人学习适合用作课程设计、桌面工具原型或自定义编辑器的起步工程通过阅读源码可掌握WPF富文本编辑的关键实现思路与二次开发方法。1. 需求先立住一套能交差、也能继续改的 WPF 富文本编辑器该长什么样“WPF 富文本编辑器”这个需求往往是在课程设计或毕设进行到中期才突然变清晰的前期只要求“能打字”临到验收前两周要求就变成要加粗斜体、插入图片、保存成 Word 能直接打开、最好还带工具栏和状态栏。我见过不少版本把所有逻辑都堆进 RichTextBox 里最后界面和文档逻辑缠在一起想加一个“居中对齐”得改三处文件。这套开源 demo 的价值不在功能堆得多全而在边界划得清编辑内核只负责 FlowDocument 和光标工具栏只负责命令路由和状态回显文件层只负责序列化。学生拿它当作业骨架从业者拿它当能直接替换持久化实现的底座都成立。2. 总体架构设计三层结构与六个核心模块的职责划分在动手写第一个 XAML 之前先把“谁拥有文档”这件事定死。WPF 里最容易出事的设计是 MainWindow 直接持有 RichTextBox然后每个按钮的 Click 事件里都去访问richTextBox.Document。这样写前两周很爽第三周开始加功能就会很痛苦换一个编辑区控件要改几十处单元测试根本没法写因为所有逻辑都挂在一个 UI 控件上。2.1 三层切分视图层、编辑服务层、持久化层我一般按下面三层来切视图层只做两件事——摆控件、把用户动作翻译成命令。它包含主窗口、工具栏Ribbon 或 ToolBar 都行、状态栏以及一个很薄的EditorView用户控件。视图层允许认识 WPF 控件但不允许出现XamlWriter、File.Create这类持久化 API。编辑服务层是这套方案的核心它持有FlowDocument的引用对外暴露“加粗当前选区”“插入图片”“设置行距”这类语义化方法内部再去调用TextRange、EditingCommands、TextPointer。这一层不引用任何具体窗口类型所以你可以在控制台程序里跑它的单元测试。持久化层只认一个东西FlowDocument进FlowDocument出。它不知道光标在哪也不关心工具栏按钮是不是按下的。依赖方向是单向的视图 → 编辑服务 → 持久化。反过来引用一次都不要有这是后面能替换实现的前提。2.2 六个核心模块与它们的边界模块所在层职责不该做的事DocumentHost视图承载 RichTextBox转发 SelectionChanged不直接改文档结构SelectionService编辑服务把TextSelection包装成可读写的选区对象不弹对话框CommandRouter编辑服务把菜单/快捷键映射到具体操作不持有窗口引用FormatStateAggregator编辑服务聚合当前光标处的格式供工具栏回显不修改文档DocumentStorage持久化xaml / docx / 纯文本的存取不感知 UI 状态PaginatorAdapter持久化页面尺寸、分页、打印不参与编辑六个模块听起来多实际代码量不大关键是每个模块只做一件事。比如FormatStateAggregator只有七八十行但它是工具栏不“发疯”的唯一保障——后面第 3 章会讲它怎么处理混合选区。2.3 技术选型为什么是 RichTextBox 而不是自绘文本引擎有人会问既然要仿 Word为什么不自己画文本答案很直接WPF 的RichTextBox已经把文本排版、光标导航、输入法、系统撤销栈、无障碍支持这些难啃的骨头处理好了你自绘一遍至少要三个月而且中文输入法的候选框定位能让人崩溃。RichTextBox的代价是它绑定FlowDocument这个文档模型导出 docx 时需要做一次结构映射——这个映射的工作量远小于重写一个排版引擎。选型上还有两个附带决定文档模型用FlowDocument而不是FixedDocument因为前者支持流式重排窗口拉宽时文字会自动换行持久化格式默认用 XAML 而不是自定义二进制因为 XAML 可读、可 diff、出问题时能直接打开看结构这在排查阶段等于自带黑匣子。先把持久化的抽象定下来后面所有实现都围绕它// 持久化统一入口调用方不关心背后是 xaml 还是 docx public interface IDocumentStorage { // 显示在“另存为”对话框的格式下拉里例如“Word 文档 (*.docx)” string FormatName { get; } // 文件过滤器直接喂给 SaveFileDialog.Filter string FileFilter { get; } // 保存必须保证用 Load 读回来时结构、格式、图片都不丢 void Save(FlowDocument document, string path); // 加载返回全新实例绝不复用容器里已有的文档对象 FlowDocument Load(string path); }这里的两个约束值得单独说。Save承诺“读回来不丢”意味着任何会丢失信息的实现都不能叫这个名字比如把图片转成占位符就不行。Load承诺“返回新实例”是因为FlowDocument一旦挂到某个RichTextBox上就有宿主关系复用一个旧实例会造成“打开第二个文件时第一个文件的样式残留”这类很难查的问题。参数path用绝对路径调用方负责在传进来之前做存在性校验。3. 编辑内核落地FlowDocument 模型、命令系统与格式状态同步这一章是整套方案里代码密度最高的部分。目标很明确让工具栏上每一个按钮点下去行为都符合用户在 Word 里养成的直觉。3.1 FlowDocument 的层级Block、Inline 与 RunFlowDocument是一棵树。根下面挂BlockParagraph、List、Table、SectionParagraph下面挂InlineRun、Span、Bold、Italic、Hyperlink、InlineUIContainer。所有可见字符最终都落在某个Run的Text属性上。理解这一点很重要因为它决定了你“设置字体”时该往哪一层写。TextRange.ApplyPropertyValue会沿着选区把属性写到合适的元素上整段选中时它可能写在Paragraph上选中间几个字时它写在新建的Run或Span上。所以正确的做法永远是“对选区应用属性”而不是自己去遍历Run挨个改——手工遍历的版本在处理嵌套Bold时一定会翻车。// 对当前选区应用字符格式properties 用依赖属性值用 WPF 的对应类型 public void ApplyCharacterFormat(DependencyProperty property, object value) { // 选区为空只是光标闪烁时不处理避免把格式写到看不见的地方 if (_editor.Selection.IsEmpty) return; // ApplyPropertyValue 会自动拆分/合并 Run不要手动遍历 Inline _editor.Selection.ApplyPropertyValue(property, value); }两个参数的含义需要说清楚。property必须是TextElement上注册的依赖属性比如TextElement.FontWeightProperty、TextElement.FontSizeProperty、TextElement.ForegroundProperty传Control.FontSizeProperty之类会静默失败。value的类型要和属性匹配字体权重传FontWeights.Bold而不是字符串Bold字号传double单位是 DIP1/96 英寸不是磅。插入图片走的是另一条路因为图片是InlineUIContainer而不是字符public void InsertImage(string filePath, double maxWidth 320) { // 用 OnLoad 一次性读进内存避免源文件被占用后图片变成空白 var bitmap new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption BitmapCacheOption.OnLoad; bitmap.UriSource new Uri(filePath, UriKind.Absolute); bitmap.EndInit(); bitmap.Freeze(); // 冻结后可跨线程使用也便于 XAML 序列化 var image new Image { Source bitmap, Stretch Stretch.Uniform, MaxWidth maxWidth }; // 插在光标处若选区非空会先替换选区内容 var container new InlineUIContainer(image, _editor.Selection.Start); _editor.CaretPosition container.ElementEnd; }这里BitmapCacheOption.OnLoad和Freeze()是必须的。少了前者图片文件被移动或删除后文档再次打开就是一片空白少了后者序列化时可能抛出跨线程访问异常。maxWidth用 DIP传给Stretch.Uniform后会自动等比缩放不会把长图压扁。3.2 命令系统复用 EditingCommands别手写加粗逻辑WPF 自带EditingCommands里面已经有ToggleBold、ToggleItalic、ToggleUnderline、AlignLeft、AlignCenter、IncreaseFontSize等几十个命令。这些命令的输入路由已经处理好了RichTextBox默认就绑定了CtrlB这类手势。所以工具栏按钮的正确写法是绑命令而不是写 Click// 工具栏按钮的 Command 直接指向内置命令Target 指向 RichTextBox boldButton.Command EditingCommands.ToggleBold; boldButton.CommandTarget _editor; // 段落对齐同理不用自己算缩进 centerButton.Command EditingCommands.AlignCenter; centerButton.CommandTarget _editor;CommandTarget这一句不能省。WPF 的命令路由会从获得焦点的元素往上冒泡如果按钮被点击时焦点转移到按钮上命令就找不到目标表现是“点按钮没反应用快捷键却正常”。显式指定CommandTarget是最省事的解法比在 Click 里手动_editor.Focus()稳。自定义命令比如“插入图片”“导出 docx”通过CommandBinding挂到窗口上// 在窗口构造函数里注册自定义命令 CommandBindings.Add(new CommandBinding( EditorCommands.InsertImage, OnInsertImage, // 实际执行 CanInsertImage)); // 决定菜单项是否置灰 private void CanInsertImage(object sender, CanExecuteRoutedEventArgs e) { // 只有文档已加载且未处于只读模式时才允许插入 e.CanExecute _currentDocument ! null !_editor.IsReadOnly; }CanExecute返回false时绑定的按钮会自动变灰菜单项也一样。这个机制比手工维护IsEnabled靠谱得多因为它会在命令路由每次重新查询时自动刷新不会出现“状态改了但按钮还亮着”的情况。3.3 混合选区的格式回显工具栏不能瞎亮用户在文档里选中一段“一半加粗一半正常”的文字此时工具栏的加粗按钮该亮还是该灭Word 的做法是不亮但点一下会全部加粗。WPF 里对应的判断是GetPropertyValue在选区属性不一致时返回DependencyProperty.UnsetValue。private void OnSelectionChanged(object sender, RoutedEventArgs e) { // 混合选区返回 UnsetValue注意不能直接和 FontWeights.Bold 比较 var weight _editor.Selection.GetPropertyValue(TextElement.FontWeightProperty); BoldButton.IsChecked weight is FontWeight w w FontWeights.Bold; var size _editor.Selection.GetPropertyValue(TextElement.FontSizeProperty); // 选区跨字号时回退到当前文档默认字号避免下拉框跳成空 FontSizeBox.Text size is double d ? d.ToString(0.#) : _defaultFontSize.ToString(0.#); }关键在于is FontWeight w这个模式匹配。如果写成weight FontWeights.Bold当weight是UnsetValue时会走object的引用比较返回false看起来“没错”但一旦你把它写进Equals判断就可能抛异常。用模式匹配先确认类型行为是确定的。SelectionChanged之外还要挂TextChanged因为用户打字时选区不变但格式可能变。两个事件都调同一个聚合方法用_isUpdatingUi布尔量防止回显时反向触发命令形成死循环——这个死循环的表现是“点一次加粗按钮文档疯狂闪烁几下然后格式错乱”属于典型的玄学故障。4. 文件链路xaml 序列化、docx 导出与打印分页编辑得再顺手保存时丢一半格式就白干。这一章把三条出口讲清楚带格式的保存、给 Word 看的导出、给打印机的分页。4.1 XAML 序列化为什么不用二进制序列化FlowDocument支持XamlWriter.Save序列化出来是纯文本的 XAML包含段落、内联元素、字体属性InlineUIContainer里的图片会以 Base64 存进BitmapImage的StreamSource。这个方案的优点是出问题时你可以直接用记事本打开文件看到底哪里没存上缺点是文件体积比二进制大一张 200KB 的图片存进去大概膨胀到 270KB。public void Save(FlowDocument document, string path) { // 克隆一份再序列化避免直接序列化正在编辑的实例带出临时状态 var copy new FlowDocument(); using (var reader new TextRange(document.ContentStart, document.ContentEnd).ToStream()) { // 这里只是示意结构实际工程中直接序列化 document 也可 // 但需要先剔除自定义控件等不可序列化的元素 } using var stream File.Create(path); XamlWriter.Save(document, stream); }更稳的做法是先把文档包一层Section再序列化。原因是某些 WPF 版本里FlowDocument作为根节点被读回来时会丢掉PagePadding、ColumnWidth这类页面级属性包一层Section后这些属性落在FlowDocument上不受根节点处理逻辑影响。读取时一定要限制可反序列化的类型否则等于给自己开了一个后门public FlowDocument Load(string path) { using var stream File.OpenRead(path); var context new ParserContext { // 只允许基础文本类型杜绝加载任意 CLR 对象 BaseUri new Uri(Path.GetDirectoryName(path) Path.DirectorySeparatorChar.ToString()) }; return (FlowDocument)XamlReader.Load(stream, context); }BaseUri不能少。文档里的相对路径图片、超链接都依赖它来解析不设的话带图片的文档读回来全是裂图。4.2 docx 导出Open XML 的段落与 Run 映射导出 docx 用的是 Open XML SDK。核心映射只有三条FlowDocument的Paragraph对应 Word 的ParagraphRun对应 Word 的Run字符格式落在RunProperties上。表格、列表、图片的映射会复杂一些但课程设计和常规交付场景里先把段落和基础字符格式做对覆盖度就够了。using W DocumentFormat.OpenXml.Wordprocessing; public void ExportDocx(FlowDocument document, string path) { using var doc W.WordprocessingDocument.Create(path, W.WordprocessingDocumentType.Document); var main doc.AddMainDocumentPart(); var body new W.Body(); foreach (var block in document.Blocks.OfTypeParagraph()) { var wPara new W.Paragraph(); foreach (var run in block.Inlines.OfTypeRun()) { var wRun new W.Run(); var props new W.RunProperties(); // 字号WPF 用 DIPWord 用半磅值换算系数是 72/96*2 1.5 if (run.FontSize 0) props.Append(new W.FontSize { Val (run.FontSize * 1.5).ToString(0) }); if (run.FontWeight FontWeights.Bold) props.Append(new W.Bold()); // Space 必须设成 Preserve否则首尾空格会被 Word 吞掉 wRun.Append(props, new W.Text(run.Text) { Space W.SpaceProcessingModeValues.Preserve }); wPara.Append(wRun); } body.Append(wPara); } main.Document new W.Document(body); main.Document.Save(); }换算系数是这里最容易错的地方WPF 的1 DIP 1/96 英寸Word 的字号单位是半磅1 磅 1/72 英寸所以DIP × 72/96 × 2 DIP × 1.5。写成 1.33 或者 2 会让导出的文档字号肉眼可见地不对。SpaceProcessingModeValues.Preserve也别省。不设的话 缩进两个字这种以空格开头的内容导出后空格消失段落对齐全乱。4.3 分页与打印DocumentPaginator 与页面尺寸打印走FlowDocument的DocumentPaginator它会根据PageWidth、PageHeight、PagePadding自动分页。需要做的是把“页面设置”里的 A4、页边距这些参数换算成 DIP 写回文档。public void ApplyPageSetup(FlowDocument document, double widthMm, double heightMm, double marginMm) { // 毫米转 DIP1 英寸 25.4 毫米 96 DIP const double MmToDip 96.0 / 25.4; document.PageWidth widthMm * MmToDip; document.PageHeight heightMm * MmToDip; document.PagePadding new Thickness(marginMm * MmToDip); // 单栏排布多栏在打印预览里会挤成一条细线 document.ColumnWidth double.PositiveInfinity; }ColumnWidth设成无穷大是为了强制单栏。默认值下文档会在页面上排两栏屏幕上看不出问题打印出来就是左右两条窄条这个坑几乎每个初次实现的人都会踩。5. 常见问题排查五类最容易翻车的点下面这五条是我在不同项目里反复见到的。写在这里不是凑数每一条都能让演示当场卡住。5.1 保存后重新打开字体全变成宋体现象编辑时设置的是微软雅黑保存、关闭、重新打开全部回退成默认字体。原因字体设置在了Paragraph上而XamlWriter序列化段落时对FontFamily的处理依赖LocalizedFontFamily直接给TextElement.FontFamilyProperty赋FontFamily实例在部分场景下不会被写进 XAML。解决统一用ApplyPropertyValue写字体并且在持久化前做一次结构规整——把段落级的字体下沉到Run上。批量下沉的写法是遍历Paragraph对每个Run检查FontFamily是否为 null为 null 就继承段落的值写下去。多花三十行代码换来的是跨版本、跨机器的格式稳定。5.2 选中一段加粗只有第一个字变粗现象拖选三行文字点加粗只有光标所在的那个字变粗。原因用了_editor.CaretPosition取格式而不是_editor.Selection。CaretPosition是插入点永远只有一个位置Selection才是选区。解决所有格式操作一律走Selection并且做两件事——先判断Selection.IsEmpty为空时提前返回再把Selection.Start和Selection.End用TextRange规范化顺序因为从后往前拖选时Start在文档里可能比End更靠后直接拿去做区间运算会得到空区间。5.3 文档写到二三十页打字开始明显卡顿现象短文档流畅长文档每敲一个字都有几十毫秒延迟滚动也掉帧。原因RichTextBox默认开启撤销每次输入都往撤销栈里压一条记录长文档下栈越来越深同时每次输入都会触发全文档重排。解决把IsUndoEnabled的UndoLimit从默认的 -1无限改成 100 左右代价是撤销步数变少收益是内存和响应速度都回来。另一个手段是给RichTextBox设IsDocumentEnabled为false关闭文档内嵌控件交互以及避免在TextChanged里做全文档遍历——TextChanged触发频率极高里面每多一次foreach (var block in document.Blocks)长文档下就是几十毫秒。5.4 CtrlZ 撤销顺序错乱或者把自己写的操作也撤了现象输入几个字点一次加粗再按 CtrlZ加粗被撤销了但输入的字没退或者反过来。原因自定义操作没进撤销栈。RichTextBox的内置撤销只记录它自己认识的操作你通过ApplyPropertyValue改的格式、通过InlineUIContainer插的图片都不在它的记录范围里表现就是撤销栈深度不一致。解决自定义操作前后调用_editor.BeginChange()和_editor.EndChange()把一组改动包成一个撤销单元。插入图片这种复合操作建 BitmapImage、建 InlineUIContainer、移动光标必须包在一起否则撤销时会留下半截状态——图片没了但光标位置还在后面看起来就像文档被咬了一口。5.5 导出的 docx 用 Word 打开提示“内容有问题”现象双击导出的文件Word 弹窗说需要修复点修复后内容还在但格式全丢。原因三种可能都有。一是Body最后没有SectionPropertiesWord 认为文档结构不完整二是Run里只有RunProperties没有Text空 Run 在某些版本里被视为非法三是命名空间前缀没对上手动拼 XML 时最容易出这个。解决导出前先调用main.Document.Validate()检查结构返回的错误列表直接看Body末尾固定追加一个SectionProperties里面带页面尺寸每条Run都保证有Text元素哪怕内容为空字符串。这三条做完绝大多数“内容有问题”的弹窗就消失了。5.6 图片在保存后变成白框现象文档里图片显示正常保存后重新打开是一片空白。原因BitmapImage的UriSource指向外部文件序列化时只写入了路径而重新打开时文件已经不在了。解决插入时就用BitmapCacheOption.OnLoad把像素读进内存再Freeze()。这样序列化时写进去的是 Base64 的图像数据跟外部文件彻底解耦。代价是文件变大但这笔交易划算。6. 验收与答辩一份能自证的用例清单和演示脚本到了最后阶段工作的重点就变成了让别人相信这套东西真的能用而不是继续加功能。我一般会准备一张用例表逐条跑一遍跑不通的当场记下来。编号用例判定标准C1连续输入 500 字中文无丢字输入法候选框跟随光标C2选中三行设置 18 号字三行全部变化工具栏字号显示 18C3一半加粗一半正常选中加粗按钮不亮点击后全段加粗C4插入 3 张图片后保存再打开图片完整位置不变无变形C5编辑 20 页文档并连续输入单次按键响应低于 100 毫秒C6加粗后 CtrlZ一次撤销即回到加粗前输入内容不受影响C7导出 docx 用 Word 打开无修复提示段落与字号一致C8页面设为 A4 后打印预览单栏排布页边距符合设置演示脚本控制在 6 分钟先用 30 秒讲清楚架构分层三层、六个模块这是评委最想听的“你为什么这么设计”接着用 2 分钟走一遍 C2 到 C4展示编辑和保存然后用 1 分钟演示 C6 的撤销和 C7 的导出这两条最能体现“仿 Word”的完成度剩下时间留给提问。答辩时最常被问的三个问题提前准备好答案为什么不用现成的富文本组件——因为 WPF 生态里RichTextBox就是标准件自研排版引擎的投入产出比不成立为什么保存成 XAML 而不是直接存 docx——因为 XAML 可读可调试docx 作为导出格式而不是工作格式职责更清晰扩展性在哪——IDocumentStorage换一个实现就多一种格式编辑服务层不感知持久化。从那以后我每次带这类项目都会要求先跑完这张表再开始写代码而不是等代码写完再想验收标准。方向对了后面每一行代码都在往交付上走方向不对功能加得越多返工时越难受。希望这套结构帮到你。本文还有配套的精品资源点击获取