
简介这是一套基于 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 !-- 框架版本必须锁死改成更高版本在老机器上可能直接起不来 -- TargetFrameworkVersionv4.5.2/TargetFrameworkVersion !-- 公式排版大量依赖字体度量固定 x86 能让不同机器上的行高更一致 -- PlatformTargetx86/PlatformTarget !-- 语言版本显式声明避免构建机编译器差异导致语法不支持 -- LangVersion6/LangVersion /PropertyGroup三个字段里平台目标最容易被忽略。同一份代码在 x64 下跑字体度量的舍入结果可能差零点几个像素短公式看不出来长公式里分式和上下标叠在一起时就会出现「横线压字」。固定平台目标不是洁癖是让排版结果可复现。语言版本则决定了你能不能用某些语法糖写的时候顺手换台机器编译不过排查成本远高于省下的那点代码量。3.3 在宿主窗口里挂一个公式控件控件本身是个普通FrameworkElement可以直接放进任何布局容器。下面这段是演示程序里的最小接线上面是公式显示区下面是源码输入框输入即解析出错只标高亮位置不清空已渲染内容。Grid Grid.RowDefinitions RowDefinition Height*/ RowDefinition HeightAuto/ /Grid.RowDefinitions !-- Padding 由控件内部消化外层不要再套 Margin否则基线会偏移 -- local:FormulaView x:NameEditor Grid.Row0 FontSize24 Padding12/ !-- 源码框AcceptsReturn 打开方便手写多行对齐的源码 -- TextBox x:NameSource Grid.Row1 Height64 AcceptsReturnTrue TextChangedOnSourceChanged/ /Gridprivate 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 ListFormulaNode Children new ListFormulaNode(); } // 分式分子分母各自是一棵子树 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 个位置。这样做的直接好处是插入、删除后路径只需局部修正重排时把公共祖先以上的盒子全部复用只重算变化的那条分支。实现上维护一个Listint _caret插入字符时比较新旧路径的最长公共前缀前缀之后的祖先标记为脏其余直接取缓存。// 返回两条光标路径的最长公共前缀长度用来决定从哪一层开始重排 private static int CommonPrefix(Listint a, Listint b) { int n Math.Min(a.Count, b.Count), i 0; while (i n a[i] b[i]) i; return i; }命中测试反过来走从根盒子开始递归先做矩形包含判断快速剪枝再看子盒子一路走到叶子落在空白区域时按水平距离吸附到最近的插入点而不是返回「没命中」。用户点分式横线附近本来就是模糊操作吸附比精确判断好用得多。这里有个细节分式的分子分母区域在视觉上是分开的但命中测试必须把它们当成两个独立可点区域否则用户点分子会被判成点到了整个分式。回归校验我一般做得比较土把一批典型公式的源码存成纯文本清单跑一个无界面测试解析、布局、输出每个公式的宽、高、深度三个数和基线快照比对偏差超过半像素就报错。这套东西加起来不到一百行但每次改排版参数都能立刻知道哪些公式受力了。血泪教训是有一次调分式间距肉眼看的几个样例都好看结果带三层嵌套分式的模板全炸了——因为里层深度变化会往上传外层基线跟着漂单看一个公式根本发现不了。从那以后我每次动布局引擎的常量都强制把这个清单跑一遍再提交希望帮到你。本文还有配套的精品资源点击获取