.NET 4.5.2 WPF 自研 LaTeX 公式编辑器:AST 与盒模型渲染
简介面向教育、科研与桌面开发学习者的C# WPF数学公式编辑器源码项目基于.NET Framework 4.5.2重点解决公式录入、排版预览与符号复用等需求适合具备C#基础、希望进阶WPF界面与交互开发的中级读者。压缩包共715个文件约16.75MB以209个cs源码、15个xaml界面、28个baml编译资源及282个png图示为主并含dll、exe、配置文件与Visual Studio解决方案便于直接运行和对照调试。已有357人学习下载。项目围绕公式编辑核心流程展开涵盖LaTeX/MathML输入转换、实时预览、数学符号库、拖放插入、历史工具栏以及多格式保存导出等模块目录按主程序与编辑组件拆分适合用来梳理WPF控件布局、事件处理、渲染优化和资源组织的完整实践路径。1. 为什么 .NET 4.5.2 上还要自己写公式编辑器一个老项目的真实约束在 .NET 4.5.2 的 C# WPF 项目里加数学公式编辑能力第一件反直觉的事是真正难的不是把公式画出来而是决定公式用什么结构表示。WPF 没有公式控件FlowDocument 只认 Run、Paragraph 这类一维文本元素分式、根号、上下标这些二维结构要么外包给浏览器控件要么自己搭一套模型。这里采取的路线是用 LaTeX 子集当输入语法解析成抽象语法树再用自绘 FrameworkElement 按盒模型渲染最后通过 InlineUIContainer 嵌进 RichTextBox。它适合两类人维护老旧桌面文档系统、被要求补公式能力的 C# 开发者以及想弄明白排版引擎里盒子、基线、度量到底怎么算的 WPF 从业者。下面按解析、渲染、集成、避坑、导出五段拆。2. 公式内核用 LaTeX 子集加 AST 把公式变成可计算对象2.1 输入语法选型为什么不用 MathML 或 OMML 直接上手选型这一步决定了后面所有工作量所以先讲清楚为什么不直接上标准格式。MathML 是 W3C 标准语义完整但 WPF 没有原生渲染器在 .NET 4.5.2 上要么写 XSLT 把它转成 XAML 元素树要么把一个 WebBrowser 塞进窗口里挂外部渲染脚本。前者转换规则多得像无底洞后者受限于老旧浏览器内核滚动、打印、DPI 缩放都会各走各的。OMML 是 Office 那套 XML优势是和 Word 剪贴板能互通缺点是要完整支持它等于把 Office 公式模块重写一遍。LaTeX 子集是折中里最划算的教师和学生几乎都认识 \frac、\sqrt、^、_字符串好存、好 diff、好做全文检索解析器完全可控报错位置能精确到字符下标。代价是语义弱于 MathML但对“文档里插公式”这个场景足够。常见做法是把 LaTeX 当主语法MathML 和 OMML 只做导入导出的旁路不进主链路。提示如果需求里明确要和 Word 文档互转就别省 OMML 这一层先把 OMML 的 m:f、m:rad、m:sSup 三类节点映射做出来覆盖八成常见公式。2.2 词法分析与递归下降解析把字符串拆成可报错的记号流解析分两步走。第一步词法分析把输入切成记号第二步递归下降按优先级拼树。先定义记号类型注意 Command 要单独一类因为反斜杠开头的命令和普通运算符的处理完全不同。// Token.cs一次扫描产出的最小单元 internal enum TokenKind { Number, // 123、3.14 Ident, // x、y、sin 这类标识符 Operator, // - * / LBrace, // { RBrace, // } Command, // \frac、\sqrt、\text End // 输入结束哨兵 } internal sealed class Token { public TokenKind Kind { get; set; } public string Text { get; set; } public int Position { get; set; } // 字符下标用于报错定位 }// FormulaLexer.cs只认 LaTeX 子集遇到不认识的字符抛异常 internal sealed class FormulaLexer { private readonly string _src; private int _i; public FormulaLexer(string src) { _src src ?? string.Empty; _i 0; } public Token Next() { while (_i _src.Length char.IsWhiteSpace(_src[_i])) _i; if (_i _src.Length) return new Token { Kind TokenKind.End, Text string.Empty, Position _i }; char c _src[_i]; // 反斜杠命令\frac 这类命令名只取连续字母 if (c \\) { int start _i; var sb new StringBuilder(); while (_i _src.Length char.IsLetter(_src[_i])) sb.Append(_src[_i]); if (sb.Length 0) throw new FormulaParseException(反斜杠后缺少命令名, start); return new Token { Kind TokenKind.Command, Text sb.ToString(), Position start }; } // 数字允许小数点不处理科学计数法公式里基本用不到 if (char.IsDigit(c)) { int start _i; while (_i _src.Length (char.IsDigit(_src[_i]) || _src[_i] .)) _i; return new Token { Kind TokenKind.Number, Text _src.Substring(start, _i - start), Position start }; } if (c {) { _i; return new Token { Kind TokenKind.LBrace, Text {, Position _i - 1 }; } if (c }) { _i; return new Token { Kind TokenKind.RBrace, Text }, Position _i - 1 }; } if (-*/^_.IndexOf(c) 0) { _i; return new Token { Kind TokenKind.Operator, Text c.ToString(), Position _i - 1 }; } if (char.IsLetter(c)) { int start _i; return new Token { Kind TokenKind.Ident, Text _src.Substring(start, 1), Position start }; } throw new FormulaParseException(无法识别的字符: c, _i); } }词法层的关键取舍是“单字符标识符”。数学公式里 x、y、a、b 基本都是单字母变量多字母的函数名sin、cos、log走命令或专门的函数表不在词法层合并。这样xy会被解析成两个变量相乘符合数学书写习惯。2.3 递归下降与节点模型优先级从低到高排好编辑就变成重建子树优先级顺序是加减最低、乘除其次、上下标再次、原子最高。写成四个方法互相调用比调度场算法好调试得多出错时栈上能直接看出卡在哪一层。// FormulaParser.cs递归下降左结合 internal sealed class FormulaParser { private readonly FormulaLexer _lexer; private Token _cur; public FormulaParser(string src) { _lexer new FormulaLexer(src); } public FormulaNode Parse() { _cur _lexer.Next(); FormulaNode node ParseAdditive(); if (_cur.Kind ! TokenKind.End) throw new FormulaParseException(多余的字符: _cur.Text, _cur.Position); return node; } private void Advance() { _cur _lexer.Next(); } private FormulaNode ParseAdditive() { FormulaNode left ParseMultiplicative(); while (_cur.Kind TokenKind.Operator (_cur.Text || _cur.Text -)) { string op _cur.Text; Advance(); left new BinaryNode(op, left, ParseMultiplicative()); } return left; } private FormulaNode ParseMultiplicative() { FormulaNode left ParseScript(); while (_cur.Kind TokenKind.Operator (_cur.Text * || _cur.Text /)) { string op _cur.Text; Advance(); left new BinaryNode(op, left, ParseScript()); } return left; } // 上下标x_i^2 这种要先吃下标再吃上标顺序写反会漏解析 private FormulaNode ParseScript() { FormulaNode baseNode ParseAtom(); FormulaNode sup null; FormulaNode sub null; while (_cur.Kind TokenKind.Operator (_cur.Text ^ || _cur.Text _)) { string mark _cur.Text; Advance(); FormulaNode operand ParseAtom(); if (mark ^) sup operand; else sub operand; } if (sup null sub null) return baseNode; return new ScriptNode(baseNode, sup, sub); } private FormulaNode ParseAtom() { Token t _cur; switch (t.Kind) { case TokenKind.Number: Advance(); return new TextRunNode(t.Text, false); // 数字用正体 case TokenKind.Ident: Advance(); return new TextRunNode(t.Text, true); // 变量用斜体 case TokenKind.LBrace: Advance(); FormulaNode inner ParseAdditive(); if (_cur.Kind ! TokenKind.RBrace) throw new FormulaParseException(花括号没有闭合, t.Position); Advance(); return inner; case TokenKind.Command: return ParseCommand(t); default: throw new FormulaParseException(这里不该出现: t.Text, t.Position); } } private FormulaNode ParseCommand(Token cmd) { Advance(); switch (cmd.Text) { case frac: return new FractionNode(ParseGroup(), ParseGroup()); case sqrt: return new RadicalNode(ParseGroup(), null); // 二次根号 case text: return new TextRunNode(ReadRawGroupText(), false); default: throw new FormulaParseException(暂不支持的命令: \\ cmd.Text, cmd.Position); } } }节点模型这边全部做成不可变对象编辑公式时不是改节点而是“重建从根到改动点的那条路径”。这个决定看着多写代码实际省掉一堆状态同步问题撤销栈里只要存根节点引用旧树还是完整的渲染层拿到的树永远不会在中途被别的线程改掉。// FormulaNode.cs不可变节点字段 readonly public abstract class FormulaNode { public abstract void Accept(IFormulaVisitor visitor); } public sealed class TextRunNode : FormulaNode { public readonly string Text; public readonly bool Italic; // 变量斜体数字与 \text 正体 public TextRunNode(string text, bool italic) { Text text; Italic italic; } public override void Accept(IFormulaVisitor v) { v.VisitTextRun(this); } } public sealed class FractionNode : FormulaNode { public readonly FormulaNode Numerator; public readonly FormulaNode Denominator; public FractionNode(FormulaNode num, FormulaNode den) { Numerator num; Denominator den; } public override void Accept(IFormulaVisitor v) { v.VisitFraction(this); } } public sealed class ScriptNode : FormulaNode { public readonly FormulaNode Base; public readonly FormulaNode Superscript; // 可为 null public readonly FormulaNode Subscript; // 可为 null public ScriptNode(FormulaNode b, FormulaNode sup, FormulaNode sub) { Base b; Superscript sup; Subscript sub; } public override void Accept(IFormulaVisitor v) { v.VisitScript(this); } } public sealed class RadicalNode : FormulaNode { public readonly FormulaNode Radicand; public readonly FormulaNode Index; // null 表示平方根 public RadicalNode(FormulaNode r, FormulaNode idx) { Radicand r; Index idx; } public override void Accept(IFormulaVisitor v) { v.VisitRadical(this); } }3. 渲染层在 WPF 里把 AST 画成公式的度量与排版3.1 自定义 FrameworkElementMeasure 和 OnRender 各管一半渲染层不用 UserControl直接继承 FrameworkElement 重写 MeasureOverride 和 OnRender。原因是公式本身没有子控件走 Visual 层比走 Control 模板轻嵌进 RichTextBox 时也不会因为样式继承把字号、前景色搞乱。// FormulaPresenter.cs只负责量尺寸和画内容 public sealed class FormulaPresenter : FrameworkElement { public static readonly DependencyProperty FormulaProperty DependencyProperty.Register(Formula, typeof(FormulaNode), typeof(FormulaPresenter), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender)); public FormulaNode Formula { get { return (FormulaNode)GetValue(FormulaProperty); } set { SetValue(FormulaProperty, value); } } protected override Size MeasureOverride(Size availableSize) { if (Formula null) return new Size(0, 0); double em FontSize 0 ? FontSize : 14.0; LayoutBox box FormulaLayout.Measure(Formula, em); // 宽度不约束公式允许横向溢出后由外层 ScrollViewer 处理 return new Size(box.Width, box.Height); } protected override void OnRender(DrawingContext dc) { if (Formula null) return; double em FontSize 0 ? FontSize : 14.0; var ctx new RenderContext(dc, em); FormulaLayout.Draw(Formula, ctx, new Point(0, 0)); } }度量结果必须是一个带基线信息的结构体而不是两个 double。// LayoutBox.csAscent/Descent 是公式排版的对齐依据 internal struct LayoutBox { public double Width; // 水平总宽 public double Ascent; // 基线以上高度 public double Descent; // 基线以下高度取正值 public double Height { get { return Ascent Descent; } } }这里有个血泪经验早期版本只用 Width 和 Height结果分式嵌套两层以后分数线自己往上飘排查半天才发现是拼接时用了 Height 对齐而不是 Ascent 对齐。公式排版和普通文本排版一样基线是唯一可靠的对齐锚点任何一行内容都要能回答“我的基线在哪”。3.2 分式、上下标、根号的几何参数参数别拍脑袋全部按 em当前字号比例定义换字号时整体等比缩放视觉一致性才稳。下面这张表是调过几轮后定下来的默认值。参数名默认值单位作用FractionBarThickness0.06em分数线粗细FractionGap0.12em分子到线、线到分母的垂直间距FractionPadding0.08em分数线左右超出内容区的宽度ScriptScale0.70倍上下标字号缩放比例SupShift0.42em上标基线相对主基线的抬升量SubShift0.20em下标基线相对主基线的下沉量RadicalGap0.10em根号上横线与被开方式的间距RadicalBarThickness0.05em根号上横线粗细以 20 号字为例em 等于 20分数线的粗细就是 1.2 像素上下各留 2.4 像素空隙。这个量级在 96 DPI 屏幕上刚好不糊也不挤字号放到 40 以上时线宽会自动变成 2.4 像素不会出现“大字配细线”的廉价感。上下标的偏移量要和 ScriptScale 联动看。上标缩放 0.7 之后字高只有主字号的 0.7 倍如果抬升量还按 0.6 em 给上标就会顶到上一行行框外面。经验值是 SupShift 大约等于 ScriptScale 的 0.6 倍SubShift 取其一半这样 x^2 和 x_2 在同一行里不会打架。3.3 字体回退与基线对齐别把 Cambria Math 当必然存在公式字体首选 Cambria Math它覆盖了数学运算符区、希腊字母区和箭头区。但目标机器有没有这个字体不能假设。开发机上装了 Office 所以有客户机上可能只有系统自带的那批。字体回退链建议写成Cambria Math → Cambria → Times New Roman → SimSun逐个用 Fonts.SystemFontFamilies 或者 FontFamily.FamilyNames 判断可用性选中第一个可用项之后再交给 FormattedText 排版。// 选一个可用的公式字体避免目标机器缺字体导致字形回退到宋体 private static FontFamily PickMathFont() { string[] candidates { Cambria Math, Cambria, Times New Roman, SimSun }; var installed new HashSetstring(StringComparer.OrdinalIgnoreCase); foreach (FontFamily ff in Fonts.SystemFontFamilies) foreach (var name in ff.FamilyNames.Values) installed.Add(name); foreach (string name in candidates) if (installed.Contains(name)) return new FontFamily(name); return new FontFamily(Segoe UI); }另外两个渲染开关容易被忽略。TextFormattingMode 设成 Display 会让小字号对齐像素网格公式里 0.7 倍缩放的上下标容易突然跳到整数像素位置间距看上去一跳一跳的设成 Ideal 则按理想字形宽度排缩放平滑。TextRenderingMode 设成 ClearType 时斜体变量边缘会更清晰但导出到位图后可能出现彩边导出前要临时切回 Grayscale。这两个属性都挂在 TextOptions 上作用在 FormulaPresenter 实例级别即可。4. 编辑器落地RichTextBox、嵌入容器与序列化4.1 InlineUIContainer 嵌入用 Border 包一层承载元数据RichTextBox 的 FlowDocument 里能放 UIElement 的只有 InlineUIContainer而且它只接受一个子元素。所以常规做法是外面套一个 BorderPadding 用来做公式与正文的呼吸感Tag 挂一个自定义对象保存 LaTeX 原文和公式 ID序列化和命中测试都靠它。// 在光标处插入一个公式 private void InsertFormula(FormulaNode root, string latex) { var presenter new FormulaPresenter { Formula root, FontSize _rtb.FontSize, Foreground _rtb.Foreground }; var host new Border { Child presenter, Background Brushes.Transparent, // 必须有背景才好吃鼠标事件 Padding new Thickness(2, 0, 2, 0), Focusable true, Tag new FormulaInlineTag { Id Guid.NewGuid(), Latex latex } }; var container new InlineUIContainer(host, _rtb.CaretPosition); _rtb.CaretPosition container.ElementEnd; // 光标跳到公式右侧 _rtb.Focus(); }Backgroud 设成 Transparent 这一步不能省。默认 null 的 Border 不参与命中测试用户点公式毫无反应会以为是渲染层挂掉了。4.2 插入、编辑、删除光标位置全靠 TextPointer 算公式插入后光标应该落在公式右边而不是容器内部否则用户接着输入的字会跑进 Border 里。用 container.ElementEnd 这个 TextPointer 赋值给 CaretPosition 是最直接的办法。删除公式比插入麻烦。用户按 Backspace 时如果光标正好在公式右侧RichTextBox 默认行为是把 InlineUIContainer 当普通字符删掉但有时候会留下一个空 Border。稳妥的写法是在 PreviewKeyDown 里拦截先判断相邻的 TextPointer 指向什么。// 删除光标左侧的公式避免留下空壳 private bool TryDeleteFormulaBeforeCaret() { TextPointer caret _rtb.CaretPosition; if (caret null) return false; TextPointer back caret.GetNextInsertionPosition(LogicalDirection.Backward); if (back null) return false; var container back.Parent as InlineUIContainer; if (container null) return false; // 记录删除位置方便撤销后恢复 int offset _rtb.Document.ContentStart.GetOffsetToPosition(container.ElementStart); container.SiblingInlines.Remove(container); _lastDeletedFormulaOffset offset; return true; }双击进入编辑态的做法是把 Border.Child 从 FormulaPresenter 换成一个 TextBox里面预填 LaTeX 原文失焦时重新解析并替换回 Presenter。这样做的好处是编辑态和展示态共用同一套解析链路用户在编辑框里敲错公式能立刻看到错误提示而不是等到保存才炸。4.3 序列化存 LaTeX 而不是存 XAML文档保存有两条路。一条是 XamlWriter.Save 把整个 FlowDocument 序列化缺点是产物巨大、依赖程序集版本、升级 .NET 后打开老文档容易报类型找不到。另一条是自定义 XML段落存 Run 的文本公式只存 LaTeX 字符串。// 保存遍历段落公式只落 latex 属性 private static void SaveDocument(FlowDocument doc, string path) { var settings new XmlWriterSettings { Indent true, Encoding new UTF8Encoding(false) }; using (XmlWriter w XmlWriter.Create(path, settings)) { w.WriteStartElement(doc); foreach (Block block in doc.Blocks) { var para block as Paragraph; if (para null) continue; w.WriteStartElement(p); foreach (Inline inline in para.Inlines) WriteInline(w, inline); w.WriteEndElement(); } w.WriteEndElement(); } } private static void WriteInline(XmlWriter w, Inline inline) { var run inline as Run; if (run ! null) { w.WriteStartElement(run); w.WriteString(run.Text); w.WriteEndElement(); return; } var box inline as InlineUIContainer; if (box null) return; var border box.Child as Border; var tag border null ? null : border.Tag as FormulaInlineTag; if (tag null) return; w.WriteStartElement(formula); w.WriteAttributeString(latex, tag.Latex); w.WriteEndElement(); }加载时反向走一遍读到 run 就建 Run读到 formula 就重新 Parse 后建 InlineUIContainer。整个文档的存储体积能压到 XAML 方案的十分之一以下而且 LaTeX 字符串天然可搜索用户搜 “\frac” 就能定位到所有分式。5. 避坑排查五个让公式编辑器翻车的地方5.1 排版类嵌套分式越叠越歪、上下标互相打架现象输入\frac{\frac{a}{b}}{c}内层分式的分数线比外层高出一截整体看上去像被谁往上推了一把嵌套三层以上时歪得更明显。 原因拼接子盒子时用 Height 做垂直对齐而不是 Ascent。Height 里混进了 Descent分子越高偏移越大。 解决所有 LayoutBox 必须同时带 Ascent 和 Descent父节点拼接时只对齐 Ascent行高取所有子节点 Ascent 的最大值加 Descent 的最大值。改完这一处三层嵌套的偏差能压到亚像素级别。现象x_i^2里的下标 i 和上标 2 在垂直方向重叠看起来像叠字。 原因上标抬升量和下标下沉量都写成了固定值没考虑对方盒子的高度当上标内容是一个分式时它的 Descent 很大直接撞进下标区域。 解决先量上标盒子的 Descent抬升量取max(SupShift, 上标Descent 0.1em)下标同理下沉量取max(SubShift, 下标Ascent * 0.35)。两个盒子高度一旦超阈值就强制按上下堆叠而不是左右并列。5.2 交互类点公式没反应、撤销后公式变空白现象鼠标点在公式上没有任何反馈想双击进编辑态也没用键盘事件全被 RichTextBox 吃掉。 原因Border 默认 Focusable 为 false且 RichTextBox 的文本编辑模型优先级更高UIElement 拿不到焦点。 解决Border 设 Focusabletrue在 PreviewMouseLeftButtonDown 里手动调用host.Focus()并置e.Handled true如果要做双击进编辑态还要在 MouseDoubleClick 里把事件从 RichTextBox 的文本选区逻辑里抢过来。现象CtrlZ 撤销一次插入操作后公式位置还在但内容变成一块空白再按一次 CtrlY 也恢复不了。 原因WPF 内置的撤销栈只记录文本层的变更对 InlineUIContainer 里的自定义 UIElement 不做重建撤销时容器被恢复成一个空 Border。 解决别指望内置撤销栈。插入和删除公式都包进自己的命令对象栈里只存 LaTeX 字符串和插入偏移量撤销时按字符串重新 Parse 再插回去。文本输入的撤销仍然交给 RichTextBox两套栈通过一个标志位区分不要混用。5.3 部署类客户机上公式字形错位、线宽突变现象开发机上排得整整齐齐的公式到了客户机上字变宽、分式线变细、根号横线短了一截。 原因Cambria Math 缺失后字体回退到了宋体或微软雅黑这两者的字宽、x-height 和数学符号的基线位置都不一样而布局参数里有一部分是写死的像素值。 解决所有几何参数统一按 em 比例算不写死像素运行时用 FormattedText 实测一个参考字符比如 “M”的宽度作为 em 校准值字体回退链在启动时确定一次并缓存避免每个公式各选各的字体。注意部署前在一台没装 Office 的干净机器上跑一遍全部样例公式这是唯一能提前抓到字体回退问题的方法。6. 进阶落地公式导出、度量缓存与一条回归清单6.1 导出 PNGdpi 倍数和一个白底导出公式图片最常见的两个坑是分辨率不够和背景透明。RenderTargetBitmap 默认按 96 DPI 渲染放到 2 倍缩放的文档里就糊白底不画PNG 在某些阅读器里会显示成黑底。// 按倍数导出先铺白底再画公式 private static void ExportPng(FormulaNode root, double fontSize, int scale, string path) { var presenter new FormulaPresenter { Formula root, FontSize fontSize }; presenter.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); presenter.Arrange(new Rect(presenter.DesiredSize)); presenter.UpdateLayout(); var visual new DrawingVisual(); using (DrawingContext dc visual.RenderOpen()) { dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, presenter.ActualWidth, presenter.ActualHeight)); dc.DrawRectangle(new VisualBrush(presenter), null, new Rect(0, 0, presenter.ActualWidth, presenter.ActualHeight)); } var bitmap new RenderTargetBitmap( (int)Math.Ceiling(presenter.ActualWidth * scale), (int)Math.Ceiling(presenter.ActualHeight * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32); bitmap.Render(visual); var encoder new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var fs File.Create(path)) encoder.Save(fs); }scale 取 3 到 4 之间比较合适再高文件体积增长很快而肉眼收益很小。VisualBrush 走的仍是同一套呈现管线导出的字形和屏幕上一致不会出现“截图和界面长得不一样”的问题。6.2 度量缓存同一个公式别量两遍编辑长文档时同一批公式会被反复 Measure尤其是滚动、缩放、切换主题的时候。Measure 内部要建 FormattedText开销不小。用一个以 LaTeX 字符串加 em 值为键的字典缓存 LayoutBox命中率通常能到九成以上。// 缓存键latex 字号量过一次就不再量 private static readonly Dictionarystring, LayoutBox _measureCache new Dictionarystring, LayoutBox(StringComparer.Ordinal); private static LayoutBox MeasureCached(string latex, double em) { string key latex | em.ToString(F2); LayoutBox box; if (_measureCache.TryGetValue(key, out box)) return box; FormulaNode root new FormulaParser(latex).Parse(); box FormulaLayout.Measure(root, em); _measureCache[key] box; return box; }缓存要有上限长文档里公式种类太多会把内存撑起来常见做法是超过 2000 条时清空重来或者换成带 LRU 的字典。我改排版参数时踩过最亏的一次是把分数线粗细写成了固定 1 像素自己机器上看着挺精神客户机上字号缩到 12 之后分数线比字还粗。从那以后我每次动几何参数都会拿那 20 条样例公式嵌套分式、三重根号、上下标叠分式、希腊字母混排跑一遍回归对照量出来的 Ascent 和 Descent 跟上一版差超过 1 像素就回去查。希望帮到你。本文还有配套的精品资源点击获取