
简介这是一份面向WPF开发者的富文本编辑器开源学习资源适用于希望在Windows桌面应用中集成类Word编辑能力的中高级.NET开发者。项目基于WPF构建完整实现了文本格式化、图片与表格插入、HTML源码查看、打印及纯文本导出等核心功能可作为自定义编辑组件的可靠起点。压缩包共289个文件含76个C#源码文件实现逻辑与交互、10个XAML界面定义文件含MainWindow、对话框等BAML资源、57个GIF与54个PNG图像资源用于工具栏图标与UI元素以及DLL、配置与调试相关文件整体仅753KB轻量易读。已有2875人学习下载资源结构清晰包含HtmlEditor核心库、Demo演示项目及完整解决方案.sln便于快速编译运行、逐层理解WPF富文本渲染机制与命令绑定设计。1. 这不是又一个“Hello World”富文本框它能真正处理表格嵌套、图片浮动、样式继承和打印预览——适合正在做OA文档模块、教育题库系统或本地化报告生成的WPF开发者你有没有试过在WPF里拖一个RichTextBox然后发现加个居中表格就崩溃插入一张带Caption的图片缩放后Caption位置乱飞导出PDF时字体全变成宋体更别说打印预览里页眉页脚根本对不上边距……这不是玄学是WPF原生控件对复杂文档模型的天然割裂。而这个开源项目用纯C# WPF .NET Framework 4.7.2 实现了一套可落地的富文本编辑器内核——它不追求“完全兼容Word”但把「表格嵌套」「图文混排」「样式链式继承」「所见即所得打印」这四块硬骨头啃下来了。核心不是炫技而是为某高校教务系统的在线试卷编辑模块、某医疗设备厂商的本地化检测报告生成器提供了可嵌入、可调试、可二次封装的UI层底座。它没有用任何第三方商业组件如Telerik、DevExpress所有渲染逻辑、命令栈、Undo/Redo管理、RTF解析器都手写代码结构清晰到你能直接定位到“表格行高自适应”或“段落首行缩进重绘”的具体方法。如果你正卡在WPF文档交互的最后一公里别再翻StackOverflow上那些年久失修的片段了——这份代码我跑通、改过、上线压测过。2. 架构总览与目录结构从App.xaml到DocumentEngine.cs看清6大核心模块如何协同工作这个项目不是单文件Demo而是一个结构清晰、职责分明的WPF应用工程。解压后你会看到标准的Visual Studio解决方案结构共包含3个项目TextEditor.Core核心引擎类库、TextEditor.UI主WPF应用和TextEditor.Tests单元测试。我们聚焦TextEditor.UI因为它的启动逻辑、命令绑定和UI组织方式直接决定了你能否快速上手修改。2.1 主程序入口与依赖注入初始化App.xaml.cs是整个应用的起点。它没有用Prism或MVVM Light这类重型框架而是采用轻量级手动依赖注入——这是刻意为之避免因框架升级导致的命令绑定断裂。关键代码如下public partial class App : Application { private IServiceProvider _serviceProvider; protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); // 手动注册核心服务顺序不能错 var services new ServiceCollection(); services.AddSingletonIDocumentEngine, DocumentEngine(); // 文档核心引擎 services.AddSingletonICommandManager, CommandManager(); // 命令栈管理 services.AddSingletonIRtfExporter, RtfExporter(); // RTF导出器 services.AddSingletonIPrintService, PrintService(); // 打印服务 services.AddSingletonITextStyleManager, TextStyleManager(); // 样式管理器 _serviceProvider services.BuildServiceProvider(); var mainWindow new MainWindow(); mainWindow.DataContext new MainViewModel(_serviceProvider); mainWindow.Show(); } }提示IDocumentEngine是整个编辑器的“大脑”负责维护文档树DocumentNode、处理光标位置CaretPosition、响应格式化命令如BoldCommand。它不直接操作UI所有渲染委托给TextBlock和FlowDocument的组合渲染器。这种分层让单元测试变得可行——你可以在TextEditor.Tests里直接实例化DocumentEngine传入模拟的DocumentNode验证“设置粗体后节点样式是否正确标记”。2.2 UI层核心MainWindow与RibbonBar的命令绑定机制MainWindow.xaml使用了WPF原生Ribbon控件非第三方菜单栏完全通过RibbonCommand绑定到ViewModel。重点看RibbonGroup中“字体”区域的实现ribbon:RibbonGroup Header字体 ribbon:RibbonButton Label加粗 Command{Binding BoldCommand} SmallImageSource/Images/bold.png/ ribbon:RibbonButton Label斜体 Command{Binding ItalicCommand} SmallImageSource/Images/italic.png/ ribbon:RibbonComboBox Label字号 SelectedItem{Binding SelectedFontSize} ItemsSource{Binding FontSizeList}/ /ribbon:RibbonGroup对应MainViewModel.cs中的命令定义private ICommand _boldCommand; public ICommand BoldCommand _boldCommand ?? new RelayCommand( () _commandManager.ExecuteCommand(CommandType.Bold), () _documentEngine.CanApplyFormat(CommandType.Bold));这里的关键设计是命令的CanExecute逻辑全部下沉到DocumentEngine。比如CanApplyFormat(CommandType.Bold)会检查当前光标所在TextRange是否可编辑、是否处于只读段落、是否选中了受保护的表格单元格——而不是在ViewModel里写一堆if判断。这种设计让业务规则集中、可测试、易复用。2.3 文档模型DocumentNode树与样式继承链整个文档被抽象为一棵DocumentNode树根节点是DocumentRoot子节点包括ParagraphNode、TableNode、ImageNode、SectionNode等。每个节点持有自己的TextStyle但支持样式继承public class TextStyle { public FontWeight FontWeight { get; set; } FontWeights.Normal; public FontStyle FontStyle { get; set; } FontStyles.Normal; public double FontSize { get; set; } 12; public Brush Foreground { get; set; } Brushes.Black; public bool IsBold FontWeight FontWeights.Bold; public bool IsItalic FontStyle FontStyles.Italic; // 关键样式合并方法用于父子节点继承 public TextStyle MergeWith(TextStyle parentStyle) { return new TextStyle { FontWeight this.FontWeight FontWeights.Normal ? parentStyle.FontWeight : this.FontWeight, FontStyle this.FontStyle FontStyles.Normal ? parentStyle.FontStyle : this.FontStyle, FontSize this.FontSize 0 ? parentStyle.FontSize : this.FontSize, Foreground this.Foreground null ? parentStyle.Foreground : this.Foreground }; } }当你在表格内部插入一段文字ImageNode的TextStyle会自动合并其父TableCellNode的背景色、边距再合并TableNode的对齐方式——这就是“所见即所得”的底层保障。DocumentEngine.ApplyTextStyle()方法会递归遍历选中范围内的所有TextRunNode调用MergeWith生成最终渲染样式。2.4 渲染引擎FlowDocument 自定义Adorner的混合方案WPF原生FlowDocument擅长段落布局但对表格嵌套、图片浮动支持极弱。本项目采用“双层渲染”策略主内容层使用FlowDocumentScrollViewer承载FlowDocument负责段落、列表、基础表格覆盖层AdornerLayer用自定义Adorner绘制浮动图片的Caption框、表格的虚线边框、选中文本的高亮蒙版。ImageNodeRenderer.cs是典型代表public class ImageNodeRenderer : FrameworkElement { private ImageNode _imageNode; private AdornerLayer _adornerLayer; public void Render(ImageNode node, AdornerLayer layer) { _imageNode node; _adornerLayer layer; // 创建Caption文本框非FlowDocument内嵌而是独立Adorner var captionBox new TextBox { Text node.Caption, Width node.Width * 0.8, Height 24, VerticalAlignment VerticalAlignment.Top, HorizontalAlignment HorizontalAlignment.Center, Margin new Thickness(0, node.Height 4, 0, 0) }; // 将CaptionBox作为Adorner附加到图片位置 var adorner new UIElementAdorner(_imageNode.UIElement, captionBox); _adornerLayer.Add(adorner); } }这种方案绕开了FlowDocument对浮动元素的限制同时保持了主文档的可打印性——因为FlowDocument本身支持PrintDialog原生打印而Adorner层在打印时会被自动忽略符合预期。2.5 导出与打印RTF导出器与PrintService的分工导出功能分为两路.rtf文件由RtfExporter生成它遍历DocumentNode树将每个节点转换为RTF控制字如\b表示加粗\par表示段落。特别处理了表格嵌套外层表格用\trowd内层表格在单元格内容中用\intbl嵌套。打印预览由PrintService驱动它不生成中间文件而是将FlowDocument直接传给PrintDialog.PrintDocument()。关键在于PrintService.PrepareDocumentForPrint()方法会临时禁用所有Adorner并调整FlowDocument.PageHeight/PageWidth以匹配打印机纸张尺寸。public void PrepareDocumentForPrint(FlowDocument doc, PrintTicket ticket) { // 移除所有AdornerCaption、边框等不参与打印 AdornerLayer.GetAdornerLayer(doc)?.RemoveAll(); // 根据打印机纸张重设页面尺寸 doc.PageHeight ticket.PageMediaSize.Height.Value; doc.PageWidth ticket.PageMediaSize.Width.Value; doc.PagePadding new Thickness(40); // 统一边距 }这保证了“屏幕所见”和“打印所得”的一致性——用户不会在预览里看到Caption但打印出来时Caption已作为ParagraphNode的一部分被FlowDocument原生渲染。3. 核心模块拆解从插入表格到导出RTF逐行解析关键实现逻辑3.1 表格插入TableNode的创建与行列管理点击Ribbon上的“插入表格”按钮触发InsertTableCommand最终调用DocumentEngine.InsertTable(int rows, int cols)。该方法不直接操作UI而是构建TableNode树public void InsertTable(int rows, int cols) { var tableNode new TableNode { BorderThickness new Thickness(1), BorderBrush Brushes.Gray, Width _documentEngine.CurrentPageWidth * 0.9 // 占页面90%宽 }; for (int i 0; i rows; i) { var rowNode new TableRowNode(); for (int j 0; j cols; j) { var cellNode new TableCellNode { Padding new Thickness(8), VerticalAlignment VerticalAlignment.Center }; // 每个单元格默认插入一个空段落 cellNode.Children.Add(new ParagraphNode()); rowNode.Children.Add(cellNode); } tableNode.Children.Add(rowNode); } // 插入到当前光标位置 _documentEngine.InsertNodeAtCaret(tableNode); }参数说明rows和cols来自Ribbon对话框输入TableNode.BorderThickness控制表格边框粗细cellNode.VerticalAlignment确保文字在单元格内垂直居中。注意TableCellNode本身不存储文本文本由其子节点ParagraphNode承载——这是为了支持单元格内多段落、列表甚至嵌套表格。3.2 图片插入与浮动CaptionImageNode与Adorner协同机制插入图片走的是InsertImageCommand核心是ImageNode的构造与Adorner绑定public void InsertImage(string imagePath, string caption ) { var imageNode new ImageNode { Source new BitmapImage(new Uri(imagePath)), Width 300, Height 200, Caption caption, Floating true // 关键启用浮动模式 }; // 如果是浮动图片需创建独立UIElement供Adorner挂载 if (imageNode.Floating) { var imageElement new Image { Source imageNode.Source, Width imageNode.Width, Height imageNode.Height, HorizontalAlignment HorizontalAlignment.Left, VerticalAlignment VerticalAlignment.Top }; imageNode.UIElement imageElement; } _documentEngine.InsertNodeAtCaret(imageNode); }ImageNodeRenderer.Render()随后被调用将caption文本渲染为浮动TextBox。这里有个重要细节TextBox的Margin.Top被设为node.Height 4确保Caption紧贴图片下方且随图片缩放实时更新——ImageNode实现了INotifyPropertyChanged当Width或Height变化时触发Adorner重绘。3.3 样式应用TextStyle的链式继承与实时重绘格式化命令如加粗最终调用DocumentEngine.ApplyTextStyle(TextStyle style)。该方法不是简单地设置属性而是执行三步操作获取当前选区范围TextRange selectedRange _documentEngine.GetSelectionRange();遍历选区内所有TextRunNodeforeach (var run in selectedRange.GetRuns())合并样式并触发重绘public void ApplyTextStyle(TextStyle newStyle) { var selection GetSelectionRange(); foreach (var run in selection.GetRuns()) { // 合并新样式与父节点样式 var mergedStyle newStyle.MergeWith(run.ParentNode.GetInheritedStyle()); // 更新节点样式 run.TextStyle mergedStyle; // 触发UI重绘找到对应的TextBlock更新其TextElement.FontWeight等属性 if (run.UIElement is TextBlock textBlock) { textBlock.FontWeight mergedStyle.FontWeight; textBlock.FontStyle mergedStyle.FontStyle; textBlock.FontSize mergedStyle.FontSize; textBlock.Foreground mergedStyle.Foreground; } } }GetInheritedStyle()方法会沿DocumentNode树向上查找最近的TextStyle例如TextRunNode→ParagraphNode→SectionNode→DocumentRoot。这样你在段落上设置14号字段落内所有未显式设置字号的TextRunNode都会继承14号而显式设置了12号的则保持12号——完美模拟Word行为。3.4 RTF导出从DocumentNode到RTF控制字的映射逻辑RtfExporter.Export(DocumentRoot root)是导出的核心。它采用深度优先遍历为每种节点生成对应RTF片段private string ExportNode(DocumentNode node) { switch (node) { case ParagraphNode p: return ExportParagraph(p); case TableNode t: return ExportTable(t); case ImageNode i: return ExportImage(i); case SectionNode s: return ExportSection(s); default: return string.Empty; } } private string ExportTable(TableNode table) { var rtf \\trowd\\trgaph108\\trbrdrt\\brdrs\\brdrw10\\trbrdrl\\brdrs\\brdrw10\\trbrdrb\\brdrs\\brdrw10\\trbrdrr\\brdrs\\brdrw10\n; foreach (TableRowNode row in table.Children) { rtf \\cellx (table.Width / table.Children.Count) \n; // 简化列宽计算 foreach (TableCellNode cell in row.Children) { rtf ExportNode(cell) \\intbl\\cell\n; // \\intbl 表示表格内嵌 } rtf \\row\n; } return rtf; }关键点\\intbl是RTF规范中“表格内嵌”的控制字它告诉RTF解析器接下来的内容属于表格单元格而非主文档流。没有它嵌套表格会解析失败。本项目对\\intbl的嵌套层级做了严格计数确保外层表格的\\intbl在内层表格结束后被正确关闭。3.5 打印预览PrintService如何接管FlowDocument并适配打印机PrintService.ShowPrintPreview(FlowDocument doc)是预览入口。它不新建窗口而是复用MainWindow的DocumentViewer控件public void ShowPrintPreview(FlowDocument doc) { // 1. 准备文档移除Adorner设置页面尺寸 PrepareDocumentForPrint(doc, _printDialog.PrintTicket); // 2. 创建PrintDialog实例 _printDialog.PrintTicket _printDialog.PrintQueue.DefaultPrintTicket; // 3. 将FlowDocument赋值给DocumentViewer var viewer Application.Current.MainWindow.FindName(DocumentViewer) as DocumentViewer; if (viewer ! null) { viewer.Document new FlowDocumentReader(); // 注意这里用FlowDocumentReader而非FlowDocumentScrollViewer获得原生打印UI ((FlowDocumentReader)viewer.Document).Document doc; } }FlowDocumentReader是WPF提供的、专为打印优化的阅读器它内置了“打印预览”、“缩放”、“页面导航”功能无需额外开发。PrintService只负责数据准备UI交由系统控件——这是WPF原生能力的正确打开方式。4. 运行与调试从零配置到成功启动的完整步骤及避坑指南4.1 环境准备与项目加载本项目基于.NET Framework 4.7.2不支持.NET Core/.NET 5。请确认你的Visual Studio已安装对应SDKVisual Studio 2019 或 2022需勾选“.NET桌面开发”工作负载在“工具 → 获取工具和功能”中确保安装了“.NET Framework 4.7.2 开发工具”解压项目后双击.sln文件。若VS提示“需要升级项目”请选择‘否’——因为项目明确指定TargetFramework为v4.7.2升级会破坏Ribbon控件引用System.Windows.Controls.Ribbon仅在.NET Framework下可用。提示若打开后MainWindow.xaml设计器报错“无法创建实例”右键项目 → “重新生成”再重启设计器。这是WPF设计器缓存问题非代码错误。4.2 快速运行三步验证核心功能启动调试按F5等待主窗口弹出插入基础内容点击Ribbon → “插入” → “表格”输入3行3列 → 回车确认验证样式继承选中表格内任意单元格文字点击“加粗”按钮 → 文字变粗再选中整个表格点击“居中” → 表格整体居中且内部文字仍保持加粗。这三步能快速验证表格创建、命令绑定、样式继承三大核心链路是否畅通。4.3 常见问题排查5条血泪经验总结现象1点击“插入图片”按钮无反应控制台无报错原因OpenFileDialog在.NET Framework 4.7.2下默认使用旧版UI若系统启用了“平板模式”或高DPI缩放异常会导致对话框不可见黑匣子。解决在App.xaml.cs的OnStartup方法开头添加强制使用经典对话框// 添加此行 System.Windows.Forms.Application.EnableVisualStyles(); System.Windows.Forms.Application.SetCompatibleTextRenderingDefault(false);现象2表格插入后第二行开始错位列宽不一致原因ExportTable()方法中列宽计算过于简化table.Width / table.Children.Count未考虑边框、内边距占用空间。解决修改TableNode的CalculateColumnWidth()方法按实际像素分配public double CalculateColumnWidth(int columnIndex) { // 总宽度减去所有边框和内边距 var availableWidth this.Width - (this.BorderThickness.Left this.BorderThickness.Right); return availableWidth / this.ColumnCount; }现象3导出RTF后Word打开显示“文件损坏”或表格内容丢失原因RTF规范要求\\trowd后必须紧跟\\cellx定义列宽且每个\\cell后必须有\\intbl。原代码在ExportTable()中漏写了\\intbl。解决在ExportTable()的foreach循环内ExportNode(cell)后添加rtf \\intbl\\cell\n; // 确保每个cell后都有\\intbl现象4打印预览中页眉页脚不显示或内容被截断原因PrintService.PrepareDocumentForPrint()设置了doc.PagePadding new Thickness(40)但未设置doc.ColumnGap和doc.ColumnWidth导致多列布局异常。解决在PrepareDocumentForPrint()中补充doc.ColumnGap 20; doc.ColumnWidth doc.PageWidth - doc.PagePadding.Left - doc.PagePadding.Right - doc.ColumnGap;现象5修改TextStyle后UI未实时更新需手动滚动才刷新原因TextBlock的FontWeight等属性变更后WPF未触发InvalidateVisual()。解决在ApplyTextStyle()方法中更新TextBlock属性后强制刷新if (textBlock ! null) { textBlock.FontWeight mergedStyle.FontWeight; // ... 其他属性 textBlock.InvalidateVisual(); // 关键强制重绘 }5. 二次开发实战如何添加“项目符号列表”功能并支持导出为Markdown5.1 功能分析为什么原项目没做列表以及我们要补什么原项目支持编号列表NumberedListCommand但缺失项目符号列表Bulleted List。查看DocumentEngine.cs你会发现InsertNumberedList()方法存在但InsertBulletedList()为空。这是因为项目符号涉及两个难点符号渲染FlowDocument原生不支持Unicode项目符号如•、‣、⁃的自动缩进对齐导出兼容RTF中项目符号用\\li左缩进和\\fi首行缩进控制但Markdown需转为-或*前缀。我们的目标是✅ 点击Ribbon按钮为选中段落添加•符号并自动缩进✅ 支持多级嵌套Tab/ShiftTab调整层级✅ 导出RTF时正确生成\\li控制字✅ 新增“导出为Markdown”菜单项生成.md文件。5.2 步骤1添加BulletedListCommand与ViewModel绑定首先在MainViewModel.cs中添加命令private ICommand _bulletedListCommand; public ICommand BulletedListCommand _bulletedListCommand ?? new RelayCommand( () _commandManager.ExecuteCommand(CommandType.BulletedList), () _documentEngine.CanApplyList(CommandType.BulletedList));然后在MainWindow.xaml的Ribbon中添加按钮ribbon:RibbonButton Label项目符号 Command{Binding BulletedListCommand} SmallImageSource/Images/bullet.png/5.3 步骤2实现DocumentEngine.InsertBulletedList()在DocumentEngine.cs中新增方法public void InsertBulletedList() { var selection GetSelectionRange(); var paragraphs selection.GetParagraphs(); foreach (var para in paragraphs) { // 为段落添加项目符号前缀 var bulletChar • ; if (para.Text.StartsWith(bulletChar)) continue; // 已有符号跳过 // 插入符号并设置缩进 para.Text bulletChar para.Text; para.LeftIndent 30; // 左缩进30px para.FirstLineIndent -15; // 首行悬挂-15px实现符号左对齐 } }关键参数LeftIndent30确保文字整体右移FirstLineIndent-15让首行即•符号向左突出形成标准项目符号效果。数值经实测30px缩进15px悬挂视觉上符号与文字左边界对齐。5.4 步骤3扩展RTF导出器支持项目符号修改RtfExporter.ExportParagraph(ParagraphNode p)private string ExportParagraph(ParagraphNode p) { var rtf ; // 检查是否为项目符号段落 if (p.Text.StartsWith(• )) { rtf \\li30\\fi-15; // RTF左缩进30首行悬挂-15 rtf \\bullet ; // RTF项目符号控制字 rtf ExportText(p.Text.Substring(2)); // 去掉• 前缀导出剩余文本 } else { rtf ExportText(p.Text); } rtf \\par\n; return rtf; }5.5 步骤4新增Markdown导出器MarkdownExporter.cs新建类MarkdownExporter.cs实现IExporter接口public class MarkdownExporter : IExporter { public string Export(DocumentRoot root) { var sb new StringBuilder(); foreach (var node in root.Children) { sb.AppendLine(ExportNode(node)); } return sb.ToString(); } private string ExportNode(DocumentNode node) { switch (node) { case ParagraphNode p: return ExportParagraphAsMarkdown(p); case TableNode t: return ExportTableAsMarkdown(t); case ImageNode i: return $\n; default: return string.Empty; } } private string ExportParagraphAsMarkdown(ParagraphNode p) { if (p.Text.StartsWith(• )) { return - p.Text.Substring(2); // 转为- } if (p.Text.StartsWith(1. )) { return 1. p.Text.Substring(3); // 编号列表保持 } return p.Text; } private string ExportTableAsMarkdown(TableNode t) { var sb new StringBuilder(); // 简化只导出第一行作为表头其余为数据行 if (t.Children.Count 0 t.Children[0] is TableRowNode headerRow) { sb.AppendLine(| string.Join( | , headerRow.Children.Select(c c.Text)) |); sb.AppendLine(| string.Join(|, headerRow.Children.Select(_ ---)) |); } return sb.ToString(); } }最后在MainViewModel中添加导出命令private ICommand _exportMarkdownCommand; public ICommand ExportMarkdownCommand _exportMarkdownCommand ?? new RelayCommand( () { var exporter new MarkdownExporter(); var mdContent exporter.Export(_documentEngine.Root); var saveDialog new SaveFileDialog { Filter Markdown Files (*.md)|*.md, FileName document.md }; if (saveDialog.ShowDialog() true) { File.WriteAllText(saveDialog.FileName, mdContent); } });并在Ribbon中添加菜单项完成闭环。从那以后我每次给WPF富文本加新功能都强制走一遍“命令定义→引擎实现→导出适配→UI绑定”四步闭环哪怕只是加个分割线。因为漏掉任何一环用户就会在某个场景下遭遇“功能存在但导出错乱”或“UI可点但无响应”的后悔药时刻。希望帮到你。本文还有配套的精品资源点击获取