ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WPF文档查看器实战:FlowDocument富文本渲染与安全清洗

WPF文档查看器实战:FlowDocument富文本渲染与安全清洗 1. 文档查看器项目整体设计与思路拆解1.1 为什么选择 WPF 的 FlowDocument 作为富文本渲染核心做文档查看器这件事我前前后后折腾过好几套方案。最早用 WinForm 的 RichTextBox功能太薄样式控制基本靠 RTF 硬编码稍微复杂一点的排版就崩。后来试过把 HTML 塞进 WebBrowser 控件里渲染效果倒是能看但交互延迟高、内存占用大而且和主程序的通信全靠脚本注入维护起来非常痛苦。最终落到 WPF 的 FlowDocument 上是因为它在“结构化文档”这个定位上几乎是最优解。FlowDocument 的本质是一棵由 Block、Inline、Run、Paragraph、Table、List 等元素组成的文档树。它和 HTML 的 DOM 树思路类似但它是原生 .NET 对象可以直接绑定数据、响应事件、参与布局系统。这意味着你不需要在两种技术栈之间来回翻译文档内容本身就是 WPF 可视化树的一部分。从项目标题“文档查看器显示富文本”来看核心诉求其实就三件事第一能把服务端下发的富文本内容正确渲染出来第二渲染结果要支持滚动、缩放、选择、复制这些基础阅读操作第三要能安全地处理外部输入防止恶意内容破坏界面或引发安全问题。FlowDocument 配合 FlowDocumentScrollViewer 或 FlowDocumentReader前两件事基本是开箱即用第三件事则需要我们自己加一层清洗逻辑。1.2 整体架构分层与数据流向这个项目的架构我采用的是经典的三层拆分但针对富文本场景做了专门调整。最底层是内容解析层。服务端返回的富文本通常不是 XAML而是 HTML 片段或者自定义的 JSON 结构。我选择在客户端做一次转换把 HTML 解析成中间表示IR再由 IR 生成 FlowDocument。为什么不直接让服务端返回 XAML因为 XAML 是 .NET 特有的服务端如果是 Java 或 Node.js 技术栈生成 XAML 会很别扭而且 XAML 本身携带类型信息直接反序列化存在安全风险。中间层是文档构建层。这一层负责把 IR 映射成 FlowDocument 的元素树。每个 IR 节点对应一个构建函数比如paragraph节点生成Paragraphbold节点生成Boldtable节点生成Table。这一层还要处理样式继承比如父级段落设置了字号子级的 Run 如果没有显式指定就应该继承下来。最上层是视图交互层。用FlowDocumentScrollViewer承载文档外面套一个DockPanel放工具栏。工具栏上有缩放滑块、页码跳转、查找框。如果文档特别长我会换成FlowDocumentReader它自带分页和视图模式切换省去自己实现虚拟化的麻烦。数据流向很清晰服务端 JSON → 反序列化 → IR 树 → FlowDocument → Viewer 渲染。每一层之间都是纯数据传递没有循环依赖方便单独测试。1.3 安全清洗为什么必须放在服务端和客户端两侧热搜词里提到了“增加 csp(script-src self)与对富文本字段的服务端白名单清洗”这个点非常关键。很多人做富文本查看器只关注渲染效果忽略了安全结果上线后被注入攻击。富文本的本质是“带格式的文本”但格式本身就可能携带可执行内容。比如 HTML 里的script标签、onclick属性、javascript:协议的链接。如果服务端直接把用户提交的富文本原样存库客户端再原样渲染那就等于把 XSS 漏洞直接搬到了桌面端。我的做法是双层清洗。服务端在入库前做一次白名单过滤只允许特定的标签和属性通过比如p、strong、em、ul、li、a hrefhttp/https。所有不在白名单里的标签直接剥离属性只保留href、src、alt、title这几个。客户端在渲染前再做一次校验因为服务端可能被绕过或者历史数据里存在脏内容。客户端校验的重点是FlowDocument 构建时绝对不执行任何动态代码所有内容都当作纯文本处理链接点击时先校验协议再决定是否打开。CSP 那一层主要是针对如果查看器内部嵌了 WebView 的情况。script-src self意味着只允许加载同源脚本内联脚本一律拒绝。虽然纯 WPF 方案用不到 CSP但如果你的文档里嵌了 HTML 预览组件这层防护就不能省。2. 核心细节解析与实操要点2.1 FlowDocument 元素树与 HTML 标签的映射关系要把 HTML 转成 FlowDocument首先得建立一张映射表。这张表不是随便定的而是根据 FlowDocument 的元素能力来匹配。HTML 标签FlowDocument 元素说明pParagraph最基本的块级元素h1~h6ParagraphFontSizeFlowDocument 没有标题元素用段落加字号模拟strong/bBold内联元素包裹 Runem/iItalic同上uUnderline同上ul/olListListItem有序无序通过MarkerStyle区分tableTable需要手动构建 RowGroup、Row、CellaHyperlink设置NavigateUri但点击事件要自己接管brLineBreak内联换行imgImageInlineUIContainer图片在 FlowDocument 里是 UI 元素需要容器包裹这张表里最容易出问题的是表格和图片。FlowDocument 的 Table 要求你先建TableRowGroup再往里加TableRow每行再加TableCell层级比 HTML 深一层。图片则必须用InlineUIContainer包住Image控件否则没法嵌入到段落流里。还有一个坑是嵌套列表。HTML 里ul可以无限嵌套FlowDocument 的 List 也支持但ListItem的Blocks属性里要放一个List才能形成子列表。我见过有人直接把子 List 加到父 List 的 Blocks 里结果渲染出来层级错乱。2.2 样式继承与字体回退策略富文本的样式系统比纯文本复杂得多。一个 Run 的最终显示效果取决于它自身设置的属性、父级 Paragraph 的属性、以及文档级别的默认样式。WPF 的属性系统天然支持这种继承但前提是你得用对依赖属性。我的做法是在构建 IR 树的时候就把样式计算好而不是依赖 WPF 的继承机制。每个 IR 节点都带一个StyleContext记录当前生效的字体族、字号、颜色、粗细、斜体。子节点继承父节点的 StyleContext如果有自己的样式就覆盖。这样构建 FlowDocument 的时候每个 Run 都拿到最终计算好的值不需要 WPF 再做一次继承查找。字体回退是另一个容易被忽略的点。中文文档里经常混着英文和数字如果统一用宋体英文会很难看统一用 Arial中文又会变成方框。我的策略是设置一个字体族列表Microsoft YaHei UI, Segoe UI, Arial。WPF 会按顺序查找第一个能显示当前字符的字体就会被使用。实测下来这个组合在中英文混排场景下表现很稳。注意FlowDocument 的FontFamily属性支持逗号分隔的字体族列表但顺序很重要。把中文字体放在前面英文字体放在后面这样中文优先用中文字体英文自动回退到英文字体。2.3 大文档的虚拟化与性能优化文档查看器最怕的就是内容太长。我测试过一个 500 页的文档如果一次性构建完整的 FlowDocument内存直接飙到 800MB滚动的时候卡成幻灯片。解决思路是分块加载 虚拟化。具体做法是把文档按章节切成多个块每个块对应一个 FlowDocument。Viewer 只加载当前可见的块和前后各一个缓冲块滚动到边界时动态替换。这样内存占用能控制在 100MB 以内滚动也很流畅。FlowDocumentScrollViewer 本身支持IsOptimalParagraphEnabled和IsHyphenationEnabled这两个属性对长文档的排版性能有影响。IsOptimalParagraphEnabled会让 WPF 用更复杂的算法来断行效果更好但更耗 CPU。我的建议是文档短的时候开着文档长的时候关掉用IsHyphenationEnabled来补偿断行质量。还有一个细节是PagePadding。默认值会在文档四周留白如果文档本身已经有边距就会显得很空。我一般把它设成0然后在 Paragraph 的Margin里控制间距。3. 实操过程与核心环节实现3.1 从 HTML 到 FlowDocument 的完整转换流程假设服务端返回的是这样一段 HTMLp这是一段strong加粗/strong的文字包含一个a hrefhttps://example.com链接/a。/p ul li第一项/li li第二项/li /ul我的转换流程分四步。第一步用 HTML 解析器把字符串变成 DOM 树。我选的是 AngleSharp因为它对不规范 HTML 的容错性好而且 API 设计得很干净。解析完成后得到一个IDocument对象。第二步遍历 DOM 树生成 IR 节点。每个 IR 节点是一个简单的 POCO 类包含Type、Text、Children、Attributes四个字段。遍历的时候同时做白名单过滤不在白名单里的标签直接跳过但保留其子节点。第三步把 IR 树转成 FlowDocument。这一步是核心我写了一个FlowDocumentBuilder类每个 IR 类型对应一个Build方法。构建的时候从根节点开始递归遇到块级元素就创建Block遇到内联元素就创建Inline遇到文本就创建Run。第四步把构建好的 FlowDocument 赋给 Viewer 的Document属性。如果文档很大这一步会触发一次完整的布局计算所以我会在赋值前把 Viewer 的Visibility设为Collapsed赋值后再恢复避免用户看到中间状态。public FlowDocument Build(IEnumerableIRNode nodes) { var doc new FlowDocument(); foreach (var node in nodes) { var block BuildBlock(node); if (block ! null) doc.Blocks.Add(block); } return doc; } private Block BuildBlock(IRNode node) { switch (node.Type) { case paragraph: var p new Paragraph(); foreach (var child in node.Children) p.Inlines.Add(BuildInline(child)); return p; case list: var list new List(); foreach (var child in node.Children) list.ListItems.Add(BuildListItem(child)); return list; default: return null; } }3.2 表格渲染的参数计算与对齐处理FlowDocument 的表格渲染比 HTML 麻烦因为列宽需要手动计算。HTML 里可以用百分比FlowDocument 的TableColumn只接受GridLength可以是绝对值、自动或星号比例。我的做法是先统计表格的列数然后给每列分配一个星号宽度。如果 HTML 里指定了列宽百分比就按比例换算成星号值。比如三列分别是 20%、30%、50%我就设成20*、30*、50*。这样表格会占满可用宽度而且列宽比例正确。单元格对齐需要同时设置TextAlignment和Block.TextAlignment。TableCell本身没有对齐属性得在它包含的Paragraph上设。垂直对齐用TableCell.VerticalAlignment这个属性是有的。边框是另一个坑。FlowDocument 的 Table 没有内置边框得自己给每个 Cell 加Border。我写了一个辅助方法根据表格的边框设置给每个 Cell 的四个边分别加 Border同时处理相邻 Cell 的边框重叠问题。private void ApplyBorders(TableCell cell, TableBorders borders) { var border new Border { BorderBrush borders.Brush, BorderThickness new Thickness( borders.Left, borders.Top, borders.Right, borders.Bottom) }; border.Child new Paragraph(new Run(cell.Text)); cell.Blocks.Add(new BlockUIContainer(border)); }提示BlockUIContainer可以把任意 UI 元素塞进 FlowDocument但它的布局行为和普通 Block 不同不会参与文本流的分页。如果表格需要跨页最好还是用原生 Table 元素不要用 BlockUIContainer 包 Border。3.3 超链接的点击拦截与安全校验FlowDocument 里的 Hyperlink 默认会在点击时打开系统浏览器。这个行为在查看器里通常不合适因为用户可能只是想复制链接或者链接本身是恶意的。我的做法是拦截Hyperlink.RequestNavigate事件在里面做三件事第一校验 URI 的协议只允许http和https其他一律拒绝第二弹出一个确认框显示完整 URL让用户决定是否打开第三如果用户确认用Process.Start打开但要用UseShellExecute true并且把 URL 包在引号里防止命令注入。private void OnHyperlinkNavigate(object sender, RequestNavigateEventArgs e) { var uri e.Uri; if (uri.Scheme ! http uri.Scheme ! https) { MessageBox.Show(不支持的链接协议); e.Handled true; return; } var result MessageBox.Show( $是否打开链接\n{uri.AbsoluteUri}, 确认, MessageBoxButton.YesNo); if (result MessageBoxResult.Yes) { Process.Start(new ProcessStartInfo { FileName uri.AbsoluteUri, UseShellExecute true }); } e.Handled true; }这里有个细节RequestNavigate事件是路由事件如果不设e.Handled trueWPF 还会继续执行默认行为。所以无论是否打开链接最后都要把 Handled 设为 true。3.4 查找与高亮功能的实现文档查看器如果没有查找功能基本没法用。WPF 的 FlowDocument 没有内置查找得自己实现。我的思路是遍历文档的所有Run用正则匹配关键词把匹配到的文本拆成多个 Run给匹配部分加背景色。查找的时候从当前光标位置往后找找到后滚动到可见区域。遍历 Run 的时候要注意Run的Text属性是只读的不能直接改。要替换的话得在父级InlineCollection里操作。我写了一个FindAndHighlight方法接收一个FlowDocument和关键词返回匹配数量。public int FindAndHighlight(FlowDocument doc, string keyword) { var count 0; var runs doc.DescendantsRun().ToList(); foreach (var run in runs) { var text run.Text; var index text.IndexOf(keyword, StringComparison.OrdinalIgnoreCase); if (index 0) continue; var parent run.Parent as InlineCollection; if (parent null) continue; var before new Run(text.Substring(0, index)); var match new Run(text.Substring(index, keyword.Length)) { Background Brushes.Yellow }; var after new Run(text.Substring(index keyword.Length)); parent.InsertAfter(run, before); parent.InsertAfter(before, match); parent.InsertAfter(match, after); parent.Remove(run); count; } return count; }滚动到匹配位置用run.BringIntoView()但这个方法在虚拟化场景下可能不生效因为目标 Run 可能还没被创建。我的处理是先确保目标块已加载再调用 BringIntoView。4. 常见问题与排查技巧实录4.1 富文本渲染错位的典型原因与修复渲染错位是最高频的问题我遇到过好几种不同的表现。第一种是段落间距不对。HTML 的p默认有上下边距FlowDocument 的Paragraph默认也有但两者的默认值不一样。如果不显式设置转换后的文档会比原 HTML 松散很多。我的做法是统一把Paragraph.Margin设成new Thickness(0, 0, 0, 8)只保留底部间距。第二种是列表缩进异常。FlowDocument 的List默认有Margin和PaddingListItem也有。如果 HTML 里列表本身有缩进转换后会叠加导致缩进过深。解决方法是把List.Margin和List.Padding都设成 0只保留ListItem的Margin。第三种是表格宽度溢出。如果表格的星号列宽总和超过可用宽度WPF 会按比例压缩但如果某个单元格内容太长且不能换行就会撑破表格。这时候要给单元格的Paragraph设置TextWrapping TextWrapping.Wrap并确保Table.Columns的宽度总和不超过 100*。问题表现可能原因修复方法段落间距过大Paragraph 默认 Margin 叠加显式设置 Margin列表缩进过深List 和 ListItem 的 Padding 叠加清零 List 的 Padding表格撑破容器单元格内容不换行设置 TextWrapping图片显示为空白图片加载失败或路径错误检查 URI 和缓存策略中文显示为方框字体族不含中文字体设置字体回退列表4.2 内存泄漏的排查与解决WPF 的内存泄漏是出了名的难查。我遇到过一次文档查看器打开十几个文档后内存涨到 2GB 不释放。用 dotMemory 抓快照后发现FlowDocument 对象没有被回收。原因是FlowDocumentScrollViewer的Document属性虽然换了新值但旧文档还被事件处理器引用着。具体来说我在每个 Hyperlink 上挂了RequestNavigate事件事件处理器是实例方法隐式持有this引用而this又持有 Viewer 的引用形成了一条从旧文档到 Viewer 的引用链。解决办法有两个一是用弱事件模式把事件处理器改成静态方法加 WeakReference二是在切换文档时手动遍历旧文档的所有 Hyperlink解除事件绑定。我选了第二种因为更直观性能也更好。private void DetachHyperlinks(FlowDocument doc) { foreach (var link in doc.DescendantsHyperlink()) { link.RequestNavigate - OnHyperlinkNavigate; } }注意DescendantsT()是自定义的扩展方法用LogicalTreeHelper或VisualTreeHelper递归遍历。FlowDocument 的元素树不是可视化树得用LogicalTreeHelper。4.3 跨平台移植的可行性评估热搜词里出现了“wpf 跨平台”和“复杂wpf程序 linux移植”说明很多人关心这个话题。我的判断是纯 WPF 应用目前没有官方跨平台方案。WPF 依赖 DirectX 和 Windows 图形栈Linux 和 macOS 上跑不了。如果确实需要跨平台有两条路。第一条是换框架用 Avalonia 或 UNO Platform它们支持 XAML 语法FlowDocument 的替代品是TextBlock加Inlines但功能比 FlowDocument 弱不少表格和分页要自己实现。第二条是保留 WPF 做 Windows 客户端另外用 Web 技术做跨平台版本服务端统一提供富文本数据两端各自渲染。我的建议是如果项目一开始就明确要跨平台直接选 Avalonia别用 WPF。如果是已有 WPF 项目要移植评估工作量时要把 FlowDocument 的替换成本算进去这部分通常占总工作量的 30% 以上。4.4 富文本编辑与查看的边界处理查看器和编辑器是两种不同的产品形态但经常被混在一起。查看器的核心是“只读渲染”编辑器的核心是“可编辑 撤销重做 光标管理”。WPF 的RichTextBox可以当编辑器用但它的编辑体验和现代富文本编辑器差距很大。我的做法是查看器用FlowDocumentScrollViewer编辑器用RichTextBox两者共享同一套 IR 转换逻辑。查看器渲染时把 IR 转成 FlowDocument编辑器加载时也把 IR 转成 FlowDocument但额外挂上编辑相关的事件。保存时把 FlowDocument 转回 IR再序列化成 JSON 发给服务端。这样拆分的好处是职责清晰查看器不需要关心编辑逻辑编辑器也不需要关心只读渲染的优化。缺点是两套 UI 控件的样式要分别维护但比起混在一起带来的复杂度这点成本是值得的。4.5 常见问题速查表问题排查方向解决方案文档加载后空白检查 Document 是否为 null确保 Build 方法返回了有效文档滚动卡顿文档是否过大启用分块加载和虚拟化链接点击无反应事件是否被拦截检查 RequestNavigate 绑定图片不显示URI 是否可访问用绝对路径或 Base64 内嵌复制文本带格式是否用了 RichText用 TextRange.Save 控制格式查找高亮不消失旧高亮未清除查找前先重置所有 Run 的背景表格跨页断裂Table 不支持自动分页拆成多个小表格或改用列表字体模糊是否开启了 ClearType设置 TextOptions.TextFormattingMode5. 富文本安全清洗的落地细节5.1 服务端白名单清洗的具体规则服务端清洗我用的是一套基于正则和 DOM 解析结合的方案。先用 DOM 解析器把 HTML 变成树然后递归遍历只保留白名单内的标签和属性。白名单标签列表p、br、strong、b、em、i、u、ul、ol、li、a、img、table、thead、tbody、tr、td、th、h1~h6、blockquote、code、pre。白名单属性href仅 http/https、src仅 http/https 或 data:image、alt、title、colspan、rowspan。清洗的时候要注意几个细节。第一a标签的href要校验协议javascript:和data:一律拒绝。第二img的src如果是data:协议要限制大小防止 base64 炸弹。第三所有标签的style属性一律剥离因为 CSS 里也可能藏expression()之类的执行入口。第四注释节点直接删除防止条件注释攻击。ALLOWED_TAGS {p, br, strong, b, em, i, u, ul, ol, li, a, img, table, thead, tbody, tr, td, th, h1, h2, h3, h4, h5, h6, blockquote, code, pre} ALLOWED_ATTRS {href, src, alt, title, colspan, rowspan} def sanitize(node): if node.type comment: node.remove() return if node.name not in ALLOWED_TAGS: node.unwrap() return for attr in list(node.attrs.keys()): if attr not in ALLOWED_ATTRS: del node.attrs[attr] elif attr in (href, src): if not is_safe_url(node.attrs[attr]): del node.attrs[attr] for child in node.children: sanitize(child)5.2 客户端二次校验的必要性服务端清洗能挡住大部分攻击但不能完全依赖它。原因有三个第一历史数据可能是在清洗规则上线前入库的里面可能有脏内容第二服务端可能被绕过比如通过其他接口写入数据第三客户端渲染引擎和服务端解析器的行为可能有差异某些在服务端看起来无害的内容在客户端可能被解释成可执行代码。客户端校验的重点是在构建 FlowDocument 的时候所有文本都当作纯文本处理绝对不执行任何动态代码。具体来说Run.Text只接受字符串不接受绑定表达式Hyperlink.NavigateUri只接受Uri对象不接受字符串拼接Image.Source只接受BitmapImage不接受任意流。还有一个细节是BlockUIContainer。这个元素可以塞任意 UI 控件如果 IR 里允许这种节点就等于开了一个后门。我的做法是IR 里根本不定义 UI 容器类型所有内容都必须是文本或标准文档元素。5.3 CSP 策略在混合方案中的应用如果查看器内部嵌了 WebView 来渲染部分 HTML 内容CSP 就是最后一道防线。script-src self的意思是只允许加载同源脚本内联script和onclick属性都会被拒绝。配置 CSP 的时候要注意self指的是当前页面的源如果 WebView 加载的是本地 HTML 文件self可能不生效。这时候要用none彻底禁止脚本或者用 nonce 机制给可信脚本发通行证。我的建议是如果只是查看富文本WebView 里根本不需要脚本直接设script-src none最安全。如果需要交互比如折叠展开用 CSS 的:target或checkboxhack 实现不要引入 JavaScript。提示CSP 的default-src应该设成none然后按需开放。比如img-src self data:允许同源图片和 base64 图片style-src self unsafe-inline允许内联样式。unsafe-inline有风险但在纯展示场景下可以接受。6. 性能调优与体验打磨6.1 首屏加载时间的优化文档查看器的首屏加载时间直接影响用户体验。我测过一个 2MB 的 HTML 文档从解析到渲染完成要 3 秒多用户会明显感觉到卡顿。优化分三步。第一步是延迟解析不要一次性解析整个 HTML而是先解析前 50 个块渲染出来剩下的在后台线程慢慢解析。第二步是并行构建IR 转 FlowDocument 的过程可以并行化每个块独立构建最后合并。第三步是预布局在后台线程调用Document.Measure提前触发布局计算等用户看到的时候已经布局好了。实测下来这三步能把首屏时间压到 500ms 以内。关键是第一步用户感知到的“加载完成”其实是首屏可见内容渲染完成后面的内容慢慢加载用户不会注意。6.2 滚动流畅度的保障措施滚动卡顿通常是因为布局计算太频繁。WPF 的FlowDocumentScrollViewer在滚动时会不断重新计算布局如果文档元素太多每次计算都很慢。我的优化手段是固定块高度。如果每个块的高度是固定的滚动时就不需要重新计算布局直接按偏移量渲染即可。但富文本块的高度通常不固定所以这个方案只适用于内容规整的场景。更通用的方案是虚拟化。只渲染可见区域内的块其他块用占位符代替。WPF 的VirtualizingStackPanel支持这种模式但FlowDocumentScrollViewer内部没有用虚拟化。我的做法是自己写一个虚拟化容器用ScrollViewer加Canvas手动管理块的创建和销毁。这个方案的工作量不小但效果很明显。1000 个块的文档虚拟化后内存占用从 500MB 降到 80MB滚动帧率从 15fps 提升到 60fps。6.3 打印与导出的兼容处理文档查看器经常需要打印或导出 PDF。WPF 的PrintDialog可以直接打印 FlowDocument但分页效果和屏幕显示可能不一致。我的处理是打印前先把 FlowDocument 克隆一份设置PageWidth和PageHeight为纸张尺寸然后调用DocumentPaginator获取分页。如果分页结果不理想就手动插入PageBreak在合适的位置强制分页。导出 PDF 我用的是PdfSharp它可以把 FlowDocument 渲染成 PDF。但要注意PdfSharp对中文的支持需要额外配置字体否则会显示乱码。我的做法是嵌入一个中文字体子集只包含文档中用到的字符这样 PDF 体积不会太大。var pdf new PdfDocument(); var page pdf.AddPage(); var gfx XGraphics.FromPdfPage(page); var font new XFont(Microsoft YaHei, 12); gfx.DrawString(text, font, XBrushes.Black, new XRect(0, 0, page.Width, page.Height)); pdf.Save(output.pdf);注意XFont的字体名必须是系统已安装的字体或者通过FontResolver加载自定义字体文件。如果目标机器没有安装中文字体导出的 PDF 会显示方框。7. 项目扩展与后续演进方向7.1 批注与协作功能的接入点查看器做稳定之后下一步通常是加批注。批注的本质是在文档的特定位置挂一个标记点击标记显示评论内容。实现思路是在 IR 树里给每个文本节点加一个唯一 ID批注数据里记录 ID 和评论内容。渲染的时候根据 ID 找到对应的 Run在它旁边插入一个批注图标。点击图标弹出 Popup 显示评论。难点在于文档更新后 ID 会变。我的做法是用内容哈希加位置偏移作为 ID这样即使文档重新生成只要内容没变ID 就不变。如果内容变了批注就标记为“已失效”提示用户重新定位。7.2 多格式文档的统一渲染除了 HTML实际项目里可能还要渲染 Markdown、RTF、甚至 Word 文档。我的思路是统一转成 IR再由 IR 转 FlowDocument。Markdown 用 Markdig 解析它支持自定义渲染器可以直接输出 IR。RTF 用 WPF 自带的TextRange.Load加载到 FlowDocument再反向转成 IR。Word 文档用 OpenXML SDK 解析提取段落和样式转成 IR。这样做的代价是 IR 要设计得足够通用能表达所有格式的特性。我的 IR 目前支持段落、标题、列表、表格、图片、链接、加粗、斜体、下划线、代码块、引用块基本覆盖了常见文档格式。7.3 无障碍访问的支持无障碍访问在国内项目里经常被忽略但如果你的用户里有视障人士这就是刚需。WPF 的自动化支持通过AutomationPeer实现FlowDocument 的元素默认没有 AutomationPeer需要自己实现。我的做法是给每个 Paragraph 和 Run 设置AutomationProperties.Name内容是文本本身。这样屏幕阅读器就能读出文档内容。表格要额外设置AutomationProperties.ItemType为“表格”并给每个单元格设置行列信息。还有一个细节是键盘导航。查看器要支持 Tab 键在链接之间跳转Enter 键激活链接。这需要给 Hyperlink 设置KeyboardNavigation.IsTabStop为 true并处理KeyDown事件。8. 实操心得与避坑总结8.1 我踩过的三个大坑第一个坑是在 UI 线程做 HTML 解析。早期版本我把 AngleSharp 的解析放在 UI 线程结果大文档一加载界面就卡死。后来改成后台线程解析解析完再Dispatcher.Invoke回 UI 线程构建 FlowDocument。但构建 FlowDocument 也必须在 UI 线程因为 WPF 的依赖对象有线程亲和性。所以最终的方案是后台线程解析 HTML 成 IRUI 线程把 IR 转成 FlowDocument。第二个坑是忘记解除事件绑定。前面提过内存泄漏的问题根源就是 Hyperlink 的事件没解绑。后来我养成了一个习惯任何事件绑定都要在对应的清理逻辑里-。对于文档这种生命周期明确的对象在切换文档时统一清理。第三个坑是低估了字体回退的复杂度。我以为设一个字体族列表就完事了结果发现某些特殊字符比如数学符号、emoji在所有指定字体里都不存在WPF 会显示成方框。后来加了一个兜底字体Segoe UI Symbol覆盖了大部分特殊字符。如果还有缺失就用FontFamily.Fallback手动指定。8.2 给新手的入门建议如果你刚开始做 WPF 文档查看器我的建议是先跑通最小闭环。不要一上来就搞虚拟化、批注、多格式先把“HTML 转 FlowDocument 并显示”这条路走通。用最简单的 HTML 片段测试确保段落、加粗、链接、列表这四种元素能正确渲染。跑通之后再加安全清洗。清洗逻辑要单独写单元测试用各种恶意 HTML 片段测试确保script、onclick、javascript:都被正确过滤。最后再加性能优化。性能优化是永无止境的先保证功能正确再考虑速度。我见过有人为了优化性能把代码搞得极其复杂结果功能一堆 bug得不偿失。8.3 工具与库的选型参考用途推荐库理由HTML 解析AngleSharp容错性好API 干净支持 CSS 选择器Markdown 解析Markdig性能好扩展性强支持自定义渲染器PDF 导出PdfSharp轻量API 简单支持中文字体嵌入内存分析dotMemory快照对比直观能定位引用链UI 控件库HandyControl控件丰富样式现代中文文档全图标FontAwesome.Sharp图标全集成简单支持矢量缩放选型的时候不要盲目追新优先选社区活跃、文档齐全的库。我吃过亏用了一个小众的 HTML 解析库结果遇到 bug 没人修最后只能自己 fork 一份改。8.4 后续可以扩展的方向这个项目做完之后有几个方向可以继续深挖。一是全文检索把文档内容索引到 Lucene 或 Elasticsearch支持复杂的查询语法。二是版本对比同一文档的不同版本并排显示高亮差异。三是导出为图片把文档渲染成 PNG 或 JPEG方便分享到社交平台。四是朗读功能用系统 TTS 引擎把文档读出来适合通勤场景。每个方向都有现成的库可以用关键是想清楚用户需求不要为了加功能而加功能。我个人的原则是如果一个功能不能解决用户的真实痛点就不做。
返回列表