☰
.NET 4.5.2 WPF 自研 LaTeX 公式编辑器:AST 与盒模型渲染
2026/10/23 6:22:22 网站建设 项目流程

简介:面向教育、科研与桌面开发学习者的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 自定义 FrameworkElement:Measure 和 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.cs:Ascent/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 HashSet<string>(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,而且它只接受一个子元素。所以常规做法是外面套一个 Border,Padding 用来做公式与正文的呼吸感,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 设 Focusable=true,在 PreviewMouseLeftButtonDown 里手动调用host.Focus()并置e.Handled = true;如果要做双击进编辑态,还要在 MouseDoubleClick 里把事件从 RichTextBox 的文本选区逻辑里抢过来。

现象:Ctrl+Z 撤销一次插入操作后,公式位置还在,但内容变成一块空白,再按一次 Ctrl+Y 也恢复不了。 原因:WPF 内置的撤销栈只记录文本层的变更,对 InlineUIContainer 里的自定义 UIElement 不做重建,撤销时容器被恢复成一个空 Border。 解决:别指望内置撤销栈。插入和删除公式都包进自己的命令对象,栈里只存 LaTeX 字符串和插入偏移量,撤销时按字符串重新 Parse 再插回去。文本输入的撤销仍然交给 RichTextBox,两套栈通过一个标志位区分,不要混用。

5.3 部署类:客户机上公式字形错位、线宽突变

现象:开发机上排得整整齐齐的公式,到了客户机上字变宽、分式线变细、根号横线短了一截。 原因:Cambria Math 缺失后字体回退到了宋体或微软雅黑,这两者的字宽、x-height 和数学符号的基线位置都不一样,而布局参数里有一部分是写死的像素值。 解决:所有几何参数统一按 em 比例算,不写死像素;运行时用 FormattedText 实测一个参考字符(比如 “M”)的宽度作为 em 校准值;字体回退链在启动时确定一次并缓存,避免每个公式各选各的字体。

注意:部署前在一台没装 Office 的干净机器上跑一遍全部样例公式,这是唯一能提前抓到字体回退问题的方法。

6. 进阶落地:公式导出、度量缓存与一条回归清单

6.1 导出 PNG:dpi 倍数和一个白底

导出公式图片最常见的两个坑是分辨率不够和背景透明。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 Dictionary<string, LayoutBox> _measureCache = new Dictionary<string, 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 像素就回去查。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询