
简介这是一份可直接运行的C# WPF数学公式编辑器完整工程基于.NET Framework 4.5.2构建主要面向希望深入WPF桌面应用开发的学习者也适用于教育、科研与论文排版中有公式编辑需求的用户。项目实现了公式输入、LaTeX/MathML解析与转换、实时预览、符号面板和拖放交互等常用功能并能将公式导出为图片、PDF、MathML或LaTeX源码解决方案内包含MathTools.sln主工程、WpfApp1启动项目以及Editor编辑器模块源码组织清晰便于理解界面层与逻辑层如何协同。包内共715个文件压缩后16.75MB核心文件包括209个C#源代码文件、15个XAML界面定义、28个BAML编译资源、282个PNG图标与素材、OTF数学字体、依赖DLL及PDF说明文档兼顾源码、构建产物与参考材料便于按需查阅。目前已有357人学习下载。通过阅读和二次开发这份完整代码可以系统掌握XAML布局与样式、数据绑定、命令路由、Canvas绘图渲染、数学公式排版和文件导入导出等关键知识点也能在此基础上扩展私人符号库或定制专属公式模板。1. 一个 .NET 4.5.2 的公式编辑器难点从来不在“编辑”两个字上C# WPF 环境下做数学公式编辑器最容易让新手误判的是以为难点在输入框、按钮、符号面板这些界面上。真正做过一轮就会发现把“x^2”变成右上角的小字号数字、把“\frac{a}{b}画成带横线的上下结构、把积分号连同上下限排得视觉协调这背后是一整套从字符串到排版图形的转换链路。用 .NET 4.5.2 做这件事意味着你拿不到 .NET Core 时代的跨平台渲染优势也没有现代框架里的现成公式控件可抄近路会在 Win32、WPF 渲染层和字体系统之间反复折腾。这篇文章面向想从零搭一个公式编辑器、又受限于 .NET Framework 老版本环境的开发者把分词、语法树、布局、绘制、输入法这些环节的顺序和坑讲清楚。2. 渲染方案选型图片拼接、浏览器内核还是自绘排版2.1 三种常见路线和它们的边界做公式编辑器第一件事不是写代码是选渲染路径。常见的有三条把公式渲染成图片再贴进 WPF嵌入浏览器控件配合 MathJax 渲染完全用 WPF 自绘排版。图片路线的实现最快把 LaTeX 丢给外部渲染服务拿回一张 PNG 扔到 Image 控件里。但这个方案在公式编辑器场景下几乎是死路用户要选中公式里的某个字符做修改图片给不了字符级命中用户缩放窗口位图发糊用户要复制公式源码还得额外维护图片和 LaTeX 的映射关系。浏览器内核路线在 .NET 4.5.2 时代用的是 WebBrowser 控件它包装的是 IE 内核渲染 MathJax 的老版本还能跑但输入法、键事件和 WPF 的焦点管理全被 IE 的文档模型劫持公式编辑框内部的键盘导航很难做干净。这个方案并不是不能用只是你要花大量时间处理浏览器与 WPF 之间的事件透传和焦点同步实验成本不低。自绘路线是把 WPF 当成一块画布自己解析 LaTeX 字符串构建公式结构树再用 GlyphRun、FormattedText、DrawingVisual 这些底层绘制 API 把每个字符画到屏幕上。这条路前期代码量大但一旦跑通字符级选中、光标定位、矢量缩放、复制粘贴 LaTeX 全部可以做到你想要的控制粒度后续做无障碍访问也有基础。对公式编辑器这种重排版、重交互的控件自绘是性价比最高的长期选择。2.2 我为什么在 .NET 4.5.2 下选 GlyphRun 自绘WPF 里显示文本有三层 API。TextBlock 最简单但它的排版模型是“整行文本”做不到让一个分数内部的分子居中于分数线、让上下标精确偏移到指定基线FormattedText 比 TextBlock 灵活能测量字符宽度、能逐字符绘制但对“无限宽度居中”这类特殊排布支持有限GlyphRun 是 WPF 里和字体字形直接对应的绘制单元可以指定每个字符的字形索引、每个字符的绘制位置、每个字符的缩放比例自由度最高。在 .NET 4.5.2 的 WPF 里GlyphRun 构造起来并不复杂它的核心参数是 GlyphTypeface、字符索引数组和基线偏移数组。有了 GlyphRun你可以把一个字符画在任意坐标、按任意比例缩放分式和上下标这种结构就有了操控空间。选 GlyphRun 而不是 FormattedText 的另一个原因是命中测试GlyphRun 本身携带每个字符的几何信息做光标定位时可以直接计算某个坐标落在第几个字符的包围盒里粒度精确到字符而 FormattedText 的命中测试要绕几层接口才能拿到类似结果。2.3 整体分层语法树、布局树与绘制层自绘路线的整体架构分成三层。第一层是语法树层也就是 AST负责把 LaTeX 字符串解析成结构化的节点比如分数节点、根号节点、上下标节点、普通文本节点第二层是布局树层负责测量每个节点的尺寸、决定横向纵向位置、对齐基线第三层是绘制层把布局树的结果转成 GlyphRun 和 DrawingVisual 输出到屏幕。这三层必须彻底解耦。我见过太多把解析和绘制混在一起的实现在 LaTeX 字符串里一边扫描一边画字符最后光标移位、选中高亮、缩放重排全都变得不可收拾。正确的节奏是字符串进来先建 ASTAST 稳定后跑一遍测量得到每个节点的尺寸再跑一遍排列得到坐标最后才进绘制。任何一次编辑只要改动了 AST整套动作重来但因为三层各自独立单测可以逐层写清楚。3. 从 LaTeX 字符串到语法树分词与递归下降解析3.1 先定义 AST 节点模型设计 AST 时节点类型不宜过多够覆盖常用公式结构就行。我给一个最小可用的分类RowNode 代表一行内横向排列的一组子节点TextNode 代表一段普通文本FractionNode 代表分数分子分母各是一个 RowNodeSqrtNode 代表根号ScriptNode 代表上标或下标NaryNode 代表带上下限的大运算符如求和、积分。每个节点都持有自己的子节点列表这样整棵树的遍历和递归测量才会统一。public abstract class FormulaNode { public ListFormulaNode Children { get; } new ListFormulaNode(); } public class RowNode : FormulaNode { } public class TextNode : FormulaNode { public string Text { get; set; } } public class FractionNode : FormulaNode { public FormulaNode Numerator { get; set; } public FormulaNode Denominator { get; set; } } public class SqrtNode : FormulaNode { public int Degree { get; set; } public FormulaNode Body { get; set; } } public class ScriptNode : FormulaNode { public FormulaNode Base { get; set; } public FormulaNode Sup { get; set; } public FormulaNode Sub { get; set; } }这个模型把分数和根号这类二目结构拆成了显式属性而不是塞进 Children 列表后续测量代码可以直接拿到分子分母引用不用再遍历列表去猜哪个是分子。Children 列表保留下来是为了统一做子树遍历比如做整体缩放或者批量替换节点时能用一个递归函数扫全树。3.2 词法分析把 LaTeX 拆成 Token 流LaTeX 源码本质上是一串带反斜杠命令的文本。词法分析要做的就是把 \frac{a}{b}^2 拆成一个个 Token命令 Token、左花括号、右花括号、字符 Token、上标标记、下标标记。这一层不需要懂公式的结构只负责切分。public enum TokenType { Command, LBrace, RBrace, Superscript, Subscript, Character } public class Token { public TokenType Type { get; set; } public string Value { get; set; } } public static ListToken Tokenize(string latex) { var tokens new ListToken(); for (int i 0; i latex.Length; i) { char c latex[i]; if (c \\) { var sb new StringBuilder(); i; while (i latex.Length char.IsLetter(latex[i])) { sb.Append(latex[i]); i; } i--; tokens.Add(new Token { Type TokenType.Command, Value sb.ToString() }); } else if (c { || c }) { tokens.Add(new Token { Type c { ? TokenType.LBrace : TokenType.RBrace, Value c.ToString() }); } else if (c ^ || c _) { tokens.Add(new Token { Type c ^ ? TokenType.Superscript : TokenType.Subscript, Value c.ToString() }); } else { tokens.Add(new Token { Type TokenType.Character, Value c.ToString() }); } } return tokens; }这段代码里最需要注意的是反斜杠命令的读取循环遇到反斜杠后一直读字母直到非字母字符为止。LaTeX 命令名只含字母所以这个边界是成立的。注意循环末尾的 i-- 是为了把最后一个非字母字符留给主循环去处理否则会跳过一个正常字符。这个细节错了公式源码里命令后面的左花括号会被吞掉解析结果当场错乱。3.3 递归下降解析把 Token 流组合成 ASTToken 流变成 AST 用递归下降最直观。解析的核心逻辑是遇到一个命令就根据命令名决定要消费多少个后续 Token遇到普通字符就往当前行的 RowNode 里追加 TextNode遇到上下标标记就把当前行最后的那个节点摘出来包成 ScriptNode。整个过程就是一边扫描 Token、一边维护一个“当前行容器”的递归操作。public class LatexParser { private ListToken _tokens; private int _pos; public FormulaNode Parse(string latex) { _tokens Tokenize(latex); _pos 0; return ParseRow(); } private RowNode ParseRow() { var row new RowNode(); while (_pos _tokens.Count) { Token t _tokens[_pos]; if (t.Type TokenType.RBrace) break; if (t.Type TokenType.Command) { _pos; if (t.Value frac) { var frac new FractionNode(); _pos; frac.Numerator ParseGroup(); _pos; frac.Denominator ParseGroup(); row.Children.Add(frac); } else if (t.Value sqrt) { var sqrt new SqrtNode(); _pos; sqrt.Body ParseGroup(); row.Children.Add(sqrt); } else { row.Children.Add(new TextNode { Text t.Value }); } } else if (t.Type TokenType.Character) { row.Children.Add(new TextNode { Text t.Value }); _pos; } else if (t.Type TokenType.Superscript || t.Type TokenType.Subscript) { _pos; var script new ScriptNode(); var last row.Children[row.Children.Count - 1]; row.Children.RemoveAt(row.Children.Count - 1); script.Base last; if (t.Type TokenType.Superscript) { _pos; script.Sup ParseGroup(); } else { _pos; script.Sub ParseGroup(); } row.Children.Add(script); } else { _pos; } } return row; } private FormulaNode ParseGroup() { if (_tokens[_pos].Type TokenType.LBrace) { _pos; var row ParseRow(); if (_tokens[_pos].Type TokenType.RBrace) _pos; return row; } return ParseRow(); } }ParseRow 是解析器的核心循环它的退出条件是碰到右花括号否则会把当前层级的所有节点堆进 RowNode。ParseGroup 负责处理花括号遇到左花括号就递归进去遇到没加花括号的单个字符也走同一个 ParseRow这样 x^2 和 x^{2} 都能正确处理。上下标处理时有个关键动作把当前 RowNode 的最后一个节点摘出来作为 Base这一步用 RemoveAt 而不是直接替换否则会把前面已经排好的兄弟节点一起吞掉。3.4 解析层的三个边界点第一个边界是命令后面不带花括号的情况比如 \frac12 在 LaTeX 里表示分子为 1、分母为 2。上面这段解析器遇到这种情况会走 ParseGroup 里的 ParseRow 分支把单个字符当作一个组结果是正确的。第二个边界是连续上下标比如 x^2^3标准 LaTeX 会报错解析器这里会让后一个上标覆盖前一个实际使用中需要在 UI 层给提示而不是在解析器里硬拼。第三个边界是花括号不配对的输入解析器会直接遇到 Token 越界解析层必须返回错误信息而不是抛异常给上层UI 才能给出友好的公式修正提示。4. 从语法树到可视化公式测量、布局与绘制4.1 布局节点与测量契约语法树描述“公式是什么”布局树描述“公式长什么样”。布局阶段需要给每个 AST 节点算出三个东西自身尺寸宽度和高度、基线在自身坐标系里的垂直位置、子节点的排列坐标。这三个值互相关联所以布局节点不能只存尺寸必须存 basline 偏移。我用的布局基类很薄每个节点实现两个方法Measure 和 Arrange。Measure 只算理想尺寸Arrange 拿到上层分配的位置后把自身坐标落定并继续排子节点。这样的好处是测量可以缓存用户拖动窗口缩放时只要重新 Arrange不需要重新走一轮递归测量性能差异在复杂公式上非常明显。4.2 递归测量每个节点先量自己再量孩子每种节点类型的测量逻辑不同但都遵循同一个递归模式先量孩子再根据孩子尺寸算自身尺寸。RowNode 的测量最简单把所有子节点的宽度加起来高度取最大值基线跟随第一个子节点。TextNode 的测量直接用 FormattedText 拿到宽高基线位置通过字体指标换算。FractionNode 是重头戏分子和分母各自测量后分数总宽度取两者较大值总高度是分子高度加分母高度再加一条横线的厚度和间距基线落在横线中心。public class LayoutNode { public double Width { get; set; } public double Height { get; set; } public double Baseline { get; set; } public double X { get; set; } public double Y { get; set; } public ListLayoutNode Children { get; } new ListLayoutNode(); } public class FractionLayout : LayoutNode { public LayoutNode NumeratorLayout { get; set; } public LayoutNode DenominatorLayout { get; set; } public void Measure(double fontSize) { double childSize fontSize * 0.8; NumeratorLayout.Measure(childSize); DenominatorLayout.Measure(childSize); double lineThickness Math.Max(1.0, fontSize * 0.06); double gap Math.Max(2.0, fontSize * 0.12); Width Math.Max(NumeratorLayout.Width, DenominatorLayout.Width); Height NumeratorLayout.Height DenominatorLayout.Height lineThickness 2 * gap; Baseline NumeratorLayout.Height gap lineThickness / 2; } }注意分数内部子节点的字号不是继承父节点的而是乘以 0.8 得到缩小后的字体尺寸。这是公式排版的惯例分式内部字符比正文小一号上下标再减一号。gap 和 lineThickness 都要用 fontSize 做比例计算而不是写死像素值否则公式放大到 200% 的时候分数线还是 1 像素视觉效果会失衡。这些参数的取值参考了印刷公式的宽高比实际调的时候可以先按 0.8、0.12、0.06 起手再根据渲染效果微调。4.3 水平排布与基线对齐公式排版最容易翻车的地方是基线对齐。普通文本行的基线由字体决定但公式里有分数线、根号、上下标这些特殊结构每一段都有自己的基线。RowNode 做横向排布时所有子节点的 Baseline 都必须映射到同一垂直坐标否则分子和分母就是歪的。public void Arrange(double baseX, double baseY, double fontSize) { double cursorX baseX; foreach (var child in Children) { child.X cursorX; child.Y baseY - child.Baseline; cursorX child.Width GetItalicCorrection(child); } }Arrange 的核心思路是外层传入一个基线坐标 baseY每个子节点把自己的 Baseline 对准这条线所以自身左上角的 Y 坐标就是 baseY 减去自己的 Baseline 值。这样不管是普通字符、分数还是根号视觉上都是“坐在同一条基线上”。GetItalicCorrection 是给斜体字母右侧预留的间隙修正不做这一步f 后面的 ( 就会贴得太近视觉上像是粘在一起。4.4 用 DrawingVisual 绘制到 WPF 可视层布局完成后绘制层的任务是把 LayoutNode 的位置信息转成 GlyphRun。绘制层不关心公式结构只关心“哪个字符画在哪个坐标、用什么字号、什么字体”。public class FormulaDrawingVisual : DrawingVisual { public void Render(FormulaNode root, double fontSize) { using (DrawingContext dc RenderOpen()) { RenderNode(dc, root, fontSize, 0, 0); } } private void RenderNode(DrawingContext dc, FormulaNode node, double fontSize, double x, double y) { if (node is TextNode text) { var formatted new FormattedText( text.Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface(new FontFamily(Cambria Math), FontStyles.Italic, FontWeights.Normal, FontStretches.Normal), fontSize, Brushes.Black, 1.0); dc.DrawText(formatted, new Point(x, y)); } else if (node is FractionNode frac) { var fracLayout BuildFractionLayout(frac, fontSize); dc.DrawLine(new Pen(Brushes.Black, fracLayout.LineThickness), new Point(x, y fracLayout.Baseline - fracLayout.LineThickness / 2), new Point(x fracLayout.Width, y fracLayout.Baseline - fracLayout.LineThickness / 2)); } } }这段代码给出的是绘制层的骨架逻辑。TextNode 用 FormattedText 直接画因为它最简单且间距处理成熟特殊结构如分数则先构建布局拿到分数线位置再用 DrawLine 画横线。实际工程里普通字符要换成 GlyphRun 绘制原因前面说过GlyphRun 能拿到每个字符的几何信息后续光标插入、字符选中高亮都要依赖它FormattedText 在这方面的接口支持不够直接。5. .NET 4.5.2 实战避坑字体、DPI、光标与输入法的五个坎5.1 Cambria Math 的 GlyphTypeface 读取失败现象new GlyphTypeface(new Uri(file:///C:/Windows/Fonts/CambriaMath.ttf)) 抛 FileNotFoundException或者读出来的 GlyphTypeface 字符映射是空的。原因Cambria Math 在不同 Windows 版本里安装路径有差异而且字体名字区分英文和本地化显示名写死路径的做法在非中文系统上必然翻车。解决用 Fonts.FontFamilies 遍历系统字体按字体名匹配到 FontFamily 后再拿 GlyphTypeface。private static GlyphTypeface GetMathGlyphTypeface() { foreach (FontFamily family in Fonts.SystemFontFamilies) { if (family.Source.Contains(Cambria Math)) { foreach (Typeface typeface in family.GetTypefaces()) { if (typeface.TryGetGlyphTypeface(out GlyphTypeface glyph)) return glyph; } } } throw new InvalidOperationException(Cambria Math not found); }这里有两个坑。第一个是 family.Source 在不同区域设置下显示的是本地化名称用 Contains(Cambria Math) 匹配英文原名的做法在非英文系统上可能失效稳妥的做法是同时匹配 Cambria Math 和本地区域名称。第二个是 TryGetGlyphTypeface 必须用 out 参数的版本直接拿 Typeface 上的属性在 .NET 4.5.2 里容易触发 NotSupportedException因为是延迟加载。5.2 DPI 缩放导致公式模糊现象在 150% 缩放的屏幕上公式整体发虚文字边缘有锯齿。原因WPF 本身是矢量渲染理论上不该模糊但如果你用了 DrawingVisual 并把渲染结果缓存到 RenderTargetBitmap 做滚动优化位图缓存一旦建立缩放后就只能走插值。解决滚动优化不要用位图缓存直接用 DrawingVisual 重绘如果实在需要缓存用 RenderOptions.SetBitmapScalingMode 设置 NearestNeighbor 或 HighQuality并在 DPI 变化时清缓存重建。另一个隐蔽坑是 PerMonitorV2 DPI 感知没有启用。.NET 4.5.2 的 WPF 程序默认是 System DPI 感知窗口从 100% 缩放的屏幕拖到 150% 屏幕时WPF 的布局系统不会自动重算公式文字的显示尺寸不变但落在高分屏的物理像素上就显得糊。解决在 app.manifest 里声明 PerMonitorV2然后监听 DpiChanged 事件触发公式控件强制重新 Measure 和 Arrange。这一步不做公式编辑器在双屏办公环境里基本没法用。5.3 光标闪烁与命中测试抖动现象点击公式中间位置光标没有落在点击处而是跳到公式末尾连续点击同一位置时光标位置漂移几个像素。原因早期实现里命中测试只判断“点击坐标是否落在某个节点的包围盒内”没有精确到字符级别所以点击分子左半边时命中的是整个分子节点光标默认取节点最右侧。解决实现逐字符命中测试遍历 GlyphRun 拿每个字符的 Bounds找到包含点击坐标的那个字符再把字符索引映射回 AST 里的节点路径。这个问题的复杂度不在算法而在索引映射。AST 到布局树不是一一对应的一个 TextNode 经过布局后可能拆成多个 GlyphRun因为字号不同或字体不同反向映射时要在布局节点上记录“源 AST 节点引用”。没有这个引用命中测试结果就无法传回编辑逻辑光标定位和选中高亮都会断链。5.4 中文输入法在公式区的组合态干扰现象在公式编辑框里输入中文时输入法候选框还在组合拼音公式区就开始重排字符乱跳确认上屏后多出半个拼音字母。原因公式编辑器通常监听 TextInput 事件这个事件在输入法组合过程中会多次触发每次携带的是当前拼音串的一部分被当成最终输入处理了。解决判断输入事件的 IsComposing 状态组合态期间的输入不进入公式解析器只暂存到候选缓冲等组合结束再一次性提交。实现上有两个细节。一是 WPF 的 TextCompositionEventArgs 有 Composition 阶段但你自己的公式控件如果继承自 FrameworkElement 而不是 TextBox 这类文本控件需要手动处理 IME 的 composition 事件监听 TextInput 是不够的还要处理 ImeComposition 相关消息。二是在组合态期间要锁住公式的撤销栈否则恢复操作会把拼音串当成公式字符回滚产生不可预期的状态。5.5 内存回收频繁重建 GlyphRun 的 GC 压力现象连续输入 20 多个字符后内存涨了上百 MBGC 频繁触发输入卡顿。原因每次 TextChanged 都触发完整重排重排时新建大量 FormattedText 和 GlyphRun 对象这些对象内部持有字体资源和 GDI 句柄。解决引入一个按“字体 字号 文本内容”为 key 的 GlyphRun 缓存同一段文本重复出现时直接复用同时控制重排频率输入间隙小于 100ms 时用 DispatcherTimer 做防抖等用户停笔再重排。private static readonly Dictionarystring, GlyphRun GlyphCache new Dictionarystring, GlyphRun(); private GlyphRun GetGlyphRun(string text, double fontSize, GlyphTypeface glyph, bool italic) { string key ${text}|{fontSize}|{italic}; if (GlyphCache.TryGetValue(key, out GlyphRun cached)) return cached; ushort[] glyphIndices new ushort[text.Length]; double[] advanceWidths new double[text.Length]; for (int i 0; i text.Length; i) { glyphIndices[i] glyph.CharacterToGlyphMap[text[i]]; advanceWidths[i] glyph.AdvanceWidths[glyphIndices[i]] * fontSize; } var run new GlyphRun(glyph, 0, false, fontSize, glyphIndices, new Point(0, 0), advanceWidths, null, null, null, null, null); GlyphCache[key] run; return run; }缓存 key 里的 italic 参数很容易漏掉同一段文本斜体和非斜体的字形索引完全不同漏掉这个字段会导致公式里的斜体变量被画成正体整个公式的风格会分裂。另外缓存要做上限控制LRU 或者定时清理都行否则输入一长串唯一公式时缓存也会成为新的内存压力源。6. 验证与进阶让编辑器真正可用的一步6.1 公式正确性的三层验证方法公式编辑器最怕的不是实现不出来而是实现出来之后不知道对不对。我建议分三层验证。第一层是 AST 结构验证把一段 LaTeX 解析成 AST再对 AST 做序列化输出把输出结果和输入的 LaTeX 做归一化对比。比如输入 \frac{a}{b}序列化后应该还原成同样的字符串。第二层是渲染对比验证把同一段公式分别用你实现的渲染器和某图像处理 Demo 做离线渲染对比对比时不要直接比像素差异而是对比字符包围盒的位置误差误差容限设成 ±0.5 像素超过就打印具体字符位置偏差。第三层是交互验证模拟用户光标移动和字符删除检查光标位置和字符索引的一致性。6.2 从渲染结果到矢量导出公式编辑器做到能显示之后导出往往是下一步需求。这里提醒一句直接把 DrawingVisual 渲染到 RenderTargetBitmap 得到的是像素图放大到 200% 就糊论文投稿场景根本不能用。正确的导出方式是保存布局树然后按布局树逐节点生成矢量输出。最简单可靠的是输出 XPS因为 .NET 4.5.2 有原生支持构造一个 DocumentPaginator 然后调用 XpsDocumentWriter 就能把 DrawingContext 的内容写进去需要 SVG 的话就得自己遍历布局树拼字符串工作量大一些但可控。做这个编辑器我最大的教训是会忍不住先写界面再补解析最后发现界面连不上数据推倒重来。现在已经养成的习惯是先跑通“LaTeX 字符串进、AST 出、渲染到 DrawingVisual”这条最小管线再往上面加输入框和符号面板。任何一次改动触发的重排都以 AST 变化为准界面只做通知。别相信“先实现后重构”能帮你省时间公式编辑器这种结构复杂度高的控件架构理顺了后面每一步都是顺着走的。调整好这些细节希望帮到你。本文还有配套的精品资源点击获取