ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

WPF仿Word富文本编辑器:FlowDocument架构与核心实现

WPF仿Word富文本编辑器:FlowDocument架构与核心实现 简介面向WPF桌面应用开发者的仿Word富文本编辑器开源示例适合希望在自己项目中实现格式化文本编辑、图文混排与表格处理的中级开发者参考。项目拆分为富文本编辑核心组件与演示主程序并提供完整解决方案功能覆盖基础文本编辑、查找替换、HTML源码查看、打印、导出纯文本、插入图片与表格便于理解编辑器界面与业务逻辑的分层组织。rar压缩包共289个文件约753KB包含76个cs源码、若干xaml与baml界面资源、gif/png图片素材、dll与pdb程序集及配置文档等其中cs源码承载编辑核心逻辑xaml与baml对应界面布局体量轻便却保留较完整的工程结构。已有2875人学习下载可据此研究WPF图形模型下的复杂编辑操作、文本与图像数据处理及.NET组件交互并在现有代码上扩展格式化选项或文件格式支持。1. 从「能输入」到「能交付」一个仿 Word 的 WPF 富文本编辑器要跨过几道坎很多人对 WPF 富文本编辑器的第一印象是把 RichTextBox 拖进窗口、绑上按钮就完事。真做过一个仿 Word 的完整 demo 才知道「能输入」和「能交付」之间隔着文档模型、命令栈、分页排版和一整层 Ribbon 界面。开源代码 demo 的意义就在这它把一条已经跑通的路先铺好让你看清每个模块该放在哪、边界划在哪而不是从零开始一边试错一边猜。这套方案适合两类读者——毕设或课设要做桌面文档编辑器的学生以及需要在产品里嵌一个轻量排版控件、又不想引入重型商业组件的开发者。2. 总体架构设计以 FlowDocument 为唯一事实来源的六层结构架构这件事我一般不会一上来就画框。先想清楚「谁是唯一事实来源」后面的分层才有意义。在这个编辑器里答案是 FlowDocument光标、选区、格式、表格、图片全部挂在它上面界面上任何一次点击最终都要落到这棵树上。定下这条剩下的事情就变成「谁能改它、怎么改、改完怎么存」。2.1 三条技术路线对比原生 RichTextBox、自绘排版、第三方控件WPF 做富文本绕不开三条路。第一条是原生路线RichTextBox 套 FlowDocument。好处是编辑器内核、光标移动、拼写检查、无障碍支持全部白送你只需要补分页、补导出、补界面。坏处也明显FlowDocument 是流式模型天生不擅长固定页排版导出 docx 需要自己写映射上万段的长文档会明显掉帧。第二条是自绘路线RichTextBox 只当输入法宿主文档模型、排版、命中测试全部自己写。这条路能做出完全可控的分页和打印但对团队要求很高断行算法、双向文本、表格自动布局每一项都是几周的活。课程设计周期里基本走不完。第三条是引入商业控件。功能齐全但源码不可见遇到「导出 docx 后表格边框丢一半」这种问题只能等厂商排期和「开源代码 demo」的定位也不搭。三条路的对比大致是这样维度原生 RichTextBox自绘排版引擎商业控件起步成本低一天能出原型高两周起步中接入 授权固定页分页需自己封装 Paginator完全可控通常内置docx 保真度中等需写映射取决于实现深度高大文档性能万段级开始吃力可做到很好视产品而定可定制性高源码在手最高低这个 demo 走的是第一条路把 FlowDocument 当成唯一事实来源所有扩展围绕它做。选型的判断依据很朴素交付周期内能不能把「表格 图片 分页 导出」四件事同时做扎实。原生的底子已经解决了其中一半剩下的一半是可以靠封装补上的自绘则是把已经解决的那一半推倒重来。2.2 六层的职责划分与依赖方向分层的关键不是层数多而是依赖方向单一。这个编辑器分成六层上层依赖下层反向一律通过接口或事件回调层职责关键类型文档层承载唯一可信的文档树FlowDocument / Paragraph / Table编辑服务层所有修改以命令形式进入IEditorCommand / UndoStack视图层渲染、光标、选区、分页RichTextBox / DocumentPaginator界面层Ribbon、状态栏、对话框RibbonTab / RoutedUICommand持久化层打开与保存各类格式DocumentIO / OpenXmlMapper扩展层表格、图片、查找替换、字数统计IEditorPlugin一个容易翻车的地方是编辑服务层和界面层的边界。如果 Ribbon 上的「加粗」按钮直接去改TextElement.FontWeightProperty撤销栈就永远记不到这一步用户按 CtrlZ 会发现格式只回退一半。约定是界面层只发命令不碰文档。Ribbon 按钮把RoutedCommand抛出去由编辑服务层接收、执行、入栈视图层只负责把执行结果画出来。这条约定写在代码注释里比写在设计文档里有用得多。扩展层用插件接口收口是为了让「查找替换」「字数统计」这类功能不往主窗口里堆。每个插件实现三个方法初始化时挂菜单、文档变更时刷新状态、卸载时清理事件订阅。少了第三条窗口关不掉、内存不释放这类问题会一直留着。public interface IEditorPlugin { string Name { get; } // host 暴露当前文档、命令总线和状态栏插件只能通过这些入口操作 void Attach(IEditorHost host); void Detach(); } public interface IEditorHost { FlowDocument Document { get; } void Execute(IEditorCommand command); // 唯一允许修改文档的入口 void SetStatus(string text); // 状态栏提示避免插件直接抓控件 }Attach里挂事件、Detach里退订是这个接口能成立的前提。我一般会要求插件在Detach之后不再持有任何TextPointer——TextPointer 会拖住整棵文档树一个泄漏就能让几十兆的内存常驻。2.3 文档模型拆解FlowDocument 的块级与行内结构FlowDocument 是一棵树块级元素Block和行内元素Inline两套体系。块级负责段落级结构Paragraph、Table、List、Section、BlockUIContainer行内负责字符级结构Run、Span、Bold、Italic、Hyperlink、InlineUIContainer、LineBreak。FlowDocument xmlnshttp://schemas.microsoft.com/winfx/2006/xaml/presentation Paragraph FontSize16 FontWeightBold文档标题/Paragraph Paragraph 正文第一段其中 Bold加粗片段/Bold 与 Span ForegroundGray灰色说明/Span 可以混排。 /Paragraph List MarkerStyleDecimal ListItemParagraph列表项一/Paragraph/ListItem ListItemParagraph列表项二/Paragraph/ListItem /List Table CellSpacing0 TableRowGroup TableRowTableCellParagraph表头/Paragraph/TableCell/TableRow /TableRowGroup /Table /FlowDocument理解这棵树的关键是两点。第一格式是属性不是节点。Bold之所以存在只是Span上FontWeight的一个语法糖你写run.FontWeight FontWeights.Bold和用Bold包裹最终树结构是一样的。第二属性有继承。段落上的FontSize会被下面的Run继承除非Run自己设了本地值。这解释了一个高频疑问为什么代码里读到的字号和用户设的不一样——GetValue拿到的是本地值TextRange.GetPropertyValue拿到的才是继承后的有效值。遍历时我一般用LogicalTreeHelper.GetChildren递归而不是遍历Blocks集合本身因为Section和Table会嵌出多层只看一层会漏掉嵌套内容。遍历结果通常用来做字数统计、全文替换和目录生成这三件事的共同点是都需要对整棵树做一次线性扫描。3. 核心编辑能力实现序列化、光标、撤销栈与表格图片架构定完落地就是四件事文档怎么存、光标怎么定位、改错了怎么退、复杂元素怎么插。这四件事做不扎实界面做得再像 Word 也只是个空壳。3.1 打开与保存XamlPackage、RTF 与 docx 的取舍WPF 提供了TextRange.Save/Load这个便捷入口但格式选择上有坑。DataFormats.Xaml只保存 XAML 文本图片以 URI 引用的形式存在文件一挪位置就变红叉DataFormats.XamlPackage是 ZIP 容器把图片等资源一起打包进去这才是本地存档该用的格式。public enum DocFormat { XamlPackage, Rtf, Text } public static class DocumentIO { public static void Save(FlowDocument doc, string path, DocFormat format) { using FileStream fs File.Create(path); TextRange range new TextRange(doc.ContentStart, doc.ContentEnd); DataFormat fmt format switch { DocFormat.XamlPackage DataFormats.XamlPackage, // 图片随包走推荐存档 DocFormat.Rtf DataFormats.Rtf, // 便于和外部工具交换 DocFormat.Text DataFormats.Text, // 只留纯文本用于预览 _ throw new ArgumentOutOfRangeException(nameof(format)) }; range.Save(fs, fmt); } public static FlowDocument Load(string path, DocFormat format) { FlowDocument doc new FlowDocument(); using FileStream fs File.OpenRead(path); TextRange range new TextRange(doc.ContentStart, doc.ContentEnd); DataFormat fmt format DocFormat.XamlPackage ? DataFormats.XamlPackage : DataFormats.Rtf; range.Load(fs, fmt); doc.UndoCapacity 100; // 默认 100 步够用长文档可调到 200 return doc; } }TextRange的两个参数是起点和终点取doc.ContentStart到doc.ContentEnd表示整篇。UndoCapacity是 RichTextBox 自带撤销栈的深度默认值对短文档够用对动辄几十页的文档偏小编辑到中段就开始「按到某一步退不回去了」调大一些能缓解。docx 则没有内建通道。可行的做法是引入 OpenXML SDK写一层OpenXmlMapper把WordprocessingDocument里的Paragraph、Table、Run逐节点映射成 FlowDocument 的对应节点反向再写一遍导出。映射过程中最耗时的不是文本是样式字号要除以 2半磅转磅、颜色要从 hex 转SolidColorBrush、表格边框要按边的方向分别取BorderThickness。demo 里通常只覆盖常用子集——标题、正文、加粗斜体、表格、图片——把「打开不崩、导出能看」作为及格线剩下的慢慢补。3.2 光标与选区TextPointer 的定位与属性读写TextPointer 是这套体系里最容易被低估的类型。它不是索引是「两个字符之间的位置」带一个LogicalDirection表示朝前还是朝后。这一句话没理解透后面全是玄学。// 取光标所在段落并读取其有效对齐方式 TextPointer caret editor.CaretPosition; Paragraph? para caret.Paragraph; // 光标不在段落里时为 null if (para is not null) { // GetValue 拿本地值TextRange.GetPropertyValue 拿继承后的有效值 var range new TextRange(para.ContentStart, para.ContentEnd); object effective range.GetPropertyValue(Paragraph.TextAlignmentProperty); TextAlignment align (TextAlignment)effective; } // 选区两端可能方向相反取 Start/End 更稳妥 TextSelection sel editor.Selection; if (!sel.IsEmpty) { TextPointer from sel.Start; // 已按文档顺序归一化 TextPointer to sel.End; int length from.GetOffsetToPosition(to); }三个参数值得单独说。caret.Paragraph是向上找最近的Paragraph祖先光标落在表格单元格或图片容器里时返回的是所在段落不是 null这点和直觉一致。GetOffsetToPosition返回的是符号偏移量一个汉字算一个符号但一个\r\n在 FlowDocument 里算两个所以字数统计不能直接拿它当年字数用需要遍历Run累加Text.Length。sel.Start/End已经按文档顺序归一化而AnchorPosition/FocusPosition保留用户拖选方向做格式刷时前者够用做选区扩展时要用后者。还有一个小细节TextPointer会随文档变更失效。保存快照式的选区比如「记住上次编辑位置」必须在每次文档变更后重新解析一遍否则下次跳转就跳到别处去了。3.3 撤销重做命令模式怎么和 RichTextBox 自带栈共存RichTextBox 本身有撤销为什么还要自己做一套因为它只认自己视图内部的操作。你在 Ribbon 上插入一个表格、批量替换了二十处文字、用代码改了一批段落的行距这些都不在它的栈里。用户按 CtrlZ格式退回去了表格还在——这就是典型的撤销断层。public interface IEditorCommand { string Name { get; } void Execute(); void Undo(); } public sealed class UndoStack { private readonly StackIEditorCommand _undo new(); private readonly StackIEditorCommand _redo new(); private const int MaxDepth 100; public void Push(IEditorCommand cmd) { cmd.Execute(); _undo.Push(cmd); _redo.Clear(); // 新操作让重做链失效这一步最常被漏掉 if (_undo.Count MaxDepth) TrimOldest(); } public void Undo() { if (_undo.Count 0) return; IEditorCommand cmd _undo.Pop(); cmd.Undo(); _redo.Push(cmd); } public void Redo() { if (_redo.Count 0) return; IEditorCommand cmd _redo.Pop(); cmd.Execute(); _undo.Push(cmd); } private void TrimOldest() { /* 栈底淘汰保持深度上限 */ } }Execute和Undo必须严格互逆这是命令模式能用的唯一前提。实现上有个务实的选择与其为每个操作写精细的逆操作不如在命令里存一份「操作前的选区文本 格式」快照撤销时用快照覆盖回去。快照的代价是内存一个 300 字的段落快照不到 1 KB100 步也就几百 KB换来的是不会出现「撤销到一半文档结构错乱」这种噩梦。另一件必须做的是合并。用户连点十次加粗不应该产生十条命令。常见做法是给命令加一个合并键操作类型 目标范围新命令入栈前和栈顶比对键相同就替换而不是追加再把一个 500ms 的时间窗作为额外条件超时就不合并。3.4 表格与图片BlockUIContainer 与 Table 的插入逻辑表格是仿 Word 的必备项也是最容易做出「看起来能行、用起来全是洞」的模块。public static Table BuildTable(int rows, int cols) { Table table new Table { CellSpacing 0, Margin new Thickness(0, 8, 0, 8) }; for (int c 0; c cols; c) table.Columns.Add(new TableColumn()); // 列宽留空交给自动布局 TableRowGroup group new TableRowGroup(); table.RowGroups.Add(group); for (int r 0; r rows; r) { TableRow row new TableRow(); for (int c 0; c cols; c) { TableCell cell new TableCell(new Paragraph(new Run(string.Empty))) { BorderBrush Brushes.Gray, BorderThickness new Thickness(0.5), Padding new Thickness(4) }; row.Cells.Add(cell); } if (r 0) row.Background Brushes.LightGray; // 首行当表头 group.Rows.Add(row); } return table; }Columns里加空的TableColumn是必须的数量决定列数宽度留空才会自动均分只往Cells里塞内容而不加TableColumn渲染出来的列宽会塌成一条。CellSpacing 0关掉单元格间隙否则边框是双线和 Word 默认样式差很远。表头底色放在TableRow.Background上而不是TableCell上导出时才能被识别成表头行。图片分两种。整段独占的图走BlockUIContainer行内小图标走InlineUIContainerBitmapImage bmp new BitmapImage(); bmp.BeginInit(); bmp.UriSource new Uri(imagePath, UriKind.Absolute); bmp.CacheOption BitmapCacheOption.OnLoad; // 立即读进内存不长期占用文件句柄 bmp.EndInit(); bmp.Freeze(); // 冻结后才能跨线程使用、参与打印 Image img new Image { Source bmp, Stretch Stretch.Uniform, MaxWidth 480 }; document.Blocks.Add(new BlockUIContainer(img));CacheOption.OnLoad和Freeze()这两行不是可选项。不设OnLoad用户删掉原图文件后文档里的图就没了不Freeze打印或导出到后台线程时直接抛异常。MaxWidth限制在正文宽度以内否则一张 4000 像素的照片会把整页撑爆。4. 仿 Word 界面层实现Ribbon、分页视图与格式刷界面层的目标不是好看是「用户不用学」。Word 用户对功能区的位置有肌肉记忆字号在最左边、加粗在旁边、对齐方式在下一组照着排就对了。4.1 Ribbon 工具栏与 RoutedCommand 的绑定方式WPF 自带EditingCommands加粗、斜体、下划线、对齐、缩进全都有现成的RoutedCommand前提是把CommandTarget指向编辑器。Ribbon x:NameMainRibbon AutomationProperties.Name主功能区 RibbonTab Header开始 RibbonGroup Header字体 RibbonComboBox x:NameFontFamilyBox IsEditableTrue Width140 SelectionChangedOnFontFamilyChanged/ RibbonComboBox x:NameFontSizeBox IsEditableTrue Width70/ RibbonButton Label加粗 SmallImageSource/Icons/bold.png CommandEditingCommands.ToggleBold CommandTarget{Binding ElementNameEditor} FocusableFalse/ RibbonButton Label倾斜 SmallImageSource/Icons/italic.png CommandEditingCommands.ToggleItalic CommandTarget{Binding ElementNameEditor} FocusableFalse/ /RibbonGroup RibbonGroup Header段落 RibbonToggleButton Label左对齐 CommandEditingCommands.AlignLeft CommandTarget{Binding ElementNameEditor} FocusableFalse/ RibbonToggleButton Label居中 CommandEditingCommands.AlignCenter CommandTarget{Binding ElementNameEditor} FocusableFalse/ /RibbonGroup /RibbonTab /RibbonCommandTarget是这套绑定的命门。EditingCommands是路由命令不指定目标就会沿着可视树往上冒泡冒到 Ribbon 就断了按钮永远是灰的。FocusableFalse同样关键——按钮一旦能拿焦点点下去编辑器就失焦选区消失加粗作用在一个空选区上用户看到的现象就是「点了没反应」。CanExecute也要接管。表格里选中整行时「项目符号」应该禁用光标在只读区域时所有编辑命令都应该变灰。做法是在CommandBinding的CanExecute里读一次当前上下文把判断集中在一个方法里别散落到十几个事件处理器里。4.2 分页视图与标尺把流式文档切成 A4 页FlowDocument 是流式的天生没有「页」的概念。要做出 Word 那样的分页效果走DocumentPaginatorpublic static DocumentPaginator CreateAPaginator(FlowDocument doc) { // 96 DPI 下A4 210mm x 297mm ≈ 794 x 1123 设备无关像素 doc.PageWidth 794; doc.PageHeight 1123; doc.PagePadding new Thickness(72); // 1 英寸页边距对齐 Word 默认值 doc.ColumnWidth double.PositiveInfinity; // 单栏防止自动分栏 return ((IDocumentPaginatorSource)doc).DocumentPaginator; }三个参数的含义要说清。PageWidth/PageHeight用的是设备无关像素换算基准是 96 DPI所以 A4 是 794×1123 而不是 210×297。PagePadding是内容区到纸边的距离设成 72 就对应 1 英寸和 Word 中文档默认页边距一致这样导出后两边对得上。ColumnWidth设成PositiveInfinity是为了强制单栏——FlowDocument 在页面够宽时会自动分栏屏幕上看着挺整齐打印出来就变成报纸排版。标尺的实现思路是反过来的不画纸画内容区。横向标尺对应PagePadding.Left到PageWidth - PagePadding.Right这一段刻度按厘米换算1 厘米约等于 37.8 设备无关像素。缩进标记用Paragraph.Margin和TextIndent驱动拖动时直接改属性改完再让DocumentPaginator重新分页。分页视图本身有两种做法FlowDocumentPageViewer省事但不可控DocumentPageView配合自定义的翻页容器灵活但要多写几十行。demo 里通常选后者因为要处理「页面缩放」「双页并排」「页间阴影」这些 Word 用户期待的效果。4.3 格式刷与样式联动属性快照的正确抓法格式刷看着简单实现上有个容易忽略的点抓的是有效值不是本地值。private static readonly DependencyProperty[] CharProps { TextElement.FontFamilyProperty, TextElement.FontSizeProperty, TextElement.FontWeightProperty, TextElement.FontStyleProperty, TextElement.ForegroundProperty, TextElement.BackgroundProperty }; private DictionaryDependencyProperty, object? _snapshot; public void CaptureFormat(TextRange source) { // GetPropertyValue 返回继承后的有效值比 GetValue 更贴近用户看到的样式 _snapshot CharProps.ToDictionary(p p, p source.GetPropertyValue(p)); } public void ApplyFormat(TextRange target) { if (_snapshot is null) return; foreach (KeyValuePairDependencyProperty, object kv in _snapshot) target.ApplyPropertyValue(kv.Key, kv.Value); }GetPropertyValue和GetValue的差别就在继承上。用户在段落上设了 14 号字光标选中的Run本身没有本地字号GetValue返回NaNGetPropertyValue返回 14。格式刷要复制的是「看起来的样子」所以必须用后者。ApplyPropertyValue也有代价它会给选区内每个元素打上本地值选区一长元素的属性表就膨胀撤销栈也跟着涨。所以格式刷只作用在非空选区上且对超过一定长度的选区给出提示别让用户一把刷完整篇。样式联动是另一层。标题 1、标题 2、正文这些样式应该存成Style资源Ribbon 上的下拉框切换的是Paragraph.Style而不是逐个属性去改。这样改一次样式定义全篇同类段落一起变才是 Word 的用法。5. 避坑与排查五类上线后才暴露的问题自测阶段一切正常、交付后问题接踵而来是这类项目最常见的剧本。下面五条是踩过之后印象最深的按「现象 → 原因 → 解决」写清楚遇到同类问题可以直接对照。5.1 渲染与持久化类图片丢失、打印走样、粘贴卡顿保存后图片全变红叉。现象是文档里图片显示正常关掉重开后全变成破图占位符。原因多半是用了DataFormats.Xaml保存——这个格式存的是 XAML 文本图片只留一个指向临时目录的 URI临时目录一清理就失效。解决是改用DataFormats.XamlPackage它是 ZIP 容器会把图片字节一起打进去如果出于兼容性必须用纯 XAML就得自己维护一个资源字典把图片 base64 内嵌进 XAML 并用BitmapImage.StreamSource还原。打印出来的版式和屏幕上对不上行尾被切掉半行。现象是预览时好好的打出来右边少一截或者页脚文字被截断。原因通常是屏幕排版按 96 DPI 算、打印机按物理尺寸算两套坐标系没对齐再叠加PagePadding和PrintTicket页边距双重扣减。解决是打印前用独立的DocumentPaginator重新分页把PageWidth/PageHeight设成纸张实际尺寸、PagePadding设成 0页边距只在PrintTicket里设一次屏幕预览和打印走同一段分页代码别各写一套。从 Word 粘贴一段带格式的文字界面卡五秒以上。现象是 CtrlV 之后整个窗口假死任务管理器里 CPU 拉满。原因是剪贴板里带过来的是完整的 HTML 或 RTF段落数量多、内联样式多、还夹着内嵌图片逐段ApplyPropertyValue会触发成百上千次布局重算。解决是先把剪贴板内容Load到一个离屏的临时 FlowDocument在离屏文档里做清洗去掉不认识的样式、压缩连续空格、把图片转成BlockUIContainer清洗完再一次性合并进主文档内容超过一定规模时用Dispatcher分批插入并给出进度。5.2 交互与状态类撤销断层、点工具栏选区消失按 CtrlZ 只能退一半格式回去了但表格还在。现象是用户插了一个表格又改了字号撤销一次字号变回去再按一次什么也不发生。原因是表格插入走的是自定义代码路径没进UndoStack只进了 RichTextBox 自带的栈而字号修改走的又是另一条路径。解决是把「所有修改文档的操作」统一收口到IEditorHost.Execute界面层不允许直接碰FlowDocumentRichTextBox 自带的IsUndoEnabled关掉撤销入口只保留一个。收口之后新加功能只要忘了走命令总线撤销就会明显不对反而容易发现。点了 Ribbon 上的加粗选区没了字也没变粗。现象是选中一段文字鼠标移到工具栏点一下选区高亮消失操作无效。原因是按钮抢走了键盘焦点RichTextBox 失焦后选区被清空命令作用在一个空位置上。解决是给所有工具栏按钮加FocusableFalse同时在PreviewMouseDown里保存一次Selection.Start/End快照命令执行前用快照恢复选区。这两步做完还要检查RibbonComboBox这类可编辑控件——它必须能拿焦点那就得在DropDownOpened时存快照、DropDownClosed时恢复。6. 验收与答辩一份 20 分钟能跑完的检查表到了答辩或交付环节最怕的不是功能少是演示到一半翻车。我在验收时固定跑一张表按顺序走一遍任何一项不过就先修再演示检查项操作判定标准文档打开打开一份含标题、列表、表格、图片的存档无异常弹窗图片正常显示格式往返加粗、斜体、字号、颜色各设一次后保存再打开四项格式完全保留表格编辑插入 5×4 表格跨单元格选中后合并结构正确边框不断线撤销深度连续做 20 次不同操作按 20 次 CtrlZ回到初始状态无残影分页一致性切到分页视图翻到最后一页页数与状态栏显示一致打印预览打开打印预览行尾不截断页边距与屏幕一致粘贴压力从外部粘贴一份 3000 字带格式文本3 秒内完成界面不假死长文档连续插入 200 个段落滚动流畅输入无明显延迟答辩时的讲述顺序也有讲究。别从技术栈开始讲从「一个具体的使用场景」切进去打开一份带表格的文档、改两处格式、撤销、导出、打印预览五个动作两分钟走完评委立刻知道这东西能干什么。然后再回头讲分层、讲为什么选 FlowDocument、讲撤销栈为什么要自己维护——这时候技术细节才有落点。被问到「你这个和 Word 差在哪」诚实回答差在协同编辑、差在复杂域、差在排版引擎的精度但轻量嵌入、源码可控、二次开发成本低这三条是它的位置。有个血泪教训值得说早些年我做类似的项目验收前一周才发现「保存后重新打开表格边框丢了一半」追下去是导出映射里只处理了外边框没处理内边框。从那以后我每次动持久化层都强制走一遍「改格式 → 保存 → 关程序 → 重开 → 逐项比对」这条链路哪怕只改了一行代码。这份 demo 的工程结构和上面的分层是一一对应的把它当参照物对照着自己的实现走一遍能省下不少返工。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表