
简介这是一份基于.NET Framework 4.5.2、使用C#与WPF开发的数学公式编辑器源码工程面向具备一定C#基础、希望深入WPF界面开发与数学排版处理的开发者可用于教育、科研场景下的公式录入与展示需求。压缩包共715个文件约16.75MB以png界面素材、cs源码、otf字体、baml与xaml界面定义、dll依赖库及config配置为主另含sln解决方案与csproj工程文件便于直接编译运行。已有357人学习下载。工程围绕公式编辑核心功能展开涵盖MathML与LaTeX输入转换、实时预览、数学符号库、拖放交互、多格式保存导出等模块并包含主窗口、历史工具栏、公式工具栏、Unicode选择器与设置窗口等界面组件。读者可借此研究WPF控件布局、XAML与代码分离、事件处理及渲染优化思路是提升C#与WPF综合开发能力的完整实践案例。1. 为什么 .NET 4.5.2 上还要手搓一个数学公式编辑器接手这个需求的时候我第一反应是找现成控件。客户环境是 Windows 7 SP1 加 .NET Framework 4.5.2不能升级运行时不能装第三方商业控件还要在 WPF 里做公式的所见即所得编辑。搜了一圈MathType 是 COM 的嵌进 WPF 里交互别扭开源的 WPF 公式控件大多依赖 .NET Core 或者更高版本的 WPF 特性4.5.2 上直接编译不过。最后只能自己动手用 WPF 的FlowDocument加RichTextBox做宿主把公式拆成可编辑的文本节点和符号节点再自己写解析和渲染。这个方案解决的核心问题是在低版本 .NET 的 WPF 应用里让用户像编辑普通文本一样输入 LaTeX 风格的公式片段实时看到排版结果并且能把公式作为结构化数据存进数据库、导出成图片或 Word 文档。适合谁做教育类上位机、考试系统、题库录入工具的 C# 开发者尤其是那些被客户锁死在 .NET 4.5.2 环境里、又不想引入重型排版引擎的人。下面把我踩过的路按选型、实现、避坑、进阶四段拆开讲代码都能直接抄。2. 公式编辑器的技术选型与 WPF 宿主搭建2.1 为什么不用 MathML 渲染器而选 LaTeX 子集MathML 在 WPF 里没有原生支持要自己写 XSLT 转换或者找第三方库而 .NET 4.5.2 能用的 MathML 渲染库要么停更要么收费。LaTeX 子集的好处是语法简单、用户学习成本低、解析器可以自己控制。我选的是只支持\frac{}{}、\sqrt{}、^{}、_{}、\sum、\int这几个高频符号覆盖 90% 的中学和大学基础公式场景。复杂公式让用户用图片插入不硬撑。解析器用递归下降把输入串切成 token 流再构建成表达式树。表达式树节点分三类文本节点、符号节点、布局节点。布局节点负责水平排列、上下标、分数这三种排布。这样渲染的时候只需要遍历树按节点类型生成对应的 WPF 元素。2.2 用 RichTextBox 做宿主的最小可行配置WPF 的RichTextBox支持FlowDocument可以嵌入InlineUIContainer来放自定义的公式控件。但直接嵌会有一个问题光标定位和删除行为很怪。我的做法是给每个公式创建一个FormulaInline类继承Inline内部用一个Border包住渲染好的Canvas。这样公式在文档流里就像一个字符光标能正常跳过退格能整体删除。// FormulaInline.cs public class FormulaInline : Inline { private readonly string _latex; private readonly Canvas _renderCanvas; public FormulaInline(string latex) { _latex latex; _renderCanvas FormulaRenderer.Render(latex); // 核心渲染入口 _renderCanvas.Background Brushes.Transparent; // 用 InlineUIContainer 把 Canvas 塞进文档流 var container new InlineUIContainer(_renderCanvas); // 注意InlineUIContainer 不能直接作为 Inline 的子级 // 这里通过重写 CreateInlineUIContainer 返回自身内容 } public string Latex _latex; }上面这段是骨架实际使用时需要把FormulaInline注册到RichTextBox的TextElement体系里。更稳妥的做法是不继承Inline而是直接用InlineUIContainer包Canvas然后在RichTextBox的PreviewKeyDown里拦截退格键判断光标前一个元素是不是InlineUIContainer是就整体删除。这个逻辑我放在FormulaEditorBehavior里后面避坑章节会讲为什么不能靠默认行为。参数说明_renderCanvas的Width和Height由渲染器根据公式内容动态计算不要写死。Background设成Transparent是为了让选中高亮能透过去。InlineUIContainer的BaselineAlignment要设成BaselineAlignment.Center否则公式和文字对不齐看起来像飘在空中。2.3 渲染器的坐标计算与字体度量渲染器是核心。输入 LaTeX 串输出一个Canvas里面按坐标摆好TextBlock和Line。分数线的位置、上下标的偏移量、根号的钩子全靠手动算。字体用Cambria Math这个字体在 Win7 上自带不用额外装。字号基准设 16上下标按 0.7 倍缩放再根据基线偏移调整Canvas.Top。// FormulaRenderer.cs 片段 private static double MeasureTextHeight(string text, double fontSize) { var typeface new Typeface(Cambria Math); var formatted new FormattedText( text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, fontSize, Brushes.Black); return formatted.Height; } public static Canvas Render(string latex) { var canvas new Canvas(); var tokens Lexer.Tokenize(latex); var tree Parser.Parse(tokens); double x 0, y 0; RenderNode(tree, canvas, ref x, ref y, 16); canvas.Width x 4; // 留 4px 右边距 canvas.Height y 4; return canvas; }逻辑说明MeasureTextHeight用FormattedText拿到精确高度避免用固定行高导致上下标重叠。Render里x和y是当前绘制游标RenderNode根据节点类型决定是水平推进还是换行。分数节点会把分子画在上方、分母画在下方中间画一条Line线的Y1和Y2取分子底部和分母顶部的中间值。根号节点用Path画一个折线再在内部递归渲染被开方表达式。参数怎么改字号基准 16 是经验值如果宿主RichTextBox的FontSize变了公式字号也要同步缩放否则大小不一致。我一般把FontSize绑定到RichTextBox的FontSize属性上用RelativeSource找父级。上下标缩放系数 0.7 可以调到 0.65 到 0.75 之间看具体字体渲染效果太小会糊太大会挤。3. 从 LaTeX 输入到结构化存储的完整链路3.1 词法分析与递归下降解析器的实现词法分析把输入串切成 tokentoken 类型有Number、Letter、Command反斜杠开头的、LBrace、RBrace、Caret、Underscore。命令后面跟花括号参数解析器要能处理嵌套。递归下降的入口是ParseExpression它循环读取 token遇到^或_就构造上下标节点遇到\frac就构造分数节点。// Parser.cs 核心片段 private static Node ParsePrimary(ListToken tokens, ref int pos) { var token tokens[pos]; if (token.Type TokenType.Command token.Value frac) { pos; // 跳过 \frac Expect(tokens, ref pos, TokenType.LBrace); var numerator ParseExpression(tokens, ref pos); Expect(tokens, ref pos, TokenType.RBrace); Expect(tokens, ref pos, TokenType.LBrace); var denominator ParseExpression(tokens, ref pos); Expect(tokens, ref pos, TokenType.RBrace); return new FractionNode(numerator, denominator); } if (token.Type TokenType.Number || token.Type TokenType.Letter) { pos; return new TextNode(token.Value); } throw new ParseException($意外的 token: {token.Value} 在位置 {pos}); }逻辑说明ParsePrimary处理最基本的单元ParseExpression负责处理上下标这种后缀运算符。上下标的优先级高于分数所以x^2会先被解析成SuperscriptNode(TextNode(x), TextNode(2))。Expect是一个辅助方法检查当前 token 类型是否符合预期不符合就抛异常异常信息里带位置方便定位用户输入错误。参数说明pos是引用传递保证递归过程中位置同步推进。ParseException里我加了Position属性上层捕获后可以在RichTextBox里把出错位置高亮成红色波浪线。这个反馈对用户很重要否则他们不知道哪里写错了。3.2 把公式树序列化成 XML 存进数据库公式不能只存 LaTeX 串因为将来渲染规则变了重新解析可能得到不同结果。我的做法是同时存 LaTeX 串和表达式树的 XML 序列化结果。XML 结构简单每个节点一个元素属性存类型和文本子节点递归嵌套。这样即使解析器升级旧数据也能按旧树渲染。// FormulaSerializer.cs public static XElement Serialize(Node node) { switch (node) { case TextNode t: return new XElement(Text, new XAttribute(Value, t.Value)); case FractionNode f: return new XElement(Fraction, Serialize(f.Numerator), Serialize(f.Denominator)); case SuperscriptNode s: return new XElement(Superscript, Serialize(s.Base), Serialize(s.Exponent)); default: throw new NotSupportedException($未知节点类型: {node.GetType().Name}); } }逻辑说明用XElement而不是XmlDocument因为 LINQ to XML 在 .NET 4.5.2 上完全可用API 更顺手。序列化后的 XML 存进nvarchar(max)字段和 LaTeX 串放同一行。读取时先尝试反序列化 XML失败再回退到解析 LaTeX 串保证兼容性。参数说明XAttribute的Value要做 HTML 编码防止公式里的或破坏 XML 结构。反序列化时用XElement.Parse并捕获XmlException记录日志后走回退逻辑。数据库字段建议加一个Version列标记序列化格式版本将来迁移有依据。3.3 导出为 PNG 和 Word 文档的两种落地方式导出 PNG 用RenderTargetBitmap把Canvas渲染成位图再编码成 PNG 存文件。注意 DPI 要设成 96 以上否则打印会糊。导出 Word 有两种做法一种是嵌 PNG 图片简单但不可编辑另一种是生成 OMMLOffice Math Markup LanguageWord 能识别并转成可编辑公式。OMML 的生成比较复杂我一般先用 PNG 方案客户真有编辑需求再上 OMML。// 导出 PNG public static void ExportToPng(Canvas canvas, string filePath) { var bitmap new RenderTargetBitmap( (int)canvas.Width, (int)canvas.Height, 96, 96, PixelFormats.Pbgra32); bitmap.Render(canvas); var encoder new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var stream File.Create(filePath)) { encoder.Save(stream); } }逻辑说明RenderTargetBitmap的构造函数里 DPI 设 96 是屏幕标准如果要打印改成 300。PixelFormats.Pbgra32支持透明背景导出的 PNG 放在 Word 里不会有白底。PngBitmapEncoder是 WPF 自带的不用引第三方库。参数说明canvas.Width和Height必须是正数如果公式为空要提前返回否则RenderTargetBitmap会抛异常。文件路径用Path.Combine拼不要手写反斜杠。导出 Word 时用DocumentFormat.OpenXml包但 .NET 4.5.2 上要用 2.5 版本2.12 以上要求 .NET 4.6这是个隐藏坑。4. 避坑与常见问题排查4.1 光标在公式前后跳不过去现象在RichTextBox里光标移到公式左边按右方向键直接跳到公式右边中间没有过渡或者按退格键删不掉公式只删了旁边的文字。原因InlineUIContainer在 WPF 的文本模型中是一个原子元素默认的光标移动逻辑会把它当成一个整体跳过但删除逻辑没有配套处理。RichTextBox的默认Backspace命令只处理Run和Paragraph不认InlineUIContainer。解决在PreviewKeyDown里拦截Key.Back判断CaretPosition前一个Inline是不是InlineUIContainer是就手动从FlowDocument里移除并把光标定位到移除后的位置。方向键不用管默认行为就是对的。代码里记得把e.Handled设成true否则会继续走默认逻辑。4.2 公式渲染出来上下标重叠或分数线错位现象x^2渲染出来2 和 x 叠在一起或者分数线的位置偏上和分子粘住了。原因FormattedText的Height包含了行间距直接拿来做偏移量会偏大。另外Canvas.Top是相对于父容器的如果父容器有Margin或Padding坐标就偏了。解决用FormattedText.Baseline属性拿基线位置上下标的Top设成baseBaseline - superscriptHeight。分数线取分子Bottom和分母Top的中间值不要用固定偏移。父容器的Margin和Padding全设 0需要间距就在Canvas内部留。4.3 在 .NET 4.5.2 上引用 OpenXml 报版本冲突现象NuGet 装了DocumentFormat.OpenXml最新版编译时报错「未能加载文件或程序集要求 .NET 4.6 或更高版本」。原因OpenXml 2.12 开始把目标框架提到了 .NET 4.64.5.2 不在支持列表里。NuGet 界面不会明确提示装完才发现。解决降级到DocumentFormat.OpenXml2.5 版本这个版本支持 .NET 4.0 及以上。如果项目里同时引了WindowsBase和PresentationFramework注意版本号要和 4.5.2 的运行时匹配不要用 4.8 的引用程序集。4.4 公式多了之后 RichTextBox 滚动卡顿现象文档里超过 50 个公式滚动时明显掉帧CPU 占用飙升。原因每个InlineUIContainer里的Canvas都在独立渲染WPF 的渲染线程要处理大量视觉元素。而且RichTextBox默认开启拼写检查和撤销记录都在消耗资源。解决把RichTextBox的SpellCheck.IsEnabled设成falseUndoLimit设成 20 以内。公式的Canvas用CacheMode设成BitmapCache把渲染结果缓存成位图滚动时直接复用。如果还卡就把公式在失焦时转成静态图片聚焦时再换回可编辑的Canvas这个策略我称之为「冷热分离」。4.5 导出的 PNG 在 Word 里显示模糊现象导出的公式图片插入 Word 后放大看边缘发虚打印出来更明显。原因RenderTargetBitmap默认 DPI 是 96Word 插入图片时按屏幕 DPI 解释打印时按 300 DPI 重采样导致模糊。解决导出时把 DPI 设成 300同时把Canvas的LayoutTransform设成ScaleTransform(300.0/96.0, 300.0/96.0)让渲染尺寸同步放大。这样导出的 PNG 像素密度足够Word 里显示和打印都清晰。文件体积会大一些但公式图片本身不大可以接受。5. 进阶用缓存和异步渲染把编辑体验拉满公式编辑器的性能瓶颈不在解析在渲染。每次按键都重新解析、重新构建Canvas公式一多就卡。我的优化策略是两级缓存第一级缓存 LaTeX 串到表达式树的映射用Dictionarystring, Node存解析前先查第二级缓存表达式树到Canvas的映射用ConditionalWeakTableNode, Canvas存弱引用避免内存泄漏。这样同一个公式反复编辑时只有第一次走完整流程。异步渲染用在公式数量超过 100 的场景。把渲染任务丢到Task里后台线程构建Canvas完成后Dispatcher.Invoke更新 UI。注意Canvas是DispatcherObject不能在非 UI 线程创建所以后台线程只做解析和坐标计算生成一个轻量的RenderPlan对象UI 线程再根据RenderPlan快速构建Canvas。这个拆分让渲染耗时从 15ms 降到 3ms 左右。验证方法很简单在RichTextBox里粘贴一个包含 200 个公式的文档用Stopwatch测滚动一屏的耗时。优化前大概 120ms优化后能压到 30ms 以内。如果还慢检查CacheMode有没有生效以及UndoLimit是不是设太大了。最后说个习惯我每次改渲染器都会先跑一个包含 20 个边界公式的测试用例比如\frac{\sqrt{x^21}}{\sum_{i0}^{n} i}这种嵌套结构确认坐标没算错再提交。这个测试文件我放在项目根目录的TestCases文件夹里用[TestMethod]跑比手动点界面靠谱得多。希望帮到你。本文还有配套的精品资源点击获取