C# WPF数学公式编辑器:自绘渲染与盒子排版的离线方案
简介:这是一套基于 C# 与 WPF、运行于 .NET Framework 4.5.2 的数学公式编辑器项目源码,面向希望深入桌面端 UI 开发与公式排版实践的中级开发者、计算机专业学生及教育科研工具搭建者。项目围绕公式输入、实时预览、数学符号库、LaTeX/MathML 支持与拖放编辑等场景展开,可用于理解 XAML 界面布局、事件交互、渲染性能优化及多格式导出的完整实现链路。压缩包共 715 个文件、约 16.75MB,其中 209 个 cs 源文件承载核心逻辑,282 个 png 与 29 个 otf 提供图标和数学字体资源,15 个 xaml、28 个 baml 对应界面定义与编译资源,另有 dll、config、resx 等支撑运行时与配置。已有 357 人学习,读者可据此梳理 WPF 项目的分层结构、编辑器控件组织与公式渲染思路,并作为课程设计或工具类应用的参考蓝本。
1. 接手一个不能升级的老系统,公式怎么往里塞
去年帮一个做检测设备的团队处理过一个需求:他们的上位机是 C# WPF 写的,框架锁死在 .NET 4.5.2,客户要求报告模板里能插入分数、根号、上下标和积分这类数学公式。升级框架不可能,改报表引擎代价更大,最后能选的方案就是直接内嵌一个能自绘的数学公式编辑器。这份 C# WPF 数学公式编辑器源码,走的就是纯托管自绘路线:不依赖浏览器内核、不依赖外部渲染服务,公式模型、解析、排版、渲染全部在进程内完成,编译进 4.5.2 的工程就能用。它适合两类人:一类是被老框架绑住、必须做离线公式录入的桌面开发者;另一类是想搞清楚公式排版(基线、深度、括号伸缩)到底怎么算的人。下面按「拆结构、跑起来、讲排版、记坑」的顺序展开。
2. 骨架解剖与选型:为什么走 Canvas 自绘而不是套网页渲染
2.1 内嵌浏览器那条路,在 4.5.2 上走不通
桌面端做公式,第一反应往往是内嵌一个浏览器控件,页面里挂个 JS 公式排版库。这条路在 4.5.2 的 WPF 里问题很实在:内嵌浏览器控件用的是老内核,脚本引擎版本低,把现代公式库塞进去要么直接报语法错误,要么排版出来行高错乱;控件的滚动条、DPI 缩放和 WPF 的布局系统是两套坐标系,缩放到 150% 时公式和周围文字差半行;打印和导出更麻烦,网页内容转矢量要靠打印管线,出来的分式横线经常糊成一片。
自绘路线的代价是要自己实现字体度量、基线对齐和括号伸缩,好处是全部可控:公式是DrawingContext画出来的矢量图元,打印、导出位图、缩放都清晰;节点树可以序列化成文本,随时还原成可编辑状态;命中测试和光标定位可以直接在盒子树上做,不用跨进程或跨控件猜坐标。对于要嵌进报表、还要能二次编辑的场景,自绘是唯一讲得通的选择。
提示:判断标准很简单,如果你的公式只需要「显示」不需要「再编辑」,渲染成矢量图就够了;一旦要求用户点进公式里改一个下标,就必须有节点树和盒子树,网页方案在这块反而更绕。
2.2 三层结构:模型层、排版层、宿主层各管什么
扒开源码看,这套编辑器是清晰的三段式,中间用不可变的盒子结构传递布局结果。读源码建议按这个顺序走,不要一上来就啃渲染控件,容易被一堆坐标运算劝退。
| 层 | 主要职责 | 关键类型 | 能否替换 |
|---|---|---|---|
| 模型层 | 保存公式结构,支持增删改 | 节点基类、行节点、分式节点、上下标节点、根式节点 | 不建议动,动了排版层全部要跟 |
| 解析层 | 把 LaTeX 子集文本转成节点树 | 词法扫描、递归下降解析器 | 可换成别的语法,只要输出同样的节点树 |
| 排版层 | 把节点树量成盒子,算宽高和基线深度 | 布局引擎、盒子结构 | 核心,不建议重写 |
| 渲染层 | 把盒子树画到画布,处理 DPI 与字体 | 自绘控件、绘制访问器 | 可按需改绘制细节 |
| 宿主层 | 光标、选区、撤销栈、剪贴板 | 编辑控件、光标路径 | 可裁剪,只留插入不留编辑也行 |
前三层是纯计算,没有任何 WPF 依赖,这一点很关键:意味着你可以在服务端或单元测试里跑解析和排版,不需要创建窗口。宿主层才是和 WPF 绑定的部分,用FrameworkElement派生一个控件,重写MeasureOverride、ArrangeOverride、OnRender三个方法,把排版结果画出来。
2.3 .NET 4.5.2 卡住的几个 API,以及替代写法
老框架的限制不是「跑不起来」,而是「写法不一样」。下面这张表是我实际改代码时踩出来的差异,按它对照着改,比对着报错一条条搜要快得多。
| 想用的能力 | 4.5.2 现状 | 常规替代 |
|---|---|---|
| 值元组、解构 | 类型不在框架内,要靠额外的包 | 自定义只读结构体,或用out参数返回多值 |
| switch 类型模式匹配 | 语言版本通常停在 6 附近 | 用as加空判断,或访问者模式分发 |
| 文本度量的 DPI 参数重载 | 没有带每显示器 DPI 的重载 | 自己读呈现源的变换矩阵换算,见 3.3 |
| 每显示器 DPI 感知 | 无对应 API | 应用清单里声明系统级 DPI 感知,统一缩放 |
| 现代 JSON 序列化 | 框架内没有 | 数据契约序列化器,或自己写节点树的文本格式 |
语言版本这一条最容易翻车:你在新环境里写的模式匹配、局部函数,换到只装了老编译器的构建机上直接编译失败,而且报错位置常常指向无关行。我的习惯是把语言版本显式写进工程文件,团队里谁也别偷偷改,改之前先在构建机上验证一遍。
3. 跑起来:工程结构、编译卡点与把编辑器嵌进宿主窗口
3.1 目录怎么摆,哪些能删
拿到的包通常是「核心库 + 控件库 + 演示程序」的组合,演示程序只是外壳,真正要迁的是前两个。下面这张表对应常见目录,删之前先确认引用关系。
| 目录 | 内容 | 是否必须 |
|---|---|---|
| 核心库 | 节点模型、词法、解析器、布局引擎 | 必须,且建议单独成项目 |
| 控件库 | 自绘控件、绘制访问器、光标逻辑 | 必须 |
| 演示程序 | 主窗口、源码输入框、导出按钮 | 可选,用来验证 |
| 资源目录 | 字体文件、图标、示例公式清单 | 字体必须,其余可选 |
迁移时最常见的错误是把核心库和控件库合成一个项目,短期省事,后面想在无界面环境跑排版测试就傻眼了。分成两个项目,核心库保留纯计算,控件库引用核心库,演示程序只引用控件库,依赖方向单一,往后加功能不会互相牵扯。
3.2 工程文件里三个必须核对的字段
老项目升级文件格式后,目标框架字段经常被悄悄改掉,或者平台目标跟着默认值跑偏。生成之前先把这三项对齐,能省掉一半「在我这能跑」的扯皮。
<PropertyGroup> <!-- 框架版本必须锁死,改成更高版本在老机器上可能直接起不来 --> <TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion> <!-- 公式排版大量依赖字体度量,固定 x86 能让不同机器上的行高更一致 --> <PlatformTarget>x86</PlatformTarget> <!-- 语言版本显式声明,避免构建机编译器差异导致语法不支持 --> <LangVersion>6</LangVersion> </PropertyGroup>三个字段里,平台目标最容易被忽略。同一份代码在 x64 下跑,字体度量的舍入结果可能差零点几个像素,短公式看不出来,长公式里分式和上下标叠在一起时就会出现「横线压字」。固定平台目标不是洁癖,是让排版结果可复现。语言版本则决定了你能不能用某些语法糖,写的时候顺手,换台机器编译不过,排查成本远高于省下的那点代码量。
3.3 在宿主窗口里挂一个公式控件
控件本身是个普通FrameworkElement,可以直接放进任何布局容器。下面这段是演示程序里的最小接线:上面是公式显示区,下面是源码输入框,输入即解析,出错只标高亮位置,不清空已渲染内容。
<Grid> <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <!-- Padding 由控件内部消化,外层不要再套 Margin,否则基线会偏移 --> <local:FormulaView x:Name="Editor" Grid.Row="0" FontSize="24" Padding="12"/> <!-- 源码框:AcceptsReturn 打开,方便手写多行对齐的源码 --> <TextBox x:Name="Source" Grid.Row="1" Height="64" AcceptsReturn="True" TextChanged="OnSourceChanged"/> </Grid>private void OnSourceChanged(object sender, TextChangedEventArgs e) { try { // 内部链路:词法扫描 -> 递归下降解析 -> 布局量算 -> 失效重绘 Editor.Load(Source.Text); } catch (FormulaParseException ex) { // 只标红出错位置,保留上一次成功的渲染结果,避免用户打字时画面闪空 Editor.MarkError(ex.Position, ex.Message); } }Load里做了三件事:把文本解析成节点树、把节点树量成盒子树、让控件失效重绘。短公式上每次输入全量重算完全够用,三十个字符以内感觉不到延迟;长公式(比如带多层分式和求和符号的)就要加防抖,或者按 4.3 的思路只重排变化的子树。异常这里不要吞掉:解析失败时把位置传回去,控件在对应字符下画一条红线,比弹窗友好得多,用户改起来也快。
注意:
FontSize改的是公式整体的缩放基准,不是单个字符大小。分式的间距、分数线粗细、根号的钩子长度都应该按这个值等比推导,写死像素值的话,用户一改字号整个版面就散架。
4. 从文本到盒子:解析、布局与基线对齐的完整链路
4.1 节点模型:五类节点覆盖绝大多数公式
节点树不需要设计得很花哨,五种节点能覆盖九成以上场景:符号节点(单个字符或运算符)、行节点(水平串联)、分式节点、上下标节点、根式节点。括号伸缩不单独建节点,交给行节点上的一个「可伸缩定界符」标记处理,排版时按内容高度决定拉伸倍率。
public abstract class FormulaNode { public FormulaNode Parent { get; internal set; } public abstract string ToSource(); // 反向序列化,用于保存与剪贴板 } // 行节点:子节点按顺序水平排列,是最常用的容器 public sealed class RowNode : FormulaNode { public readonly List<FormulaNode> Children = new List<FormulaNode>(); } // 分式:分子分母各自是一棵子树 public sealed class FractionNode : FormulaNode { public FormulaNode Numerator, Denominator; } // 上下标:基座 + 可选的下标 / 上标,两者可以同时存在 public sealed class ScriptNode : FormulaNode { public FormulaNode Base, Sub, Sup; }Parent指针在插入和删除时同步维护,光标定位和局部重排都靠它向上找祖先。ToSource是后悔药:用户把公式复制到别处、过两天想改,只要能拿回源码文本就能重新解析,不需要序列化整棵树。位图剪贴板是单向的,源码才是双向的,这个设计在后期救过好几次。
4.2 递归下降解析:先把语法子集定死
解析器只认一个明确的子集,别想着兼容完整语法,那是个无底洞。我一般支持这些:字母数字、\frac{}{}、\sqrt{}、^、_、{}分组、\left( \right)定界符。其他的遇到就报错并给出位置,比默默渲染成乱码强。
// 解析一行内容,stop 为终止字符('{' 对应 '}',顶层传 '\0') private FormulaNode ParseRow(char stop) { var row = new RowNode(); while (_pos < _text.Length && _text[_pos] != stop) { char c = _text[_pos]; if (c == '\\') // 命令:\frac \sqrt \left ... { row.Children.Add(ParseCommand()); continue; } if (c == '{') // 分组,递归到匹配的右括号 { _pos++; var group = ParseRow('}'); _pos++; // 跳过 '}' row.Children.Add(group); continue; } if (c == '^' || c == '_') // 上下标,挂到前一个兄弟节点上 { AttachScript(row, c); continue; } row.Children.Add(new SymbolNode(c.ToString())); _pos++; } return row; }stop参数是整个递归下降的骨架:遇到{就带着}递归下去,回来时位置正好落在右括号后一位。AttachScript负责把行里最后一个子节点「升级」成上下标节点——这里有个边界要处理,如果^前面什么都没有(比如公式以^开头),就得补一个空基座,否则排版时找不到对齐参照,整行基线会跳。解析失败时抛出的异常要带上_pos,宿主层靠它画错误标记。
4.3 布局:Height 和 Depth 分开算,基线才不会乱
排版的核心是一个盒子结构,它不存绝对坐标,只存三个量:宽度、基线以上高度、基线以下深度。总高是两者之和,不是 Height,这个混淆是新手最容易犯的错,直接后果是含下标的公式行距忽大忽小。
public struct FormulaBox { public double Width; // 水平占位 public double Height; // 基线以上的高度 public double Depth; // 基线以下的深度(下标、分母会撑大它) public double TotalHeight { get { return Height + Depth; } } } // 分式布局:分数线中心压在数学轴上,分子分母各留一段呼吸空间 public FormulaBox LayoutFraction(FractionNode node) { var num = Layout(node.Numerator); var den = Layout(node.Denominator); double axis = FontSize * 0.25; // 数学轴高度,经验值约四分之一字号 double gap = FontSize * 0.12; // 分子/分母与横线的间距 double rule = Math.Max(1.0, FontSize / 24.0); // 分数线粗细随字号等比走 var box = new FormulaBox(); box.Width = Math.Max(num.Width, den.Width) + FontSize * 0.2; box.Height = axis + rule / 2 + gap + num.TotalHeight; box.Depth = gap + rule / 2 + den.TotalHeight - axis; box.Depth = Math.Max(box.Depth, 0); // 兜底:字号极小时防止负深度 return box; }参数不是拍脑袋定的:axis决定分数线在基线上的位置,取值偏大分式整体上浮,和同行文字对不齐;gap太小分子分母会贴住横线,打印出来糊成一坨。这两个值按字号等比推导,用户改字号时版面比例才不会变形。最后那个Math.Max是兜底,某些极端字号下算出来的深度会是负数,负深度往上传会让父节点的基线计算全错,表现出来就是「改小字号后整段文字往上飘」。
绘制时把原点放在基线上,所有子盒子按基线对齐再落笔,公式和周围文字的混排就自然了。宿主如果是富文本,需要把基线以上的高度作为对齐偏移传给排版容器;如果是自己画,直接按Padding + Height算原点 y 坐标。
4.4 导出:MathML 文本和位图各存一份
导出这一步决定了公式能不能离开编辑器活下去。位图适合贴进报表,MathML 适合给别的系统做二次解析,两份都留,用户想改的时候才有退路。序列化用XmlWriter逐节点写,注意语言版本限制,这里不能用模式匹配。
private static void WriteMathML(XmlWriter w, FormulaNode node) { var frac = node as FractionNode; if (frac != null) { w.WriteStartElement("mfrac"); WriteMathML(w, frac.Numerator); WriteMathML(w, frac.Denominator); w.WriteEndElement(); return; } var script = node as ScriptNode; if (script != null && script.Sup != null && script.Sub == null) { w.WriteStartElement("msup"); WriteMathML(w, script.Base); WriteMathML(w, script.Sup); w.WriteEndElement(); return; } // 其余情况统一退化成标识符节点,先保证结构完整,再逐步细化 w.WriteStartElement("mi"); w.WriteString(node.ToSource()); w.WriteEndElement(); }写元素之前先判类型,用as加空判断替代模式匹配,是因为目标框架常用的语言版本不支持case 类型 变量写法。上下标同时存在时要落到msubsup,上面只处理了单上标的情况,实际代码里还得补msub和msubsup两个分支。位图导出用RenderTargetBitmap,按缩放倍率把 DPI 一起抬高,否则高分屏上导出的图片会糊——这一点在第 5 章有专门的坑。
5. 避坑排查:五条流过血的经验
5.1 编译与环境类
现象:开发机上一切正常,装到客户机器上公式区域一片空白,控件高度却是对的。原因:排版用的数学字体没有随程序分发,目标机器缺字体时FormattedText度量返回零宽,绘制阶段直接跳过。解决:把字体文件作为资源嵌进程序集,用资源 URI 引FontFamily,同时准备一条回退链,在控件初始化时用FormattedText量一个已知字符,宽度为零就切下一条。字体授权要单独确认,嵌入分发和随系统安装是两回事。
现象:同事拉下代码编译报「功能在当前语言版本中不可用」,报错行指着一个无关的方法签名。原因:工程文件被改过语言版本,或者构建机装的是老编译器,?.、模式匹配这类语法直接不支持。解决:把语言版本写死进工程文件并纳入版本管理,构建脚本第一步先打印编译器版本;实在要用新语法,就抽成一个独立的小项目单独升级,不要拉着整个老工程一起冒险。
5.2 排版与渲染类
现象:高分屏(150% 缩放)下公式文字发虚,和旁边文本框的字重对不上。原因:4.5.2 的文本度量接口没有每显示器 DPI 参数,应用又没声明 DPI 感知,系统把整个窗口当位图拉伸。解决:在应用清单里声明系统级 DPI 感知,绘制时统一用设备无关单位,导出位图时按缩放倍率提高 DPI 并配合缩放变换。声明之后窗口不再被位图拉伸,文字立刻清晰,代价是你要自己处理所有像素相关的换算。
现象:行内公式插进一段文字后,行距变大,文字被顶下去半行。原因:宿主容器把公式控件当成普通矩形,按控件总高垂直居中,忽略了基线。解决:给控件暴露一个只读的基线偏移属性,宿主按它对齐;或者把公式封装成行内元素参与文本排版。这一步没做对,前面排版算得再准也白搭,表现在用户眼里就是「公式和文字不在一条线上」。
现象:大括号和小括号在长公式里拉伸得很生硬,转角处出现断裂。原因:括号被当成普通字符等比放大,字形里的曲线被拉变形。解决:把定界符拆成上钩、中段、下钩三段,中段做垂直重复或按需截取,上钩下钩保持原比例。这是排版引擎里的常规做法,也是自绘方案比网页方案更容易做对的地方。
5.3 交互与导出类
现象:公式复制到别的软件里变成一张图,过两天想改只能重打一遍。原因:剪贴板只放了位图格式。解决:一次放入多种格式——源码文本、MathML、位图,粘贴方按能力取。用户在自己的编辑器里粘贴拿到图,在我们自己的宿主里粘贴优先读源码文本,两条路都通。
现象:输入长公式时每敲一个字符卡一下,输入法上屏明显滞后。原因:每次文本变化都全量解析加全量重排,还顺手调了整控件失效。解决:文本变化加一个短防抖;布局按节点缓存盒子,文本没变的子树直接复用;重绘只失效受影响的矩形区域。改完之后同样的公式输入流畅度提升很明显,尤其是嵌了求和符号的模板。
注意:不要用「禁用输入法」这种办法绕开卡顿,中文用户会立刻发现打不出字。性能问题就老老实实做缓存和防抖。
6. 进阶:光标路径、命中测试和一套便宜的回归校验
光标不要存绝对坐标,存「从根到叶子节点的一串子索引」,比如[0, 2, 1]表示根节点第 0 个子节点的第 2 个子节点的第 1 个位置。这样做的直接好处是:插入、删除后路径只需局部修正,重排时把公共祖先以上的盒子全部复用,只重算变化的那条分支。实现上维护一个List<int> _caret,插入字符时比较新旧路径的最长公共前缀,前缀之后的祖先标记为脏,其余直接取缓存。
// 返回两条光标路径的最长公共前缀长度,用来决定从哪一层开始重排 private static int CommonPrefix(List<int> a, List<int> b) { int n = Math.Min(a.Count, b.Count), i = 0; while (i < n && a[i] == b[i]) i++; return i; }命中测试反过来走:从根盒子开始递归,先做矩形包含判断快速剪枝,再看子盒子,一路走到叶子;落在空白区域时,按水平距离吸附到最近的插入点,而不是返回「没命中」。用户点分式横线附近本来就是模糊操作,吸附比精确判断好用得多。这里有个细节,分式的分子分母区域在视觉上是分开的,但命中测试必须把它们当成两个独立可点区域,否则用户点分子会被判成点到了整个分式。
回归校验我一般做得比较土:把一批典型公式的源码存成纯文本清单,跑一个无界面测试,解析、布局、输出每个公式的宽、高、深度三个数,和基线快照比对,偏差超过半像素就报错。这套东西加起来不到一百行,但每次改排版参数都能立刻知道哪些公式受力了。血泪教训是有一次调分式间距,肉眼看的几个样例都好看,结果带三层嵌套分式的模板全炸了——因为里层深度变化会往上传,外层基线跟着漂,单看一个公式根本发现不了。从那以后我每次动布局引擎的常量,都强制把这个清单跑一遍再提交,希望帮到你。
本文还有配套的精品资源,点击获取