简介:这是一份基于C#、WPF与.NET Framework 4.5.2开发的数学公式编辑器项目源码,面向教育科研人员、桌面应用开发者及希望练习WPF界面与交互设计的学习者,可用于创建、编辑并实时预览数学公式,缓解公式排版与符号输入效率低的问题。压缩包共715个文件,约16.75MB,包含209个cs源码、15个xaml界面、282个png图标素材及27个dll依赖等,覆盖主程序、编辑模块、符号选择、历史记录与设置窗口等完整结构。已有357人学习,说明其作为WPF实战案例具有一定参考价值。读者可从中了解XAML布局、公式渲染、MathML/LaTeX处理、实时预览、符号库、拖放交互、保存导出与性能优化等实现思路,适合按模块研读并迁移到自己的桌面工具开发中。
1. 从一台升级不了的工控机说起
上个月接手一个公式编辑的活,客户现场的机器锁死在 .NET 4.5.2,装不了更高版本的运行时,网络还是内网隔离。原本方案是用浏览器内核套一个现成的在线公式编辑器,一到现场全部报废:既没有 WebView2 依赖,也没有把 LaTeX 渲染成图片的本地服务。最后翻出来一套纯 C# + WPF 写的数学公式编辑器,整个渲染链路跑在DrawingContext上,不依赖任何浏览器内核和第三方排版库,编译目标就是 .NET Framework 4.5.2,双击 exe 就能用。这正是标题里那个「C# WPF .NET4.5.2 版本的数学公式编辑器」要解决的问题场景,也是我后来把它当成模板反复改的原因:在低版本运行时的约束下,它把公式当成一棵可编辑的节点树来做,而不是把公式当成一张提前画好的图。这篇就按我自己拆这套源码的顺序,把结构、渲染、输入三个链路讲透,最后落到怎么改成你自己要的样子。
2. 工程骨架:四个项目与一棵公式节点树
拿到一个 WPF 项目,我的习惯是先看 sln 里分了几个工程、引用关系是单向还是互相缠。这个项目分得比较克制,渲染层和数据层彻底切开,没有出现「View 里直接 new 一个分式对象」这种耦合。
2.1 解决方案分层与目录结构
工程按职责切成四块,依赖方向单一,Core 不认识 WPF,这一点很关键,意味着公式模型层可以单独拿去跑单元测试,也可以脱离界面做批量转换。
FormulaEditor.sln ├─ FormulaEditor.Core // 纯 C# 类库,不引用 PresentationFramework │ ├─ Nodes/ // RowNode、FractionNode、SqrtNode、ScriptNode、SymbolNode │ ├─ Editing/ // EditorModel、CaretPosition、CommandStack │ └─ Parsing/ // LatexSubsetParser,递归下降 ├─ FormulaEditor.Rendering // 引用 Core + PresentationCore │ ├─ FormulaContext.cs // 字号、字体、前景色、数学轴高度 │ ├─ FormulaView.cs // FrameworkElement,重写 OnRender │ └─ Export/ // PngExporter、XamlExporter ├─ FormulaEditor.App // WPF 主程序,工具栏 + 编辑区 + 预览面板 └─ FormulaEditor.Tests // 对 Core 的排版数值做断言这个切法里最值钱的是FormulaEditor.Rendering单独成一个工程。很多人写 WPF 自绘组件喜欢把绘制逻辑写进主程序的 UserControl 里,等到要做「批量把文档里的公式导出成 PNG」时就傻眼了,因为那段绘制代码跟窗口生命周期绑死了。这里FormulaView只负责「拿到一个根节点,画出来」,导出模块复用同一个渲染入口,RenderTargetBitmap直接对着离屏的DrawingVisual渲染即可。
FormulaEditor.Core不引用PresentationFramework,但排版总要算字体宽度,怎么解决?做法是在 Core 里定义一个轻量接口ITextMeasurer,只暴露MeasureText(string, double fontSize)返回宽高,Rendering 层用一个基于FormattedText的实现去注入。这样 Core 保持纯净,测试里塞一个假实现就能断言「分式宽度 = max(分子宽, 分母宽) + 2 * 内边距」。
2.2 节点树:把公式当树而不是一堆控件
新手最容易想到的方案是:分式就用一个Grid,上面放分子TextBlock,中间一根Border当分数线,下面放分母,然后靠Grid.Row拼。这个方案在小 demo 里能跑,一旦嵌套三层根号加两层上下标就彻底失控,因为 WPF 布局系统不认识「基线」这个概念,上下标的对齐全部要靠手算 Margin,改字体大小就全线崩塌。
这套源码走的是另一条路:所有公式元素继承自一个抽象节点,节点自己负责测量和绘制,父节点只关心子节点的「尺寸」和「基线」两个结果。数据结构上就是一棵普通的树。
// FormulaEditor.Core/Nodes/FormulaNode.cs public abstract class FormulaNode { // 排版产物:占位大小 + 基线(相对节点顶部,向下为正) public Size Size { get; protected set; } public double Baseline { get; protected set; } protected FormulaContext Ctx { get; private set; } public void Measure(FormulaContext ctx) { Ctx = ctx; MeasureCore(ctx); } // 子类只实现这一个方法,父节点不关心你怎么算 protected abstract void MeasureCore(FormulaContext ctx); // origin 是节点左上角在画布上的位置 public abstract void Render(DrawingContext dc, Point origin); }Baseline是整个设计的支点。分式的基线是分数线,上下标的基线跟随主体,根号的基线是根号内部内容的基线。父节点做水平拼接时,只需要把所有子节点的基线拉到同一水平线上,不用管它们各自多高多矮。RowNode 的MeasureCore大概长这样:
protected override void MeasureCore(FormulaContext ctx) { double width = 0, above = 0, below = 0; foreach (var child in Children) { child.Measure(ctx); width += child.Size.Width + Spacing; // Spacing 是字间距,按字号比例给 above = Math.Max(above, child.Baseline); // 基线以上的最大高度 below = Math.Max(below, child.Size.Height - child.Baseline); } Baseline = above; // 整行基线 = 最高那个子节点的基线 Size = new Size(width, above + below); }above和below的拆解是这套写法能成立的关键:把「节点高度」拆成基线以上和基线以下两段,横向拼接时取两个方向的最大值再合并,避免了一个 3 倍行高的根号把整行文字顶歪。这种写法在数学排版里是标准做法,浏览器内核做行内公式也走同一套思路。
2.3 4.5.2 上的技术选型取舍
为什么不用现成的方案?在 4.5.2 这个约束下,可选项其实很窄。
| 方案 | 4.5.2 可行性 | 主要问题 |
|---|---|---|
| 内嵌浏览器 + 前端公式库 | 不可行 | 没有 WebView2,老 IE 控件渲染引擎太旧,字体回退不可控 |
| 引用第三方数学排版库 | 视情况 | 多数库依赖更高版本的 BCL 或依赖 GDI+ 的字体度量,跨机器字体缺失就崩 |
WPFFlowDocument的公式能力 | 不可行 | 4.5.2 的 FlowDocument 不支持数学标记,只能画字符 |
自绘节点树 +DrawingContext | 可行 | 全部代码可控,字体缺失能兜底,导出链路自己写 |
选最后一条不是因为它先进,而是因为它可控。字体缺失时你可以降级到系统默认字体继续画,而不是抛异常给用户看。同一条渲染链路可以同时喂给屏幕和RenderTargetBitmap,导出 PNG 时不用重写一遍绘制代码。至于导出矢量格式,直接用XamlWriter.Save把DrawingGroup序列化成 XAML,绝大多数矢量编辑工具都能吃进去,这个技巧在低版本环境里非常实用。
一个容易被忽略的细节是语言版本。项目文件里加了<LangVersion>7.3</LangVersion>,用 VS2017 以上打开就能写模式匹配和out var,但目标框架仍然锁在 4.5.2。这意味着你能用新语法糖,但不能用ValueTuple、Span<T>、IAsyncEnumerable这些依赖新 BCL 的类型。血泪经验:先确认目标框架,再决定语法甜度,别写着写着发现(int, int)编译不过。
3. 渲染链路:一次 Measure 与 OnRender 的完整走位
渲染这块是整个项目最值得抄的部分。屏幕上看到的每一次闪烁、每一处上下标错位,根子都在这两个方法里:Measure算尺寸,Render落笔。把这一节读明白,你改任何公式结构都不会心里没底。
3.1 分式与根号的几何计算
先看分式,它是所有结构里最简单也最典型的一个。难点不在画线,在于「分数线该压在哪」。如果按节点垂直居中去画,配上上下标就会显得忽高忽低,因为数学排版里有一条隐形的「数学轴」,通常取字号高度的四分之一左右,分子底边和分母顶边都围绕这条轴对称分布。
// FormulaEditor.Core/Nodes/FractionNode.cs public sealed class FractionNode : FormulaNode { public FormulaNode Numerator { get; set; } public FormulaNode Denominator { get; set; } private const double Gap = 3.0; // 分子到线的留白,实际按字号比例缩放 private const double Pad = 4.0; // 分数线左右外扩,防止贴边 protected override void MeasureCore(FormulaContext ctx) { Numerator.Measure(ctx); Denominator.Measure(ctx); double gap = Gap * ctx.FontSize / 16.0; double pad = Pad * ctx.FontSize / 16.0; double w = Math.Max(Numerator.Size.Width, Denominator.Size.Width) + pad * 2; double barOffset = Numerator.Size.Height + gap; // 分数线距节点顶部 Size = new Size(w, barOffset + gap + Denominator.Size.Height); Baseline = barOffset + gap / 2; // 基线落在分数线附近 } public override void Render(DrawingContext dc, Point origin) { double barY = origin.Y + Numerator.Size.Height + Gap * Ctx.FontSize / 16.0; double pad = Pad * Ctx.FontSize / 16.0; // 分子分母各自水平居中,父节点只给左上角坐标 Numerator.Render(dc, new Point( origin.X + (Size.Width - Numerator.Size.Width) / 2, origin.Y)); Denominator.Render(dc, new Point( origin.X + (Size.Width - Denominator.Size.Width) / 2, barY + Gap)); dc.DrawLine(Ctx.BarPen, new Point(origin.X + pad, barY), new Point(origin.X + Size.Width - pad, barY)); } }代码里Gap和Pad都乘了ctx.FontSize / 16.0,这是让留白随字号线性缩放。写死像素值的话,用户把字号从 16 调到 40,分数线会和分子黏在一起,看起来像渲染 bug,其实是常量没跟着缩放。这是我改这套源码时踩的第一个坑。
Ctx.BarPen是提前创建并Freeze()过的Pen。WPF 里凡是Freezable的子类(Pen、Brush、Geometry、Typeface相关的部分对象)都应该冻结,冻结之后既能跨线程访问,又不会被布局系统反复校验,滚动大量公式时帧率差别很明显。
根号的算法稍复杂一点,因为它要「拉伸」。常见做法是用PathGeometry手工构造:先算出一段固定形状的勾(√ 的左半部分),然后把中间的横线部分按内容宽度拉长,最后接上被开方内容的边界。关键在于勾的高度要跟随内容高度动态调整,而不是把 √ 这个字符用ScaleTransform粗暴拉伸,那样线条粗细会变形,视觉上很廉价。
protected override void MeasureCore(FormulaContext ctx) { Content.Measure(ctx); double hookW = ctx.FontSize * 0.55; // 勾的宽度,随字号走 double overhang = ctx.FontSize * 0.12; // 横线上方留白 Size = new Size(Content.Size.Width + hookW + overhang, Content.Size.Height + overhang); Baseline = Content.Baseline + overhang; // 基线跟随内部内容 }Baseline = Content.Baseline + overhang这一行是让根号内部的公式和外面的公式站在同一条基线上,否则整行文字会被根号顶得上下跳动。
3.2 FormattedText 在 4.5.2 上的写法差异
叶子节点最终要画字符,4.5.2 上只能用FormattedText配合DrawingContext.DrawText。这里有个版本差异必须知道:.NET 4.6.2之后FormattedText多了一个带pixelsPerDip的构造函数重载,而 4.5.2 上只有六参数版本,高 DPI 屏上文字会有一点点模糊,需要靠TextOptions.SetTextFormattingMode在窗口级别做补偿。
// FormulaEditor.Rendering/TextMeasurer.cs internal sealed class TextMeasurer : ITextMeasurer { // Typeface 构造代价高,静态复用一次即可 private static readonly Typeface MathFace = new Typeface(new FontFamily("Cambria Math"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal); public Size MeasureText(string text, double fontSize) { var ft = new FormattedText( text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, MathFace, fontSize, Brushes.Black); // 测量阶段颜色随便给,不影响尺寸 return new Size(ft.WidthIncludingTrailingWhitespace, ft.Height); } }注意WidthIncludingTrailingWhitespace,不是Width。数学符号里经常带空白(比如∫后面的间距),用Width会让相邻元素挤在一起,这是文字排版类项目里非常高频的翻车点。
字体这块要给个兜底。数学字体在部分精简版系统上是不全的,代码里做了字体回退链:优先数学字体,取不到就退到通用的衬线字体,再取不到用系统默认。回退逻辑放在FormulaContext初始化时做一次判断,不要每次绘制都去查,那会让大文档滚动明显掉帧。
private static FontFamily ResolveMathFont() { var candidates = new[] { "Cambria Math", "Latin Modern Math", "Times New Roman" }; foreach (var name in candidates) { // 简单粗暴地尝试构造,取不到会抛,捕获后继续下一个 try { return new FontFamily(name); } catch (ArgumentException) { /* 该机器没有这个字体,继续找 */ } } return new FontFamily("SimSun"); // 最后兜底,保证能画出来 }3.3 光标定位与命中测试
可编辑的公式最麻烦的不是画,是「点哪算哪」。用户点一下分母,光标要进到分母里面,按左箭头能退到分子,按 Tab 跳到下一个可插入槽位。这套源码的处理方式是把光标抽象成一个「槽位坐标」:
// FormulaEditor.Core/Editing/CaretPosition.cs public struct CaretPosition : IEquatable<CaretPosition> { public FormulaNode Parent; // 当前所在的父节点 public int SlotIndex; // 父节点的第几个可插入槽位 public int Offset; // 槽位内的字符偏移(RowNode 用) }命中测试从根节点往下递归,每层判断点击点是否落在某个子节点的矩形内,命中就下钻,直到叶子节点。为了让「点分式的分数线附近也能进编辑」这件事成立,命中矩形会向外扩几个像素,也就是所谓的「热区扩展」。
public CaretPosition? HitTest(Point p, Point origin) { var rect = new Rect(origin, Size); // 向外扩 3px,让细线条也能被点到 rect.Inflate(3, 3); if (!rect.Contains(p)) return null; foreach (var child in GetChildrenWithOrigin(origin)) { var hit = child.Node.HitTest(p, child.Origin); if (hit.HasValue) return hit; } return new CaretPosition { Parent = this, SlotIndex = 0, Offset = 0 }; }这个Inflate(3, 3)是纯体验优化,但移除它之后,用户点分数线、点根号的那条竖笔,十次有八次点不进去,反馈「编辑器难用」的抱怨会集中在这里。
4. 输入链路:按键到结构命令的翻译层
绘制再漂亮,输入不顺手就等于没有。这套编辑器的输入设计参考了常见数学编辑器的习惯:先打字符,再打结构符号触发「把已有内容包进去」,而不是让用户先去工具栏点一个分式再往两个空槽里填。
4.1 从 TextInput 到结构命令
入口只有一个OnTextInput,所有字符走同一个分发器。这里有个细节:中文输入法下e.Text可能是多字符,必须逐字符处理并显式设置e.Handled = true,否则输入法候选窗口和编辑器会抢焦点。
// FormulaEditor.App/FormulaEditorControl.xaml.cs protected override void OnTextInput(TextCompositionEventArgs e) { if (string.IsNullOrEmpty(e.Text)) return; foreach (char c in e.Text) { switch (c) { case '/': // 把光标所在槽位已有内容整体变成分子,光标进分母 _model.SplitFraction(); break; case '^': _model.ApplySuperscript(); break; case '_': _model.ApplySubscript(); break; case '\\': // 反斜杠进入 LaTeX 命令输入模式,等后续字符 _model.BeginCommandInput(); break; default: _model.InsertText(c.ToString()); break; } } e.Handled = true; // 必须吃掉,不然输入法会重复投递 InvalidateVisual(); }SplitFraction的实现要点是「非破坏性」:如果光标处已经有内容,就把它整体作为分子的第一个子节点,然后新建一个空的行节点作为分母,光标定位到分母的第一个槽位;如果是空的,就新建两个空行。这样用户输入a/b时会先得到字母 a,打/之后自动变成 a 在上的分式,整个过程没有回退动作,符合肌肉记忆。
上下标的处理稍微绕一点:ApplySuperscript要检查当前节点的父节点是不是ScriptNode,如果是,就把「上标」改成「上下标同时存在」的结构,而不是再套一层。否则用户连续打两个^会得到一层套一层的畸形结构,视觉上两个字叠不到一起。
4.2 槽位遍历与 Tab 跳转
Tab 键在公式编辑器里不是缩进,是「跳到下一个空位」,这个交互几乎所有数学编辑器都这么做。实现依赖节点树的前序深度优先遍历:
public IEnumerable<CaretPosition> EnumerateSlots() { for (int i = 0; i < SlotCount; i++) { yield return new CaretPosition { Parent = this, SlotIndex = i, Offset = 0 }; foreach (var child in GetSlotContent(i).EnumerateAllNodes()) foreach (var slot in child.EnumerateSlots()) yield return slot; } }拿到扁平化后的槽位列表,Tab 就是列表下标 +1 取模,Shift+Tab 是 -1 取模。注意SlotCount的定义:分式是 2,根号是 1,上下标是 2(上标位、下标位),普通行节点的槽位数按字符数走。这个设计的好处是光标永远落在合法位置,不会出现「光标跑进一个不存在的槽位」把模型搞坏。
4.3 撤销栈与两种导出
撤销用最朴素的双栈结构,每次修改前把整个根节点做一次深拷贝压栈。公式节点树规模很小,几百个节点量级,深拷贝的开销完全可以接受,比实现命令模式加逆操作省事得多,也不容易出 bug。栈深度限制 200 步,超出就丢最老的,防止内存随着长时间编辑线性增长。
public void Execute(Action mutation) { _undo.Push(_root.Clone()); // 先存快照再改 _redo.Clear(); mutation(); if (_undo.Count > MaxHistory) TrimOldest(); }导出两条路。PNG 走RenderTargetBitmap,注意 DPI 参数要跟屏幕无关,固定按 96 * 缩放倍数算,否则在 125% 缩放的机器上导出的图会比预期小一圈,这是很经典的坑。
public void ExportPng(FormulaNode root, string path, double scale = 2.0) { var ctx = FormulaContext.Default; root.Measure(ctx); // 96 DPI 是基准,scale 控制清晰度,不要跟系统缩放挂钩 var bitmap = new RenderTargetBitmap( (int)Math.Ceiling(root.Size.Width * scale), (int)Math.Ceiling(root.Size.Height * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32); var visual = new DrawingVisual(); using (var dc = visual.RenderOpen()) { dc.PushTransform(new ScaleTransform(scale, scale)); root.Render(dc, new Point(0, 0)); dc.Pop(); } bitmap.Render(visual); // 同一套 Render 代码,屏幕和导出复用 var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var fs = File.Create(path)) encoder.Save(fs); }矢量导出走DrawingGroup收集,再XamlWriter.Save序列化。这里的前提是所有绘制都通过DrawingContext完成,没有直接操作Visual子元素,否则 XAML 序列化会丢内容。这也是为什么前面强调绘制逻辑必须收敛到Render(dc, origin)一个方法里。
提示:
XamlWriter.Save在部分安全策略下会被限制,走这条导出路线前先在目标机器上试一次,别等交付当天才发现。
5. 避坑排查:上线前必须验证的五件事
这套代码在我手里前后改了三版,遇到的坑基本集中在字体、DPI、输入法三个方向。下面五条都是现象明确、原因清楚、能当场解决的,照着排查能省不少时间。
5.1 换台机器公式就变糊、字符间距变了
现象:开发机上正常,部署到现场机器后公式明显模糊,部分符号挤在一起。
原因:现场机器没有安装数学字体,回退到了系统默认字体,字宽完全不同,之前算好的布局数值全部失效。同时 4.5.2 的FormattedText没有pixelsPerDip参数,高 DPI 下不做补偿就会发虚。
解决:字体回退链必须在程序启动时就确定下来并缓存,不能每帧查;窗口级别设置TextOptions.SetTextFormattingMode(this, TextFormattingMode.Ideal),并在app.manifest里声明 DPI 感知,否则系统会做一次位图拉伸,再怎么调都糊。
5.2 中文输入法打不出符号,或者打出双份
现象:用拼音输入法在公式里打字母,出现两个字符,或者候选框消失。
原因:OnTextInput没有把e.Handled置为true,事件继续向上冒泡,父级控件又处理了一遍。另外输入法组合期间e.Text可能为空字符串,没做判空会走进foreach空转,看起来像卡了一下。
解决:入口处对空串直接 return,处理完一定设Handled = true;如果要做更细的控制,可以监听TextCompositionManager.TextInputStart记录组合状态,组合中不响应结构命令,避免用户打拼音时误触发分式。
5.3 大文档滚动掉帧,公式越多越卡
现象:单个公式很流畅,一页放二十个公式开始明显卡顿,滚动时像幻灯片。
原因:Render里每次都在 newPen、newFormattedText、newTypeface。FormattedText的构造会走一遍字体度量,代价不低,一帧几十次就是灾难。
解决:把所有Freezable对象提升到FormulaContext里创建一次并Freeze();文本测量结果按「字符串 + 字号」做一层字典缓存,命中率在公式场景下极高,因为符号重复度很大。
5.4 导出的 PNG 比屏幕上小一圈
现象:屏幕上看着挺大,导出图片只有一半尺寸,文字边缘还有锯齿。
原因:RenderTargetBitmap的 DPI 参数误用了系统 DPI 或者屏幕缩放比例,导致逻辑单位和物理像素换算错位。
解决:固定用 96 作为基准 DPI,乘以缩放系数,跟系统缩放彻底解耦;同时PushTransform的缩放要和 DPI 参数保持一致,两处不一致就会出现「画得大但画布小」的裁切现象。
5.5 撤销之后光标跑到了开头
现象:Ctrl+Z 撤销一次,内容回去了,但光标跳回公式最前面,继续打字会插错位置。
原因:撤销时把根节点整体替换成了快照,CaretPosition里存的是旧节点的引用,引用失效后只能重置到默认位置。
解决:快照里连光标位置一起存,恢复时按「节点路径」重新定位而不是按对象引用。实现上给每个节点加一个稳定的Id,路径就是Id序列,恢复时按路径重新走一遍树。这套机制加上去之后,撤销体验才真正能用。
6. 二次开发:从改一个符号模板到接自己的语法
真要在这套代码上做二次开发,我建议从最小的改动开始:改一个已有节点的绘制,跑通整条链路,再去动模型。最典型的入口是SqrtNode,把它复制一份改成BracketNode,实现带伸缩的方括号或圆括号,改动量小但会把 Measure、Render、HitTest、EnumerateSlots 四个方法全部走一遍。
具体做法是给节点加一个OpenChar/CloseChar字段,MeasureCore里取内容高度,把括号字符用ScaleTransform只做纵向拉伸(横向保持不变,避免笔画变粗),Baseline跟随内容。改完之后在LatexSubsetParser里注册对应的定界符命令,输入\left(时就能生成这个节点。
往深一层是接自定义语法。解析器是标准的递归下降,词法层只有一个NextToken,语法层每个结构对应一个ParseXxx方法。要加一个「矩阵」结构,就新增MatrixNode和对应的ParseMatrix,在ParseAtom的分支表里挂上命令名即可,不需要动渲染层。这也是分层带来的好处:模型和渲染解耦之后,加结构只写一处。
| 改造目标 | 要动的文件 | 工作量判断 |
|---|---|---|
| 换配色、字号、行距 | FormulaContext.cs | 改常量即可,注意留白要随字号缩放 |
| 加一种新结构(矩阵、求和) | 新增节点 + 解析分支 | 中等,四个方法都要实现 |
| 换成 LaTeX 全量子集 | Parsing/整个目录 | 较大,建议先做词法表驱动 |
| 导出成 MathML | 新增Export/MathMLExporter.cs | 中等,节点树遍历一遍即可 |
| 嵌入到自己的 WPF 窗体 | 引用 Rendering 工程 | 小,把FormulaView当普通控件用 |
验证方式我一般不用肉眼盯,而是先写一条最小链路测试:构造a/b这个分式,断言Size.Width等于两个字符宽度的较大值加内边距,Baseline落在分数线附近。数值断言能跑通,再去界面上看效果。改渲染之前先让测试跑绿,改完再跑一遍,哪一步把布局算歪了立刻能定位,比在界面上反复试快得多。
源码包的结构就是上面这几层,用 Visual Studio 打开 sln 直接可编译,目标框架已锁在 .NET Framework 4.5.2。拿到后建议先跑一次导出 PNG 的自测按钮,链路通了再动手改,能少走很多弯路。
有个习惯我一直保持着:任何一次改动只要碰了MeasureCore,就必须先在测试里把那几个数值断言跑一遍,绝不靠眼睛判断布局对不对。公式排版这种地方,差两三个像素肉眼看不出来,但排到一行里就是明显的高低不平。希望这套拆解能帮到你,也欢迎把自己改造出来的节点结构拿去对比。
本文还有配套的精品资源,点击获取