From 0bb49d1e2c660e42c5805428d1958b494e798a14 Mon Sep 17 00:00:00 2001 From: LaTeXSnipper Developer Date: Thu, 1 Oct 2026 19:06:32 +0800 Subject: [PATCH 1/5] fix: stabilize Office formulas and consolidate plugin maintenance --- .../windows-office-plugin-installer.xml | 2 +- docs/office_plugin_formula_workflows.md | 11 +- office_plugin/README.md | 29 +- .../DynamicPowerPointApplicationAdapter.cs | 39 +- .../PowerPointAddIn/EditorAssets/settings.css | 258 ------------- .../EditorAssets/settings.html | 20 +- .../PowerPointAddIn/EditorAssets/settings.js | 55 +-- .../PowerPointAddIn/PowerPointAddInText.cs | 18 +- .../PowerPointMathMlClipboard.cs | 39 +- ...rPointPluginController.DocumentCommands.cs | 122 ++++-- .../PowerPointPluginController.cs | 2 - .../PowerPointSettingsWindow.cs | 5 + ...WordApplicationAdapter.FormulaLifecycle.cs | 28 +- .../hosts/WordAddIn/EditorAssets/settings.css | 279 -------------- .../WordAddIn/EditorAssets/settings.html | 22 +- .../hosts/WordAddIn/EditorAssets/settings.js | 55 +-- .../hosts/WordAddIn/WordAddInFactory.cs | 5 +- .../hosts/WordAddIn/WordAddInText.cs | 28 +- .../WordPluginController.DocumentCommands.cs | 282 +++++++++----- .../WordAddIn/WordPluginController.Parsing.cs | 9 +- .../hosts/WordAddIn/WordSettingsWindow.cs | 5 + office_plugin/installer/README.md | 8 +- .../LaTeXSnipperOffice_3.1.0_amd64.exe | 4 +- .../LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 | 2 +- .../OfficeOperationError.cs | 48 +++ .../EditorAssets/cjk-font-picker.mjs | 11 + .../EditorAssets/settings-typography.mjs | 28 ++ .../EditorAssets/settings.css | 111 ++++++ .../EditorAssets/source-editor.css | 59 ++- .../EditorAssets/symbol-library.mjs | 32 +- .../EditorAssets/typography-panel.mjs | 3 +- .../TypographySystemFonts.cs | 95 ++--- .../MathJaxTypographyAdapter.js | 3 +- .../AutomationApiClientTests.cs | 4 + .../FailingPowerPointAdapter.cs | 64 ++++ .../PowerPointRoundTrip.cs | 351 +++++++++++------- .../Program.cs | 11 +- .../README.md | 10 +- .../FailingWordAdapter.cs | 51 +++ .../README.md | 4 +- .../WordParsingE2ERunner.cs | 24 ++ office_plugin/tools/Review-History.ps1 | 92 ----- office_plugin/tools/Test-MetadataSafety.ps1 | 21 -- .../tools/Test-OfficeTypographyE2E.ps1 | 94 +++-- .../tools/Test-WordFormulaParsingE2E.ps1 | 50 --- src/assets/i18n/latexsnipper_en_US.ts | 124 ++++--- src/update/installer_cache.py | 16 +- src/update/update_dialog.py | 77 ++-- tools/mathjax/README.md | 29 +- tools/office-editor/README.md | 7 +- tools/office-editor/tests/editor.spec.mjs | 47 ++- tools/office-editor/tests/settings.spec.mjs | 23 +- 52 files changed, 1350 insertions(+), 1466 deletions(-) delete mode 100644 office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.css delete mode 100644 office_plugin/hosts/WordAddIn/EditorAssets/settings.css create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/OfficeOperationError.cs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/cjk-font-picker.mjs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/settings-typography.mjs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/settings.css create mode 100644 office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/FailingPowerPointAdapter.cs create mode 100644 office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/FailingWordAdapter.cs delete mode 100644 office_plugin/tools/Review-History.ps1 delete mode 100644 office_plugin/tools/Test-MetadataSafety.ps1 delete mode 100644 office_plugin/tools/Test-WordFormulaParsingE2E.ps1 diff --git a/.signpath/artifact-configurations/windows-office-plugin-installer.xml b/.signpath/artifact-configurations/windows-office-plugin-installer.xml index b7e1866d..2b9541c3 100644 --- a/.signpath/artifact-configurations/windows-office-plugin-installer.xml +++ b/.signpath/artifact-configurations/windows-office-plugin-installer.xml @@ -1,7 +1,7 @@ - + diff --git a/docs/office_plugin_formula_workflows.md b/docs/office_plugin_formula_workflows.md index 7efe6b4e..db49ba29 100644 --- a/docs/office_plugin_formula_workflows.md +++ b/docs/office_plugin_formula_workflows.md @@ -9,6 +9,7 @@ - 编辑器以源码最外层 `\textcolor{#RRGGBB}{…}` 表示当前公式的全局颜色;工具栏修改该命令,源码区修改该命令则同步工具栏。提交和预览的 `Typography.Color` 从源码读取,MathLive 继承同一颜色,局部 `\textcolor` 可覆盖外层。右键局部上色立即写入源码。 - Word OLE、PPT OLE / PNG 使用同一 SVG 轮廓;Word OMML 与 PowerPoint 文本内原生公式复用带样式 MathML,由各自宿主管理排版。 - MathLive 提供可视化编辑;符号、数字、汉字三类字体和独立的默认字形共同构成样式快照。设置页的 JSON 导入/导出只管理当前宿主的全局公式默认属性;编辑器内调整只作用于当前公式。 +- 数字字体枚举全部系统字体;汉字字体使用从等线到幼圆的 23 项固定列表,中文名称与稳定字体 ID 分开,不探测字形。全局设置和编辑器共享列表与 17 项字形选项。字体列表顺序固定,当前选择不改变顺序。 ## 用户偏好与升级保留 @@ -135,9 +136,9 @@ PowerPoint 没有 Word 编号、引用、章/节分隔符链路。 ### 插入行内公式 1. Ribbon 调用 `InsertInlineAsync`。 -2. 打开 MathLive 编辑器,初始源码为空,显示模式为 `Inline`,不把默认字体/颜色写入编辑器草稿。 -3. 用户提交后,`CreateMetadataFromOptions` 读取当前设置。 -4. 解析新建字号上下文并生成完整 `Typography` 快照。 +2. 打开 MathLive 编辑器,初始源码为空,显示模式为 `Inline`;当前全局设置及有效宿主字号生成初始样式快照,颜色与源码最外层 `\textcolor` 保持同步。 +3. 用户提交后,根据接受的源码与样式快照创建元数据。 +4. 编辑器的本次自定义样式优先于全局默认属性。 5. 按设置的插入后端生成 OMML 或 OLE。 6. 插入完成后保存元数据并移动光标到公式外。 @@ -148,7 +149,7 @@ PowerPoint 没有 Word 编号、引用、章/节分隔符链路。 ### 插入带编号公式 1. Ribbon 调用 `InsertNumberedAsync`。 -2. 打开编辑器时只记录编号意图,不把默认字体/颜色写入编辑器草稿。 +2. 打开编辑器时记录编号意图,并使用同一套全局默认样式与字号解析。 3. 提交后生成 display 公式,并设置 `NumberingMode`。 4. 自动编号公式插入时按当前位置前的编号时间线计算当前章/节前缀和是否需要重置 `SEQ`,只写入当前公式的编号字段。 5. 段落使用制表位布局:公式在正文区域中线对齐,编号按设置位于左侧或右侧。 @@ -216,6 +217,8 @@ REF LaTeXSnipperEq_{equationId} \h 章/节分隔符只服务插件自动编号,不绑定 Word 标题级别,也不改变 Word 文档大纲。 +隐藏使用 Word 的 `Font.Hidden`。如果 Word 开启“隐藏文字”或“显示所有格式标记”,隐藏的分隔符仍会显示;可在“文件 → 选项 → 显示 → 始终在屏幕上显示这些格式标记”中关闭这两项。 + ### 插入引用 插入引用的链路: diff --git a/office_plugin/README.md b/office_plugin/README.md index 418f4bc7..4510254f 100644 --- a/office_plugin/README.md +++ b/office_plugin/README.md @@ -30,6 +30,7 @@ Office 2016 is not officially supported (requires manual .NET 4.8 and WebView2 i ### PowerPoint - OLE and PNG formula insertion +- Native inline equations at a text-box caret, using MathJax MathML and optional host-size inheritance - Load, update, and delete managed formulas - In-place conversion of selected managed formulas between OLE and PNG - Selected formula style reset and presentation-wide natural-size restoration @@ -41,7 +42,7 @@ Office 2016 is not officially supported (requires manual .NET 4.8 and WebView2 i - Reusable WebView2/MathLive formula editor - 18-category shared symbol and formula library - Chinese and English Ribbon, task pane, editor, settings, and help -- Status task pane with connection test and formula preview +- Status task pane with connection test and shared visual/source formula editing ## Project Layout @@ -58,7 +59,9 @@ Desktop and Office share pinned MathJax 4.1.3 with independent resource profiles | `hosts/PowerPointAddIn` | PowerPoint workflows: Ribbon, OLE/PNG insertion, metadata, controller | | `hosts/PowerPointVstoAddIn` | Thin VSTO shell loaded by PowerPoint | | `tests/LaTeXSnipper.OfficePlugin.MetadataSafety.Tests` | Deterministic metadata storage and identity tests | -| `tests/LaTeXSnipper.OfficePlugin.WordParsingE2E` | Real-Word OMML/OLE parsing regression test | +| `tests/LaTeXSnipper.OfficePlugin.Typography.Tests` | Typography contracts, sizes, symbol assets, and OMML mapping | +| `tests/LaTeXSnipper.OfficePlugin.WordParsingE2E` | Real-Word OMML/OLE parsing, formatting, conversion, and persistence | +| `tests/LaTeXSnipper.OfficePlugin.PowerPointE2E` | Real-PowerPoint text insertion, shape round trips, and batch failure handling | | `installer/` | Inno Setup installer and release build entry point | | `tools/` | Build, installer, metadata, and Word parsing test entry points | | `hosts/OleFormulaObjectNative/` | Native C++ COM/OLE in-proc handler DLL registered as the Office formula object for 32-bit and 64-bit Office | @@ -67,7 +70,7 @@ Shared libraries target `net48;net9.0`. Office hosts target .NET Framework 4.8. ## Build -The release build requires Visual Studio 2022 with Office/SharePoint and Visual C++ ATL workloads, .NET 9 SDK, and Inno Setup 6. Run from the repository root: +The release build requires Visual Studio 2022 or newer with Office/SharePoint and Visual C++ ATL workloads, .NET 9 SDK, and Inno Setup 6 or newer. Run from the repository root: ```batch office_plugin\installer\build.bat Release @@ -75,26 +78,30 @@ office_plugin\installer\build.bat Release The build reads the shared product version from the repository `VERSION` file. -Output: `office_plugin\release\LaTeXSnipperOffice_3.1.0_amd64.exe` +Output: `office_plugin\release\LaTeXSnipperOffice__amd64.exe` and its SHA-256 file. The release workflow validates and publishes this locally built installer. Run the installer as administrator. Close Word and PowerPoint before installation, upgrade, or removal. -## Metadata Safety Tests +## Tests Run: ```powershell -office_plugin\tools\Test-MetadataSafety.ps1 +dotnet test office_plugin/LaTeXSnipper.OfficePlugin.slnx -c Release ``` -The deterministic tests validate current-schema writes, copied metadata identity, corrupt metadata rejection, and document identity without starting Office. +The deterministic tests validate typography, current-schema writes, copied metadata identity, corrupt metadata rejection, and document identity without starting Office. Shared editor tests are documented in [tools/office-editor](../tools/office-editor/README.md). -## Word Parsing E2E Test +### Real Office integration -Close every Word window and run: +Build the installer or the managed solution and native handler first, then close Word and PowerPoint. Run from 64-bit PowerShell: ```powershell -office_plugin\tools\Test-WordFormulaParsingE2E.ps1 +office_plugin\tools\Test-OfficeTypographyE2E.ps1 +# Word OMML only, without temporary OLE registration: +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope Word -WordBackend Omml +# PowerPoint batch failure and retry only, without OLE: +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode Batch ``` -The test exercises both OMML and OLE backends through the production controller. Disposable DOCX/PDF evidence is written to the test project's ignored `artifacts` directory. +The unified runner defaults to Release binaries, Word OMML/OLE, and the full PowerPoint suite. Use `-Configuration Debug`, `-WordBackend Ole`, or `-HostScope` to select a smaller run. Evidence goes to a unique temporary directory, or `-OutputDirectory`. OLE runs require 64-bit Click-to-Run Office and temporarily register the built handler under HKCU; existing user registration is refused, and the runner removes only the keys it creates. The plugin installer itself registers the OLE handler under HKLM. diff --git a/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs b/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs index 0d911943..ab51d0d9 100644 --- a/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs +++ b/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs @@ -3,12 +3,11 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using System.Windows.Forms; -using LaTeXSnipper.OfficePlugin.Abstractions; +using LaTeXSnipper.OfficePlugin.Abstractions; namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; -public sealed class DynamicPowerPointApplicationAdapter : IPowerPointApplicationAdapter, IDisposable +public sealed class DynamicPowerPointApplicationAdapter : IPowerPointApplicationAdapter { private const int MsoFalse = 0; private const int MsoTrue = -1; @@ -17,8 +16,6 @@ public sealed class DynamicPowerPointApplicationAdapter : IPowerPointApplication private const string OleFormulaProgId = "LaTeXSnipper.Formula"; private readonly dynamic _application; - private readonly Control _officeThreadControl; - private readonly int _officeThreadId; [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); @@ -28,16 +25,6 @@ public DynamicPowerPointApplicationAdapter(object application) _application = application ?? throw new ArgumentNullException(nameof(application)); if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) throw new InvalidOperationException("PowerPoint 插件必须在 Office STA 线程上初始化。"); - _officeThreadId = Thread.CurrentThread.ManagedThreadId; - _officeThreadControl = new Control(); - _ = _officeThreadControl.Handle; - } - - public void Dispose() - { - if (Thread.CurrentThread.ManagedThreadId == _officeThreadId) _officeThreadControl.Dispose(); - else if (_officeThreadControl.IsHandleCreated) - _officeThreadControl.BeginInvoke(new Action(_officeThreadControl.Dispose)); } public double GetCurrentFontSizePoints() @@ -722,28 +709,18 @@ public Task InsertNativeEquationAsync(PowerPointTextInsertionTarget target, stri { cancellationToken.ThrowIfCancellationRequested(); if (target == null) throw new ArgumentNullException(nameof(target)); - return RunOnOfficeThreadAsync(() => InsertNativeEquation(target, mathMl, fontSizePoints, cancellationToken), cancellationToken); - } - - private Task RunOnOfficeThreadAsync(Action action, CancellationToken cancellationToken) - { - if (Thread.CurrentThread.ManagedThreadId == _officeThreadId) - { - action(); - return Task.CompletedTask; - } - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _officeThreadControl.BeginInvoke(new Action(() => + var worker = new Thread(() => { try { - cancellationToken.ThrowIfCancellationRequested(); - action(); + InsertNativeEquation(target, mathMl, fontSizePoints, cancellationToken); completion.TrySetResult(true); } catch (Exception error) { completion.TrySetException(error); } - })); + }) { IsBackground = true, Name = "LaTeXSnipper PowerPoint MathML paste" }; + worker.SetApartmentState(ApartmentState.STA); + worker.Start(); return completion.Task; } @@ -766,7 +743,9 @@ private void InsertNativeEquation(PowerPointTextInsertionTarget target, string m (int Start, int Length) pasted = default; try { + ActivateForEditingAsync(cancellationToken).GetAwaiter().GetResult(); dynamic insertionRange = shape.TextFrame.TextRange.Characters(target.Start, 0); + insertionRange.Select(); pasted = PowerPointMathMlClipboard.PasteAtRange(insertionRange, mathMl); if (pasted.Length <= 0) throw new InvalidOperationException(PowerPointAddInText.Get("NativeEquationInsertFailed")); diff --git a/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.css b/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.css deleted file mode 100644 index d607802c..00000000 --- a/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.css +++ /dev/null @@ -1,258 +0,0 @@ -:root { - color-scheme: light; - --bg: #f4f7fb; - --panel: #fff; - --line: #d7dfeb; - --text: #172033; - --muted: #617087; - --accent: #1f6feb; - --accent-soft: #e8f1ff; - --ok: #15803d; - --ok-soft: #effaf3; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - color: var(--text); - background: var(--bg); - font: 14px/1.55 "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif; -} - -.shell { - min-height: 100vh; - padding: 24px; -} - -.header { - margin-bottom: 16px; -} - -h1, -h2, -p { - margin: 0; -} - -h1 { - font-size: 24px; - line-height: 1.25; - letter-spacing: 0; -} - -h2 { - margin-bottom: 12px; - font-size: 16px; - letter-spacing: 0; -} - -.header p, -.hint { - color: var(--muted); -} - -.panel { - margin-top: 12px; - padding: 16px; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel); -} - -.accent-panel { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); - gap: 18px; - align-items: center; - border-left: 4px solid var(--accent); -} - -.segmented { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -button { - min-height: 34px; - border: 1px solid #bac8da; - border-radius: 5px; - background: #fff; - color: var(--text); - font: inherit; - cursor: pointer; -} - -button:hover { - border-color: #8fb0e6; - background: #f6f9ff; -} - -.settings-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.settings-grid label, -.color-control { - display: grid; - gap: 6px; -} - -.settings-grid label.follow-host-option { - display: flex; - grid-column: 1 / -1; - gap: 8px; - align-items: center; -} - -.settings-grid .font-size-field { - grid-column: 1 / -1; -} - -.follow-host-option input { - margin: 0; - flex: none; -} - -.size-control { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; -} - -.size-control select { - width: 100%; -} - -.color-control { - grid-template-columns: 56px minmax(0, 1fr); -} - -input[type="color"], -select { - width: 100%; - min-height: 36px; - border: 1px solid #bac8da; - border-radius: 5px; - background: #fff; -} - -.range-control { - display: grid; - grid-template-columns: minmax(0, 1fr) 54px; - gap: 8px; - align-items: center; -} - -.range-control input { - width: 100%; -} - -.range-control output { - color: var(--text); - text-align: right; -} - -.segmented button.active { - border-color: #77a2e8; - background: var(--accent-soft); - color: var(--accent); - font-weight: 600; -} - -.shortcut-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 10px; -} - -.shortcut-grid div { - display: grid; - grid-template-columns: auto auto auto 1fr; - gap: 7px; - align-items: start; - min-height: 78px; - padding: 10px; - border: 1px solid #e0e6ef; - border-radius: 5px; - background: #fbfdff; -} - -.shortcut-grid strong, -.shortcut-grid em { - display: block; -} - -.shortcut-grid strong { - grid-column: 4; - line-height: 1.2; -} - -.shortcut-grid em { - grid-column: 1 / -1; - color: var(--muted); - font-size: 13px; - font-style: normal; -} - -.shortcut-grid span { - min-width: 26px; - padding: 3px 6px; - border-radius: 4px; - background: var(--ok-soft); - color: var(--ok); - text-align: center; - font: 12px "Cascadia Code", Consolas, monospace; -} - -.key-list { - display: grid; - gap: 8px; -} - -.key-list div { - display: flex; - gap: 7px; - align-items: center; - color: var(--muted); -} - -kbd { - min-width: 34px; - padding: 3px 7px; - border: 1px solid #c7d1df; - border-radius: 4px; - background: #f7f9fc; - color: var(--text); - text-align: center; - font: 12px "Cascadia Code", Consolas, monospace; -} - -.footer { - display: flex; - gap: 12px; - align-items: center; - justify-content: space-between; - margin-top: 16px; - color: var(--muted); -} - -#closeButton { - min-width: 92px; -} - -.preset-file-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; } -#presetStatus { color: var(--muted); } -#presetStatus.error { color: #b42318; } - -@media (max-width: 720px) { - .accent-panel { - grid-template-columns: 1fr; - } -} diff --git a/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.html b/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.html index 0f8de10a..e8a4258c 100644 --- a/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.html +++ b/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.html @@ -3,16 +3,16 @@ - LaTeXSnipper Office Plugin Settings - + LaTeXSnipper PowerPoint Plugin Settings +
-

LaTeXSnipper Office 插件设置

+

LaTeXSnipper PowerPoint 插件设置

-
+

公式插入方式

默认使用 OLE 公式对象,也可切换为 PNG 图片。

@@ -23,7 +23,7 @@

公式插入方式

-
+

公式默认属性

这些设置应用于新插入公式和格式化命令。

@@ -43,7 +43,7 @@

公式默认属性

默认字形 -
@@ -67,17 +67,15 @@

全局公式预设

编辑器键盘行为

-
Enter新建数学行
Shift+Enter插入或更新当前公式
Ctrl+F插入分式
Ctrl+R插入根号
Ctrl+H插入上标
Ctrl+L插入下标
Ctrl+J插入上下标
-
Esc收回 MathLive 虚拟键盘
- + diff --git a/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.js b/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.js index a70eda82..fd78c493 100644 --- a/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.js +++ b/office_plugin/hosts/PowerPointAddIn/EditorAssets/settings.js @@ -1,6 +1,8 @@ +import {populateTypographyOptions} from 'https://latexsnipper-editor-shared.officeplugin.local/settings-typography.mjs'; + const TEXT = { zh: { - title: "LaTeXSnipper Office 插件设置", + title: "LaTeXSnipper PowerPoint 插件设置", backendTitle: "公式插入方式", backendHint: "默认使用 OLE 公式对象,也可切换为 PNG 图片。", backendOle: "OLE 对象", @@ -10,11 +12,10 @@ const TEXT = { colorLabel: "字体颜色", resetColor: "恢复黑色", fontStyleLabel: "默认字形", - fontSizeLabel: "公式字号(pt)", + fontSizeLabel: "公式字号", symbolFontLabel: "符号字体", numberFontLabel: "数字字体", cjkFontLabel: "汉字字体", - followSymbolFont: "跟随符号字体", presetTitle: "全局公式预设", presetHint: "导入会替换当前宿主的公式默认属性;导出时用 JSON 文件名命名预设。编辑器内调整只作用于当前公式。", importPreset: "导入 JSON", @@ -24,16 +25,14 @@ const TEXT = { followHostSize: "新建时跟随文字字号(无有效选区时使用上述字号)", editorTitle: "编辑器键盘行为", acceptShortcut: "插入或更新当前公式", - newlineShortcut: "新建数学行", fractionShortcut: "插入分式", rootShortcut: "插入根号", superscriptShortcut: "插入上标", subscriptShortcut: "插入下标", scriptsShortcut: "插入上下标", - cancelShortcut: "收回 MathLive 虚拟键盘", }, en: { - title: "LaTeXSnipper Office Plugin Settings", + title: "LaTeXSnipper PowerPoint Plugin Settings", backendTitle: "Formula Insertion", backendHint: "OLE formula objects are the default. PNG image insertion is also available.", backendOle: "OLE Object", @@ -43,11 +42,10 @@ const TEXT = { colorLabel: "Font color", resetColor: "Reset to black", fontStyleLabel: "Default math style", - fontSizeLabel: "Formula size (pt)", + fontSizeLabel: "Formula size", symbolFontLabel: "Symbol font", numberFontLabel: "Number font", cjkFontLabel: "CJK font", - followSymbolFont: "Follow symbol font", presetTitle: "Global formula preset", presetHint: "Import replaces this host's formula defaults. The JSON filename names the preset. Editor changes apply to the current formula.", importPreset: "Import JSON", @@ -57,13 +55,11 @@ const TEXT = { followHostSize: "Follow text size for new formulas (use the size above when unavailable)", editorTitle: "Editor Keyboard Behavior", acceptShortcut: "insert or update the current formula", - newlineShortcut: "start a new math row", fractionShortcut: "insert a fraction", rootShortcut: "insert a square root", superscriptShortcut: "insert a superscript", subscriptShortcut: "insert a subscript", scriptsShortcut: "insert superscript and subscript", - cancelShortcut: "hide the MathLive virtual keyboard", }, }; @@ -90,46 +86,13 @@ const numberFontInput = document.getElementById("numberFontFamily"); const cjkFontInput = document.getElementById("cjkFontFamily"); const presetStatus = document.getElementById("presetStatus"); -function populateFonts(payload) { - const setOptions = (select, values, selected) => { - select.replaceChildren(); - for (const value of [...new Set([...values, selected])]) { - if (value === undefined || value === null) continue; - select.add(new Option(value || strings().followSymbolFont, value)); - } - select.value = selected; - }; - setOptions(symbolFontInput, payload.symbolFonts, symbolFontId); - setOptions(numberFontInput, ["", ...payload.systemFonts], numberFontFamily); - setOptions(cjkFontInput, payload.cjkFonts, cjkFontFamily); -} - -function populateMathStyles(styles) { - formulaMathStyleSelect.replaceChildren(); - for (const style of styles) { - formulaMathStyleSelect.add(new Option(locale.startsWith("zh") ? style.zh : style.en, style.id)); - } -} - -function populateFontSizes(payload) { - formulaFontSizePointsInput.replaceChildren(); - for (const size of payload.namedSizes) { - formulaFontSizePointsInput.add(new Option(size.name, String(size.points))); - } - for (const points of payload.commonPointSizes) { - formulaFontSizePointsInput.add(new Option(String(points), String(points))); - } - if (![...formulaFontSizePointsInput.options].some((option) => Number(option.value) === formulaFontSizePoints)) { - formulaFontSizePointsInput.add(new Option(String(formulaFontSizePoints), String(formulaFontSizePoints))); - } -} - function strings() { return locale.startsWith("zh") ? TEXT.zh : TEXT.en; } function applyText() { const dict = strings(); + document.title = dict.title; document.documentElement.lang = locale.startsWith("zh") ? "zh-CN" : "en"; document.querySelectorAll("[data-i18n]").forEach((node) => { node.textContent = dict[node.dataset.i18n] || node.textContent; @@ -172,10 +135,8 @@ function init(payload) { symbolFontId = String(payload?.symbolFontId || "mathjax-tex"); numberFontFamily = String(payload?.numberFontFamily || ""); cjkFontFamily = String(payload?.cjkFontFamily || "Microsoft YaHei"); - populateFontSizes(payload); applyText(); - populateMathStyles(payload.mathStyles); - populateFonts(payload); + populateTypographyOptions(payload, {symbolFontId, numberFontFamily, cjkFontFamily, formulaFontSizePoints}, locale); render(); } diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs index f24f9fd7..e6b374e8 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using LaTeXSnipper.OfficePlugin.Abstractions; namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; @@ -7,18 +8,7 @@ public static class PowerPointAddInText { public static string GetExceptionMessage(Exception exception) { - string message = exception?.Message?.Trim() ?? string.Empty; - foreach (char value in message) - { - if (value >= '\u3400' && value <= '\u9fff') - { - return message; - } - } - - return exception is TimeoutException - ? "操作超时,请稍后重试。" - : "操作失败,请重试;若问题持续,请重新启动 Office 和 LaTeXSnipper。"; + return OfficeOperationError.Describe(exception); } public static string Get(string key) @@ -90,9 +80,11 @@ private static string GetEnglish(string key) "DeletedManyStatus" => "Deleted {count} selected formulas.", "ConvertedStatus" => "Converted {count} formulas.", "ConvertedWithSkippedStatus" => "Converted {count} formulas; skipped {skipped} missing formulas.", + "ConvertedWithFailuresStatus" => "Processed {total} formulas: converted {succeeded}, failed {failed}, skipped {skipped}. First error: {reason}", "NoConversionNeededStatus" => "The selected formulas already use the target format.", "FormattedStatus" => "Formatted {count} formulas.", "FormattedWithSkippedStatus" => "Formatted {count} formulas; skipped {skipped} missing formulas.", + "FormattedWithFailuresStatus" => "Processed {total} formulas: formatted {succeeded}, failed {failed}, skipped {skipped}. First error: {reason}", "NoFormattingNeededStatus" => "No formulas need formatting.", "BatchConvertingStatus" => "Converting formulas: {processed}/{total}.", "BatchFormattingStatus" => "Formatting formulas: {processed}/{total}.", @@ -172,9 +164,11 @@ private static string GetChinese(string key) "DeletedManyStatus" => "已删除 {count} 个所选公式。", "ConvertedStatus" => "已转换 {count} 个公式。", "ConvertedWithSkippedStatus" => "已转换 {count} 个公式,跳过 {skipped} 个已不存在的公式。", + "ConvertedWithFailuresStatus" => "共处理 {total} 个公式:成功转换 {succeeded} 个,失败 {failed} 个,跳过 {skipped} 个。首个错误:{reason}", "NoConversionNeededStatus" => "所选公式已经是目标格式。", "FormattedStatus" => "已格式化 {count} 个公式。", "FormattedWithSkippedStatus" => "已格式化 {count} 个公式,跳过 {skipped} 个已不存在的公式。", + "FormattedWithFailuresStatus" => "共处理 {total} 个公式:成功格式化 {succeeded} 个,失败 {failed} 个,跳过 {skipped} 个。首个错误:{reason}", "NoFormattingNeededStatus" => "没有需要格式化的公式。", "BatchConvertingStatus" => "正在转换公式:{processed}/{total}。", "BatchFormattingStatus" => "正在格式化公式:{processed}/{total}。", diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointMathMlClipboard.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointMathMlClipboard.cs index 185fc9b7..48b5a95c 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointMathMlClipboard.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointMathMlClipboard.cs @@ -1,6 +1,5 @@ using System; using System.Drawing; -using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -12,16 +11,15 @@ namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; internal static class PowerPointMathMlClipboard { - private const int PpPasteHtml = 8; - public static (int Start, int Length) PasteAtRange(dynamic range, string mathMl) { if (string.IsNullOrWhiteSpace(mathMl)) throw new ArgumentException("MathML 不能为空。", nameof(mathMl)); IDataObject? previous = SnapshotClipboard(); try { - Clipboard.SetText(CreateHtmlClipboardPayload(CompactMathMl(mathMl)), TextDataFormat.Html); - dynamic pasted = range.PasteSpecial(PpPasteHtml); + string normalized = CompactMathMl(mathMl); + Clipboard.SetText(normalized, TextDataFormat.UnicodeText); + dynamic pasted = range.Paste(); return (Convert.ToInt32(pasted.Start), Convert.ToInt32(pasted.Length)); } finally @@ -88,19 +86,22 @@ private static string CompactMathMl(string mathMl) .Where(node => string.IsNullOrWhiteSpace(node.Value) && node.Parent?.Elements().Any() == true) .ToArray()) whitespace.Remove(); - return root.ToString(SaveOptions.DisableFormatting); - } - - private static string CreateHtmlClipboardPayload(string mathMl) - { - const string prefix = ""; - const string suffix = ""; - const string header = "Version:0.9\r\nStartHTML:{0:D10}\r\nEndHTML:{1:D10}\r\nStartFragment:{2:D10}\r\nEndFragment:{3:D10}\r\n"; - int startHtml = Encoding.UTF8.GetByteCount(string.Format(CultureInfo.InvariantCulture, header, 0, 0, 0, 0)); - int startFragment = startHtml + Encoding.UTF8.GetByteCount(prefix); - int endFragment = startFragment + Encoding.UTF8.GetByteCount(mathMl); - int endHtml = endFragment + Encoding.UTF8.GetByteCount(suffix); - return string.Format(CultureInfo.InvariantCulture, header, startHtml, endHtml, startFragment, endFragment) - + prefix + mathMl + suffix; + // Encode non-ASCII text and attribute values as numeric references so + // PowerPoint's MathML paste parser accepts formulas containing Chinese. + string compact = root.ToString(SaveOptions.DisableFormatting); + var encoded = new StringBuilder(compact.Length); + for (int index = 0; index < compact.Length; index++) + { + char character = compact[index]; + if (character < 128) + { + encoded.Append(character); + continue; + } + int codePoint = char.ConvertToUtf32(compact, index); + if (char.IsHighSurrogate(character)) index++; + encoded.Append("&#x").Append(codePoint.ToString("X", System.Globalization.CultureInfo.InvariantCulture)).Append(';'); + } + return encoded.ToString(); } } diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs index aaf351e0..98dee82b 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Threading; @@ -38,6 +39,8 @@ private async Task ConvertSelectedAsync(RenderEngineKind target, CancellationTok await _powerPointAdapter.LoadFormulaEntriesAsync(false, cancellationToken); int converted = 0; int skipped = 0; + int failed = 0; + string? firstFailure = null; for (int batchStart = 0; batchStart < entries.Count; batchStart += BatchFormulaOperationSize) { PowerPointFormulaEntry[] batch = entries @@ -47,31 +50,46 @@ private async Task ConvertSelectedAsync(RenderEngineKind target, CancellationTok foreach (PowerPointFormulaEntry entry in batch) { cancellationToken.ThrowIfCancellationRequested(); - if (entry.Metadata.RenderEngine == target) + try { - continue; - } + if (entry.Metadata.RenderEngine == target) + { + continue; + } - if (!_powerPointAdapter.ContainsFormula(entry.Metadata.Identity.EquationId)) - { - skipped++; - continue; - } + if (!_powerPointAdapter.ContainsFormula(entry.Metadata.Identity.EquationId)) + { + skipped++; + continue; + } - if (await ReplaceEntryAsync(entry, WithRenderEngine(entry.Metadata, target), entry.Scale, cancellationToken)) + if (await ReplaceEntryAsync(entry, WithRenderEngine(entry.Metadata, target), entry.Scale, cancellationToken)) + { + converted++; + } + else + { + skipped++; + } + } + catch (OperationCanceledException) { - converted++; + throw; } - else + catch (Exception exception) { - skipped++; + failed++; + firstFailure ??= PowerPointAddInText.GetExceptionMessage(exception); + Trace.TraceWarning("Formula conversion failed on slide {0}, formula {1}: {2}", + entry.SlideIndex, entry.Metadata.Identity.EquationId, exception); } } PostBatchProgress("BatchConvertingStatus", Math.Min(batchStart + batch.Length, entries.Count), entries.Count); } - PostChangedCount(converted, skipped, "ConvertedStatus", "ConvertedWithSkippedStatus", "NoConversionNeededStatus"); + PostBatchResult(entries.Count, converted, skipped, failed, firstFailure, + "ConvertedStatus", "ConvertedWithSkippedStatus", "ConvertedWithFailuresStatus", "NoConversionNeededStatus"); } private async Task FormatAsync(bool all, CancellationToken cancellationToken) @@ -81,6 +99,8 @@ private async Task FormatAsync(bool all, CancellationToken cancellationToken) await _powerPointAdapter.LoadFormulaEntriesAsync(all, cancellationToken); int formatted = 0; int skipped = 0; + int failed = 0; + string? firstFailure = null; for (int batchStart = 0; batchStart < entries.Count; batchStart += BatchFormulaOperationSize) { PowerPointFormulaEntry[] batch = entries @@ -90,41 +110,56 @@ private async Task FormatAsync(bool all, CancellationToken cancellationToken) foreach (PowerPointFormulaEntry entry in batch) { cancellationToken.ThrowIfCancellationRequested(); - if (!NeedsFormatting(entry, settings)) + try { - continue; - } + if (!NeedsFormatting(entry, settings)) + { + continue; + } - if (!_powerPointAdapter.ContainsFormula(entry.Metadata.Identity.EquationId)) - { - skipped++; - continue; - } + if (!_powerPointAdapter.ContainsFormula(entry.Metadata.Identity.EquationId)) + { + skipped++; + continue; + } - string latex = entry.Metadata.Latex; - FormulaMetadata metadata = new FormulaMetadata( - entry.Metadata.Identity, - latex, - entry.Metadata.DisplayMode, - entry.Metadata.NumberingMode, - entry.Metadata.NumberText, - entry.Metadata.RenderEngine, - entry.Metadata.SchemaVersion, - settings.Typography); - if (await ReplaceEntryAsync(entry, metadata, scale: 1, cancellationToken)) + string latex = entry.Metadata.Latex; + FormulaMetadata metadata = new FormulaMetadata( + entry.Metadata.Identity, + latex, + entry.Metadata.DisplayMode, + entry.Metadata.NumberingMode, + entry.Metadata.NumberText, + entry.Metadata.RenderEngine, + entry.Metadata.SchemaVersion, + settings.Typography); + if (await ReplaceEntryAsync(entry, metadata, scale: 1, cancellationToken)) + { + formatted++; + } + else + { + skipped++; + } + } + catch (OperationCanceledException) { - formatted++; + throw; } - else + catch (Exception exception) { - skipped++; + failed++; + firstFailure ??= PowerPointAddInText.GetExceptionMessage(exception); + Trace.TraceWarning("Formula formatting failed on slide {0}, formula {1}: {2}", + entry.SlideIndex, entry.Metadata.Identity.EquationId, exception); } } PostBatchProgress("BatchFormattingStatus", Math.Min(batchStart + batch.Length, entries.Count), entries.Count); } - PostChangedCount(formatted, skipped, "FormattedStatus", "FormattedWithSkippedStatus", "NoFormattingNeededStatus"); + PostBatchResult(entries.Count, formatted, skipped, failed, firstFailure, + "FormattedStatus", "FormattedWithSkippedStatus", "FormattedWithFailuresStatus", "NoFormattingNeededStatus"); } private async Task ReplaceEntryAsync( @@ -171,8 +206,21 @@ await _powerPointAdapter.InsertFormulaImageOnSlideAsync( return true; } - private void PostChangedCount(int count, int skipped, string changedKey, string skippedKey, string unchangedKey) + private void PostBatchResult(int total, int count, int skipped, int failed, string? firstFailure, + string changedKey, string skippedKey, string failedKey, string unchangedKey) { + if (failed > 0) + { + string summary = PowerPointAddInText.Get(failedKey) + .Replace("{total}", total.ToString(CultureInfo.InvariantCulture)) + .Replace("{succeeded}", count.ToString(CultureInfo.InvariantCulture)) + .Replace("{failed}", failed.ToString(CultureInfo.InvariantCulture)) + .Replace("{skipped}", skipped.ToString(CultureInfo.InvariantCulture)) + .Replace("{reason}", firstFailure ?? string.Empty); + _statusSink.Post(count == 0 ? PowerPointStatusKind.Error : PowerPointStatusKind.Info, summary); + return; + } + if (count == 0) { _statusSink.Post(PowerPointStatusKind.Info, PowerPointAddInText.Get(unchangedKey)); diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.cs index 88c3eb87..928d7bae 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.cs @@ -552,8 +552,6 @@ public void Dispose() _editorSession.Dispose(); _automationClient.Dispose(); _mathJaxRenderer.Dispose(); - if (_powerPointAdapter is IDisposable disposableAdapter) disposableAdapter.Dispose(); - _commandGate.Dispose(); } diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointSettingsWindow.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointSettingsWindow.cs index 795648c0..7a130d8c 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointSettingsWindow.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointSettingsWindow.cs @@ -99,6 +99,11 @@ private async Task InitializeAsync() SettingsHostName, assetsRoot, CoreWebView2HostResourceAccessKind.Allow); + core.SetVirtualHostNameToFolderMapping( + "latexsnipper-editor-shared.officeplugin.local", + InstalledAssetResolver.FindSharedAssetRoot("settings.css") + ?? throw new DirectoryNotFoundException("Shared settings assets were not found."), + CoreWebView2HostResourceAccessKind.Allow); core.WebMessageReceived += OnWebMessageReceived; core.NavigationCompleted += OnNavigationCompleted; _webView.Source = new Uri("https://" + SettingsHostName + "/settings.html?_=" + DateTime.UtcNow.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture)); diff --git a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.FormulaLifecycle.cs b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.FormulaLifecycle.cs index 765127df..6980bb39 100644 --- a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.FormulaLifecycle.cs +++ b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.FormulaLifecycle.cs @@ -406,16 +406,34 @@ private dynamic AddOleInlineShapeAtRange(dynamic range, FormulaMetadata metadata Type.Missing, Type.Missing, range); - ApplyOleInlineShapeLayout(inlineShape, presentation, metadata.DisplayMode == FormulaDisplayMode.Display); - TagOleInlineShape(inlineShape, metadata); - return inlineShape; + try + { + ApplyOleInlineShapeLayout(inlineShape, presentation, metadata.DisplayMode == FormulaDisplayMode.Display); + TagOleInlineShape(inlineShape, metadata); + return inlineShape; + } + catch + { + TryCom(() => inlineShape.Delete()); + throw; + } } private dynamic ReplaceOleInlineShape(dynamic inlineShape, FormulaMetadata metadata, OlePresentationResult presentation) { int insertionPoint = GetRangeStart(inlineShape.Range); - inlineShape.Delete(); - return AddOleInlineShapeAtRange(CreateDocumentRange(insertionPoint, insertionPoint), metadata, presentation); + dynamic replacement = AddOleInlineShapeAtRange( + CreateDocumentRange(insertionPoint, insertionPoint), metadata, presentation); + try + { + inlineShape.Delete(); + return replacement; + } + catch + { + TryCom(() => replacement.Delete()); + throw; + } } private static void InsertTextAtRange(dynamic range, string text) diff --git a/office_plugin/hosts/WordAddIn/EditorAssets/settings.css b/office_plugin/hosts/WordAddIn/EditorAssets/settings.css deleted file mode 100644 index b935e5dd..00000000 --- a/office_plugin/hosts/WordAddIn/EditorAssets/settings.css +++ /dev/null @@ -1,279 +0,0 @@ -:root { - color-scheme: light; - --bg: #f4f7fb; - --panel: #fff; - --line: #d7dfeb; - --text: #172033; - --muted: #617087; - --accent: #1f6feb; - --accent-soft: #e8f1ff; - --ok: #15803d; - --ok-soft: #effaf3; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - color: var(--text); - background: var(--bg); - font: 14px/1.55 "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif; -} - -.shell { - min-height: 100vh; - padding: 24px; -} - -.header { - margin-bottom: 16px; -} - -h1, -h2, -p { - margin: 0; -} - -h1 { - font-size: 24px; - line-height: 1.25; - letter-spacing: 0; -} - -h2 { - margin-bottom: 12px; - font-size: 16px; - letter-spacing: 0; -} - -.header p, -.hint { - color: var(--muted); -} - -.panel { - margin-top: 12px; - padding: 16px; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel); -} - -.accent-panel { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); - gap: 18px; - align-items: center; - border-left: 4px solid var(--accent); -} - -.segmented { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.numbering-grid { - display: grid; - gap: 10px; -} - -.numbering-controls { - display: grid; - gap: 12px; -} - -.numbering-options { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px 16px; -} - -label { - display: grid; - gap: 5px; - color: var(--muted); - font-size: 13px; -} - -.check-option { - display: flex; - gap: 8px; - align-items: center; - min-height: 34px; -} - -.check-option input { - margin: 0; - flex: none; -} - -.follow-host-option { - justify-content: flex-start; -} - -.size-control { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; - color: var(--text); -} - -.size-control select { - width: 100%; -} - -.color-control { - display: grid; - grid-template-columns: 1fr auto; - gap: 8px; - align-items: stretch; -} - -.color-control input { - width: 100%; - min-height: 34px; -} - -.range-control { - display: grid; - grid-template-columns: minmax(0, 1fr) 54px; - gap: 8px; - align-items: center; -} - -.range-control input { - width: 100%; -} - -.range-control output { - color: var(--text); - text-align: right; -} - -select { - width: 100%; - min-height: 34px; - border: 1px solid #bac8da; - border-radius: 5px; - background: #fff; - color: var(--text); - font: inherit; -} - -.preset-file-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; } -#presetStatus { color: var(--muted); } -#presetStatus.error { color: #b42318; } - -button { - min-height: 34px; - border: 1px solid #bac8da; - border-radius: 5px; - background: #fff; - color: var(--text); - font: inherit; - cursor: pointer; -} - -button:hover { - border-color: #8fb0e6; - background: #f6f9ff; -} - -.segmented button.active { - border-color: #77a2e8; - background: var(--accent-soft); - color: var(--accent); - font-weight: 600; -} - -.shortcut-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 10px; -} - -.shortcut-grid div { - display: grid; - grid-template-columns: auto auto auto 1fr; - gap: 7px; - align-items: start; - min-height: 78px; - padding: 10px; - border: 1px solid #e0e6ef; - border-radius: 5px; - background: #fbfdff; -} - -.shortcut-grid strong, -.shortcut-grid em { - display: block; -} - -.shortcut-grid strong { - grid-column: 4; - line-height: 1.2; -} - -.shortcut-grid em { - grid-column: 1 / -1; - color: var(--muted); - font-size: 13px; - font-style: normal; -} - -.shortcut-grid span { - min-width: 26px; - padding: 3px 6px; - border-radius: 4px; - background: var(--ok-soft); - color: var(--ok); - text-align: center; - font: 12px "Cascadia Code", Consolas, monospace; -} - -.key-list { - display: grid; - gap: 8px; -} - -.key-list div { - display: flex; - gap: 7px; - align-items: center; - color: var(--muted); -} - -kbd { - min-width: 34px; - padding: 3px 7px; - border: 1px solid #c7d1df; - border-radius: 4px; - background: #f7f9fc; - color: var(--text); - text-align: center; - font: 12px "Cascadia Code", Consolas, monospace; -} - -.footer { - display: flex; - gap: 12px; - align-items: center; - justify-content: space-between; - margin-top: 16px; - color: var(--muted); -} - -#closeButton { - min-width: 92px; -} - -@media (max-width: 720px) { - .accent-panel { - grid-template-columns: 1fr; - } -} diff --git a/office_plugin/hosts/WordAddIn/EditorAssets/settings.html b/office_plugin/hosts/WordAddIn/EditorAssets/settings.html index a901acbf..8989408f 100644 --- a/office_plugin/hosts/WordAddIn/EditorAssets/settings.html +++ b/office_plugin/hosts/WordAddIn/EditorAssets/settings.html @@ -3,18 +3,18 @@ - LaTeXSnipper Office Plugin Settings - + LaTeXSnipper Word Plugin Settings +
-

LaTeXSnipper Office 插件设置

+

LaTeXSnipper Word 插件设置

-
+

公式插入方式

默认使用 OLE 公式对象;也可切换为 Word OMML。

@@ -25,7 +25,7 @@

公式插入方式

-
+

带编号公式默认布局

此设置只影响新插入的编号公式,以及之后被自动编号的普通公式。

@@ -65,12 +65,12 @@

带编号公式默认布局

-
+

公式默认属性

这些设置应用于新插入公式和格式化命令。

-
+
@@ -109,18 +109,16 @@

全局公式预设

编辑器键盘行为

-
Enter新建数学行
Shift+Enter插入或更新当前公式
Ctrl+F插入分式
Ctrl+R插入根号
Ctrl+H插入上标
Ctrl+L插入下标
Ctrl+J插入上下标
-
Esc收回 MathLive 虚拟键盘
- + diff --git a/office_plugin/hosts/WordAddIn/EditorAssets/settings.js b/office_plugin/hosts/WordAddIn/EditorAssets/settings.js index 6c0213f4..d99033d2 100644 --- a/office_plugin/hosts/WordAddIn/EditorAssets/settings.js +++ b/office_plugin/hosts/WordAddIn/EditorAssets/settings.js @@ -1,6 +1,8 @@ +import {populateTypographyOptions} from 'https://latexsnipper-editor-shared.officeplugin.local/settings-typography.mjs'; + const TEXT = { zh: { - title: "LaTeXSnipper Office 插件设置", + title: "LaTeXSnipper Word 插件设置", backendTitle: "公式插入方式", backendHint: "默认使用 OLE 公式对象;也可切换为 Word OMML。", backendOle: "OLE 对象", @@ -21,11 +23,10 @@ const TEXT = { colorLabel: "字体颜色", resetToBlack: "恢复黑色", fontStyleLabel: "默认字形", - fontSizeLabel: "公式字号(pt)", + fontSizeLabel: "公式字号", symbolFontLabel: "符号字体", numberFontLabel: "数字字体", cjkFontLabel: "汉字字体", - followSymbolFont: "跟随符号字体", presetTitle: "全局公式预设", presetHint: "导入会替换当前宿主的公式默认属性;导出时用 JSON 文件名命名预设。编辑器内调整只作用于当前公式。", importPreset: "导入 JSON", @@ -35,16 +36,14 @@ const TEXT = { followHostSize: "新建时跟随文字字号(无有效选区时使用上述字号)", editorTitle: "编辑器键盘行为", acceptShortcut: "插入或更新当前公式", - newlineShortcut: "新建数学行", fractionShortcut: "插入分式", rootShortcut: "插入根号", superscriptShortcut: "插入上标", subscriptShortcut: "插入下标", scriptsShortcut: "插入上下标", - cancelShortcut: "收回 MathLive 虚拟键盘", }, en: { - title: "LaTeXSnipper Office Plugin Settings", + title: "LaTeXSnipper Word Plugin Settings", backendTitle: "Formula Insertion", backendHint: "OLE formula objects are the default. Word OMML insertion is also available.", backendOle: "OLE Object", @@ -65,11 +64,10 @@ const TEXT = { colorLabel: "Font color", resetToBlack: "Reset to black", fontStyleLabel: "Default math style", - fontSizeLabel: "Formula size (pt)", + fontSizeLabel: "Formula size", symbolFontLabel: "Symbol font", numberFontLabel: "Number font", cjkFontLabel: "CJK font", - followSymbolFont: "Follow symbol font", presetTitle: "Global formula preset", presetHint: "Import replaces this host's formula defaults. The JSON filename names the preset. Editor changes apply to the current formula.", importPreset: "Import JSON", @@ -79,13 +77,11 @@ const TEXT = { followHostSize: "Follow text size for new formulas (use the size above when unavailable)", editorTitle: "Editor Keyboard Behavior", acceptShortcut: "insert or update the current formula", - newlineShortcut: "start a new math row", fractionShortcut: "insert a fraction", rootShortcut: "insert a square root", superscriptShortcut: "insert a superscript", subscriptShortcut: "insert a subscript", scriptsShortcut: "insert superscript and subscript", - cancelShortcut: "hide the MathLive virtual keyboard", }, }; @@ -128,40 +124,6 @@ const numberFontInput = document.getElementById("numberFontFamily"); const cjkFontInput = document.getElementById("cjkFontFamily"); const presetStatus = document.getElementById("presetStatus"); -function populateFonts(payload) { - const setOptions = (select, values, selected) => { - select.replaceChildren(); - for (const value of [...new Set([...values, selected])]) { - if (value === undefined || value === null) continue; - select.add(new Option(value || strings().followSymbolFont, value)); - } - select.value = selected; - }; - setOptions(symbolFontInput, payload.symbolFonts, symbolFontId); - setOptions(numberFontInput, ["", ...payload.systemFonts], numberFontFamily); - setOptions(cjkFontInput, payload.cjkFonts, cjkFontFamily); -} - -function populateMathStyles(styles) { - formulaMathStyleSelect.replaceChildren(); - for (const style of styles) { - formulaMathStyleSelect.add(new Option(locale.startsWith("zh") ? style.zh : style.en, style.id)); - } -} - -function populateFontSizes(payload) { - formulaFontSizePointsInput.replaceChildren(); - for (const size of payload.namedSizes) { - formulaFontSizePointsInput.add(new Option(size.name, String(size.points))); - } - for (const points of payload.commonPointSizes) { - formulaFontSizePointsInput.add(new Option(String(points), String(points))); - } - if (![...formulaFontSizePointsInput.options].some((option) => Number(option.value) === formulaFontSizePoints)) { - formulaFontSizePointsInput.add(new Option(String(formulaFontSizePoints), String(formulaFontSizePoints))); - } -} - function strings() { return locale.startsWith("zh") ? TEXT.zh : TEXT.en; } @@ -172,6 +134,7 @@ function send(message) { function applyText() { const dict = strings(); + document.title = dict.title; document.documentElement.lang = locale.startsWith("zh") ? "zh-CN" : "en"; document.querySelectorAll("[data-i18n]").forEach((node) => { node.textContent = dict[node.dataset.i18n] || node.textContent; @@ -257,10 +220,8 @@ function init(payload) { symbolFontId = String(payload?.symbolFontId || "mathjax-tex"); numberFontFamily = String(payload?.numberFontFamily || ""); cjkFontFamily = String(payload?.cjkFontFamily || "Microsoft YaHei"); - populateFontSizes(payload); applyText(); - populateMathStyles(payload.mathStyles); - populateFonts(payload); + populateTypographyOptions(payload, {symbolFontId, numberFontFamily, cjkFontFamily, formulaFontSizePoints}, locale); applyPlatform(); renderPlacement(); renderBackend(); diff --git a/office_plugin/hosts/WordAddIn/WordAddInFactory.cs b/office_plugin/hosts/WordAddIn/WordAddInFactory.cs index cf824d66..c0b753e3 100644 --- a/office_plugin/hosts/WordAddIn/WordAddInFactory.cs +++ b/office_plugin/hosts/WordAddIn/WordAddInFactory.cs @@ -14,7 +14,8 @@ public static WordPluginController CreateController( IWordStatusSink? statusSink = null, IWordFormulaOptionsProvider? optionsProvider = null, Func? settingsLoader = null, - string? mathJaxHostName = null) + string? mathJaxHostName = null, + IWordApplicationAdapter? applicationAdapter = null) { statusSink ??= NullWordStatusSink.Instance; var oleIntermediateRenderer = new MathJaxSvgRenderer( @@ -22,7 +23,7 @@ public static WordPluginController CreateController( var editor = new MathLiveFormulaEditor(CreateEditorOptions(oleIntermediateRenderer)); var editorSession = new FormulaEditorSession(editor); var automationClient = new AutomationApiClient(new AutomationApiOptions()); - var wordAdapter = new DynamicWordApplicationAdapter(wordApplication); + IWordApplicationAdapter wordAdapter = applicationAdapter ?? new DynamicWordApplicationAdapter(wordApplication); var olePresentationPipeline = new OlePresentationPipeline(new IOlePresentationRenderer[] { new EnhancedMetafilePresentationRenderer() }); var controller = new WordPluginController( diff --git a/office_plugin/hosts/WordAddIn/WordAddInText.cs b/office_plugin/hosts/WordAddIn/WordAddInText.cs index a232daff..ca3b5abc 100644 --- a/office_plugin/hosts/WordAddIn/WordAddInText.cs +++ b/office_plugin/hosts/WordAddIn/WordAddInText.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using LaTeXSnipper.OfficePlugin.Abstractions; namespace LaTeXSnipper.OfficePlugin.WordAddIn; @@ -7,18 +8,7 @@ public static class WordAddInText { public static string GetExceptionMessage(Exception exception) { - string message = exception?.Message?.Trim() ?? string.Empty; - foreach (char value in message) - { - if (value >= '\u3400' && value <= '\u9fff') - { - return message; - } - } - - return exception is TimeoutException - ? "操作超时,请稍后重试。" - : "操作失败,请重试;若问题持续,请重新启动 Office 和 LaTeXSnipper。"; + return OfficeOperationError.Describe(exception); } public static string Get(string key) @@ -101,7 +91,7 @@ private static string GetEn(string key) "EditingFormulaTitle" => "Editing Formula", "HelpStatus" => "Help opened.", "SettingsStatus" => "Settings opened.", - "SettingsTitle" => "LaTeXSnipper Office Plugin Settings", + "SettingsTitle" => "LaTeXSnipper Word Plugin Settings", "SettingsNumberingGroup" => "Default numbered formula layout", "SettingsNumberRight" => "Number on the right", "SettingsNumberLeft" => "Number on the left", @@ -138,10 +128,12 @@ private static string GetEn(string key) "SectionBoundaryText" => "Section boundary", "ConvertedStatus" => "Converted {count} formulas.", "ConvertedWithSkippedStatus" => "Converted {count} formulas; skipped {skipped} missing formulas.", + "ConvertedWithFailuresStatus" => "Processed {total} formulas: converted {succeeded}, failed {failed}, skipped {skipped}. First error: {reason}", "NoConversionTargetsStatus" => "No formulas to convert were found.", "NoConversionNeededStatus" => "The selected formulas already use the target format.", "FormattedStatus" => "Formatted {count} formulas.", "FormattedWithSkippedStatus" => "Formatted {count} formulas; skipped {skipped} missing formulas.", + "FormattedWithFailuresStatus" => "Processed {total} formulas: formatted {succeeded}, failed {failed}, skipped {skipped}. First error: {reason}", "NoFormattingTargetsStatus" => "No formulas to format were found.", "NoFormattingNeededStatus" => "The selected formulas already use the default style.", "BatchConvertingStatus" => "Converting formulas: {processed}/{total}.", @@ -149,7 +141,8 @@ private static string GetEn(string key) "BatchParsingStatus" => "Parsing formulas: {processed}/{total}.", "NoParsingTargetsStatus" => "No formulas to parse were found.", "ParsedStatus" => "Parsed {count} formulas.", - "ParsedWithFailuresStatus" => "Processed {total} formulas: {succeeded} succeeded, {failed} failed.", + "ParsedWithFailuresStatus" => "Processed {total} formulas: {succeeded} succeeded, {failed} failed. First error: {reason}", + "InvalidParsingSourceStatus" => "The LaTeX delimiter or equation tag is incomplete.", "ParseSourceChangedError" => "The source text changed before it could be parsed.", _ => key, }; @@ -229,7 +222,7 @@ private static string GetZh(string key) "EditingFormulaTitle" => "正在编辑公式", "HelpStatus" => "已打开帮助。", "SettingsStatus" => "已打开设置。", - "SettingsTitle" => "LaTeXSnipper Office 插件设置", + "SettingsTitle" => "LaTeXSnipper Word 插件设置", "SettingsNumberingGroup" => "带编号公式默认布局", "SettingsNumberRight" => "编号在右侧", "SettingsNumberLeft" => "编号在左侧", @@ -266,10 +259,12 @@ private static string GetZh(string key) "SectionBoundaryText" => "节分隔符", "ConvertedStatus" => "已转换 {count} 个公式。", "ConvertedWithSkippedStatus" => "已转换 {count} 个公式,跳过 {skipped} 个已不存在的公式。", + "ConvertedWithFailuresStatus" => "共处理 {total} 个公式:成功转换 {succeeded} 个,失败 {failed} 个,跳过 {skipped} 个。首个错误:{reason}", "NoConversionTargetsStatus" => "未找到可转换的公式。", "NoConversionNeededStatus" => "所选公式已经是目标格式。", "FormattedStatus" => "已格式化 {count} 个公式。", "FormattedWithSkippedStatus" => "已格式化 {count} 个公式,跳过 {skipped} 个已不存在的公式。", + "FormattedWithFailuresStatus" => "共处理 {total} 个公式:成功格式化 {succeeded} 个,失败 {failed} 个,跳过 {skipped} 个。首个错误:{reason}", "NoFormattingTargetsStatus" => "未找到可格式化的公式。", "NoFormattingNeededStatus" => "所选公式已经是默认样式。", "BatchConvertingStatus" => "正在转换公式:{processed}/{total}。", @@ -277,7 +272,8 @@ private static string GetZh(string key) "BatchParsingStatus" => "正在解析公式:{processed}/{total}。", "NoParsingTargetsStatus" => "未找到可解析的公式。", "ParsedStatus" => "已解析 {count} 个公式。", - "ParsedWithFailuresStatus" => "共处理 {total} 个公式,成功 {succeeded} 个,失败 {failed} 个。", + "ParsedWithFailuresStatus" => "共处理 {total} 个公式,成功 {succeeded} 个,失败 {failed} 个。首个错误:{reason}", + "InvalidParsingSourceStatus" => "LaTeX 定界符或公式编号标记不完整。", "ParseSourceChangedError" => "源文本在解析前已发生变化。", _ => key, }; diff --git a/office_plugin/hosts/WordAddIn/WordPluginController.DocumentCommands.cs b/office_plugin/hosts/WordAddIn/WordPluginController.DocumentCommands.cs index 6a4c6077..b579a5e1 100644 --- a/office_plugin/hosts/WordAddIn/WordPluginController.DocumentCommands.cs +++ b/office_plugin/hosts/WordAddIn/WordPluginController.DocumentCommands.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -73,6 +74,15 @@ private async Task ConvertSelectedAsync(FormulaInsertionBackend target, Cancella : RenderEngineKind.Omml; int convertedCount = 0; int skippedCount = 0; + int failedCount = 0; + string? firstFailure = null; + void RecordFailure(int start, string stage, Exception exception) + { + failedCount++; + firstFailure ??= WordAddInText.GetExceptionMessage(exception); + Trace.TraceWarning("Formula conversion {0} failed at {1}: {2}", stage, start, exception); + } + for (int batchStart = 0; batchStart < formulas.Count; batchStart += BatchFormulaOperationSize) { WordFormulaEntry[] batch = formulas @@ -83,51 +93,62 @@ private async Task ConvertSelectedAsync(FormulaInsertionBackend target, Cancella foreach (WordFormulaEntry entry in batch) { cancellationToken.ThrowIfCancellationRequested(); - if (entry.IsNativeWordFormula) + try { - if (target != FormulaInsertionBackend.Ole) + if (entry.IsNativeWordFormula) + { + if (target != FormulaInsertionBackend.Ole) + { + continue; + } + + if (!_wordAdapter.ContainsNativeWordFormula(entry.Start)) + { + skippedCount++; + continue; + } + + FormulaMetadata native = CreateMetadataFromNativeWordFormula(entry); + PreparedWordFormula nativePrepared = await PrepareRenderedFormulaAsync( + native, + includeEquationOoxml: false, + cancellationToken, + FormulaInsertionBackend.Ole, + reportProgress: false); + preparedBatch.Add((entry, nativePrepared)); + continue; + } + + FormulaMetadata formula = entry.Metadata + ?? throw new InvalidOperationException(WordAddInText.Get("SelectedFormulaMetadataMissing")); + if (formula.RenderEngine == targetEngine) { continue; } - if (!_wordAdapter.ContainsNativeWordFormula(entry.Start)) + if (!_wordAdapter.ContainsFormula(formula.Identity.EquationId)) { skippedCount++; continue; } - FormulaMetadata native = CreateMetadataFromNativeWordFormula(entry); - PreparedWordFormula nativePrepared = await PrepareRenderedFormulaAsync( - native, - includeEquationOoxml: false, + FormulaMetadata converted = WithRenderEngine(formula, targetEngine); + PreparedWordFormula prepared = await PrepareRenderedFormulaAsync( + converted, + includeEquationOoxml: true, cancellationToken, - FormulaInsertionBackend.Ole, + target, reportProgress: false); - preparedBatch.Add((entry, nativePrepared)); - continue; + preparedBatch.Add((entry, prepared)); } - - FormulaMetadata formula = entry.Metadata - ?? throw new InvalidOperationException(WordAddInText.Get("SelectedFormulaMetadataMissing")); - if (formula.RenderEngine == targetEngine) + catch (OperationCanceledException) { - continue; + throw; } - - if (!_wordAdapter.ContainsFormula(formula.Identity.EquationId)) + catch (Exception exception) { - skippedCount++; - continue; + RecordFailure(entry.Start, "preparation", exception); } - - FormulaMetadata converted = WithRenderEngine(formula, targetEngine); - PreparedWordFormula prepared = await PrepareRenderedFormulaAsync( - converted, - includeEquationOoxml: true, - cancellationToken, - target, - reportProgress: false); - preparedBatch.Add((entry, prepared)); } using (_wordAdapter.BeginUndoRecord()) @@ -135,40 +156,56 @@ private async Task ConvertSelectedAsync(FormulaInsertionBackend target, Cancella foreach ((WordFormulaEntry entry, PreparedWordFormula prepared) in preparedBatch) { cancellationToken.ThrowIfCancellationRequested(); - if (entry.IsNativeWordFormula) + try { - if (!_wordAdapter.ContainsNativeWordFormula(entry.Start)) + if (entry.IsNativeWordFormula) + { + if (!_wordAdapter.ContainsNativeWordFormula(entry.Start)) + { + skippedCount++; + continue; + } + + await _wordAdapter.ReplaceNativeWordFormulaWithOleAsync( + entry.Start, + prepared.Metadata, + prepared.OlePresentation!, + prepared.Display, + cancellationToken); + convertedCount++; + continue; + } + + string equationId = prepared.Metadata.Identity.EquationId; + if (!_wordAdapter.ContainsFormula(equationId)) { skippedCount++; continue; } - await _wordAdapter.ReplaceNativeWordFormulaWithOleAsync( - entry.Start, - prepared.Metadata, - prepared.OlePresentation!, - prepared.Display, - cancellationToken); + await UpdatePreparedFormulaAsync(prepared, cancellationToken, reportStatus: false); convertedCount++; - continue; } - - string equationId = prepared.Metadata.Identity.EquationId; - if (!_wordAdapter.ContainsFormula(equationId)) + catch (OperationCanceledException) { - skippedCount++; - continue; + throw; + } + catch (Exception exception) + { + RecordFailure(entry.Start, "replacement", exception); } - - await UpdatePreparedFormulaAsync(prepared, cancellationToken, reportStatus: false); - convertedCount++; } } PostBatchProgress("BatchConvertingStatus", Math.Min(batchStart + batch.Length, formulas.Count), formulas.Count); } - if (convertedCount == 0) + if (failedCount > 0) + { + PostBatchFailures("ConvertedWithFailuresStatus", targetCount, convertedCount, failedCount, skippedCount, firstFailure!); + return; + } + else if (convertedCount == 0) { _statusSink.Post(WordStatusKind.Info, WordAddInText.Get("NoConversionNeededStatus")); return; @@ -191,13 +228,22 @@ private async Task FormatAsync(bool all, CancellationToken cancellationToken) int formattedCount = 0; int skippedCount = 0; + int failedCount = 0; + string? firstFailure = null; + void RecordFailure(int start, string stage, Exception exception) + { + failedCount++; + firstFailure ??= WordAddInText.GetExceptionMessage(exception); + Trace.TraceWarning("Formula formatting {0} failed at {1}: {2}", stage, start, exception); + } + for (int batchStart = 0; batchStart < formulas.Count; batchStart += BatchFormulaOperationSize) { WordFormulaEntry[] batch = formulas .Skip(batchStart) .Take(BatchFormulaOperationSize) .ToArray(); - var preparedBatch = new List(); + var preparedBatch = new List<(WordFormulaEntry Entry, PreparedWordFormula Prepared)>(); foreach (WordFormulaEntry entry in batch) { cancellationToken.ThrowIfCancellationRequested(); @@ -206,83 +252,110 @@ private async Task FormatAsync(bool all, CancellationToken cancellationToken) continue; } - FormulaMetadata formula = entry.Metadata - ?? throw new InvalidOperationException(WordAddInText.Get("SelectedFormulaMetadataMissing")); - if (!NeedsFormatting(formula, settings)) + try { - continue; - } + FormulaMetadata formula = entry.Metadata + ?? throw new InvalidOperationException(WordAddInText.Get("SelectedFormulaMetadataMissing")); + if (!NeedsFormatting(formula, settings)) + { + continue; + } - if (!_wordAdapter.ContainsFormula(formula.Identity.EquationId)) - { - skippedCount++; - continue; - } + if (!_wordAdapter.ContainsFormula(formula.Identity.EquationId)) + { + skippedCount++; + continue; + } - FormulaMetadata formatted = WithDefaultStyle(formula, settings); - if (formula.RenderEngine == RenderEngineKind.MathJaxSvg) + FormulaMetadata formatted = WithDefaultStyle(formula, settings); + if (formula.RenderEngine == RenderEngineKind.MathJaxSvg) + { + PreparedWordFormula prepared = await PrepareRenderedFormulaAsync( + formatted, + includeEquationOoxml: false, + cancellationToken, + FormulaInsertionBackend.Ole, + reportProgress: false); + preparedBatch.Add((entry, prepared)); + } + else + { + PreparedWordFormula prepared = await PrepareRenderedFormulaAsync( + formatted, + includeEquationOoxml: true, + cancellationToken, + FormulaInsertionBackend.WordOmml, + reportProgress: false); + preparedBatch.Add((entry, prepared)); + } + } + catch (OperationCanceledException) { - PreparedWordFormula prepared = await PrepareRenderedFormulaAsync( - formatted, - includeEquationOoxml: false, - cancellationToken, - FormulaInsertionBackend.Ole, - reportProgress: false); - preparedBatch.Add(prepared); + throw; } - else + catch (Exception exception) { - PreparedWordFormula prepared = await PrepareRenderedFormulaAsync( - formatted, - includeEquationOoxml: true, - cancellationToken, - FormulaInsertionBackend.WordOmml, - reportProgress: false); - preparedBatch.Add(prepared); + RecordFailure(entry.Start, "preparation", exception); } } using (_wordAdapter.BeginUndoRecord()) { - foreach (PreparedWordFormula prepared in preparedBatch) + foreach ((WordFormulaEntry entry, PreparedWordFormula prepared) in preparedBatch) { cancellationToken.ThrowIfCancellationRequested(); - FormulaMetadata formatted = prepared.Metadata; - if (!_wordAdapter.ContainsFormula(formatted.Identity.EquationId)) + try { - skippedCount++; - continue; - } + FormulaMetadata formatted = prepared.Metadata; + if (!_wordAdapter.ContainsFormula(formatted.Identity.EquationId)) + { + skippedCount++; + continue; + } + + if (formatted.RenderEngine == RenderEngineKind.MathJaxSvg) + { + await _wordAdapter.ResetOleFormulaObjectAsync( + formatted.Identity.EquationId, + formatted, + prepared.OlePresentation!, + prepared.Display, + cancellationToken); + } + else + { + await _wordAdapter.UpdateFormulaAsync( + formatted.Identity.EquationId, + prepared.Ooxml!, + prepared.EquationOoxml!, + prepared.EquationContentOoxml!, + formatted, + prepared.Display, + cancellationToken, preserveUserScale: false); + } - if (formatted.RenderEngine == RenderEngineKind.MathJaxSvg) + formattedCount++; + } + catch (OperationCanceledException) { - await _wordAdapter.ResetOleFormulaObjectAsync( - formatted.Identity.EquationId, - formatted, - prepared.OlePresentation!, - prepared.Display, - cancellationToken); + throw; } - else + catch (Exception exception) { - await _wordAdapter.UpdateFormulaAsync( - formatted.Identity.EquationId, - prepared.Ooxml!, - prepared.EquationOoxml!, - prepared.EquationContentOoxml!, - formatted, - prepared.Display, - cancellationToken, preserveUserScale: false); + RecordFailure(entry.Start, "replacement", exception); } - - formattedCount++; } } PostBatchProgress("BatchFormattingStatus", Math.Min(batchStart + batch.Length, formulas.Count), formulas.Count); } - if (formattedCount == 0) + if (failedCount > 0) + { + PostBatchFailures("FormattedWithFailuresStatus", formulas.Count, formattedCount, failedCount, skippedCount, firstFailure!); + return; + } + else if (formattedCount == 0) { _statusSink.Post(WordStatusKind.Info, WordAddInText.Get("NoFormattingNeededStatus")); return; @@ -350,4 +423,15 @@ private static string BuildChangedStatus(string changedKey, string skippedKey, i .Replace("{count}", changed.ToString(System.Globalization.CultureInfo.InvariantCulture)); return message.Replace("{skipped}", skipped.ToString(System.Globalization.CultureInfo.InvariantCulture)); } + + private void PostBatchFailures(string key, int total, int succeeded, int failed, int skipped, string reason) + { + string message = WordAddInText.Get(key) + .Replace("{total}", total.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Replace("{succeeded}", succeeded.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Replace("{failed}", failed.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Replace("{skipped}", skipped.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Replace("{reason}", reason); + _statusSink.Post(succeeded == 0 ? WordStatusKind.Error : WordStatusKind.Info, message); + } } diff --git a/office_plugin/hosts/WordAddIn/WordPluginController.Parsing.cs b/office_plugin/hosts/WordAddIn/WordPluginController.Parsing.cs index 973771e8..80f46631 100644 --- a/office_plugin/hosts/WordAddIn/WordPluginController.Parsing.cs +++ b/office_plugin/hosts/WordAddIn/WordPluginController.Parsing.cs @@ -38,6 +38,7 @@ private async Task ParseLatexAsync(bool all, CancellationToken cancellationToken int succeeded = 0; int failed = 0; + string? firstFailure = null; for (int batchStart = 0; batchStart < candidates.Count; batchStart += BatchFormulaOperationSize) { @@ -57,6 +58,7 @@ private async Task ParseLatexAsync(bool all, CancellationToken cancellationToken if (!preprocessed.Success) { failed++; + firstFailure ??= WordAddInText.Get("InvalidParsingSourceStatus"); continue; } @@ -84,6 +86,7 @@ private async Task ParseLatexAsync(bool all, CancellationToken cancellationToken exception.GetType().Name, exception.Message); failed++; + firstFailure ??= WordAddInText.GetExceptionMessage(exception); } } @@ -126,6 +129,7 @@ await _wordAdapter.ReplaceParsedOmmlFormulaAsync( exception.GetType().Name, exception.Message); failed++; + firstFailure ??= WordAddInText.GetExceptionMessage(exception); } } } @@ -142,8 +146,9 @@ await _wordAdapter.ReplaceParsedOmmlFormulaAsync( : WordAddInText.Get("ParsedWithFailuresStatus") .Replace("{total}", candidates.Count.ToString(CultureInfo.InvariantCulture)) .Replace("{succeeded}", succeeded.ToString(CultureInfo.InvariantCulture)) - .Replace("{failed}", failed.ToString(CultureInfo.InvariantCulture)); - _statusSink.Post(failed == 0 ? WordStatusKind.Success : WordStatusKind.Info, status); + .Replace("{failed}", failed.ToString(CultureInfo.InvariantCulture)) + .Replace("{reason}", firstFailure ?? string.Empty); + _statusSink.Post(failed == 0 ? WordStatusKind.Success : succeeded == 0 ? WordStatusKind.Error : WordStatusKind.Info, status); } private FormulaMetadata CreateParsedFormulaMetadata( diff --git a/office_plugin/hosts/WordAddIn/WordSettingsWindow.cs b/office_plugin/hosts/WordAddIn/WordSettingsWindow.cs index c319621c..8895ef84 100644 --- a/office_plugin/hosts/WordAddIn/WordSettingsWindow.cs +++ b/office_plugin/hosts/WordAddIn/WordSettingsWindow.cs @@ -105,6 +105,11 @@ private async Task InitializeAsync() SettingsHostName, assetsRoot, CoreWebView2HostResourceAccessKind.Allow); + core.SetVirtualHostNameToFolderMapping( + "latexsnipper-editor-shared.officeplugin.local", + InstalledAssetResolver.FindSharedAssetRoot("settings.css") + ?? throw new DirectoryNotFoundException("Shared settings assets were not found."), + CoreWebView2HostResourceAccessKind.Allow); core.WebMessageReceived += OnWebMessageReceived; core.NavigationCompleted += OnNavigationCompleted; _webView.Source = new Uri("https://" + SettingsHostName + "/settings.html?_=" + DateTime.UtcNow.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture)); diff --git a/office_plugin/installer/README.md b/office_plugin/installer/README.md index ad6333a7..9d9c6f1e 100644 --- a/office_plugin/installer/README.md +++ b/office_plugin/installer/README.md @@ -1,12 +1,12 @@ # LaTeXSnipper Office Plugin Installer -Produces the released Word and PowerPoint plugin installer with Inno Setup 6. +Produces the released Word and PowerPoint plugin installer with Inno Setup 6 or newer. ## Prerequisites - Inno Setup 6+ (install from ) -- Visual Studio 2022 with Office/SharePoint development workload (for VSTO MSBuild) -- Visual Studio 2022 Visual C++ ATL components (for the native OLE formula object) +- Visual Studio 2022 or newer with Office/SharePoint development workload (for VSTO MSBuild) +- Visual C++ ATL components (for the native OLE formula object) - .NET 9.0 SDK (for dotnet build of shared libraries) ## Build @@ -18,7 +18,7 @@ build.bat Release The build reads the shared product version from the repository `VERSION` file. -Output: `office_plugin\release\LaTeXSnipperOffice_3.1.0_amd64.exe` +Output: `office_plugin\release\LaTeXSnipperOffice__amd64.exe` and its adjacent `.sha256` file. The SignPath Office artifact configuration matches the same filename pattern. ## Installation Responsibilities diff --git a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe index 48d57b22..4404798b 100644 --- a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe +++ b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dcad17eff0dc66e49a71ae87d05c54c044d852787a6649061892a8f3a4e2c77f -size 7504510 +oid sha256:629b7cbe0c8a56a3e8dd4329ae1b6951200959482fb2a43ba4e9a66bf01d9c55 +size 7506108 diff --git a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 index f218051f..a351c9c3 100644 --- a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 +++ b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 @@ -1 +1 @@ -dcad17eff0dc66e49a71ae87d05c54c044d852787a6649061892a8f3a4e2c77f LaTeXSnipperOffice_3.1.0_amd64.exe +629b7cbe0c8a56a3e8dd4329ae1b6951200959482fb2a43ba4e9a66bf01d9c55 LaTeXSnipperOffice_3.1.0_amd64.exe diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/OfficeOperationError.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/OfficeOperationError.cs new file mode 100644 index 00000000..cfe8ac35 --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/OfficeOperationError.cs @@ -0,0 +1,48 @@ +using System; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace LaTeXSnipper.OfficePlugin.Abstractions; + +public static class OfficeOperationError +{ + public static string Describe(Exception exception) + { + if (exception == null) + { + throw new ArgumentNullException(nameof(exception)); + } + + Exception cause = exception; + while (cause.InnerException != null + && (cause is TargetInvocationException + || cause is AggregateException aggregate && aggregate.InnerExceptions.Count == 1)) + { + cause = cause.InnerException; + } + string message = cause.Message.Trim(); + string detail = string.IsNullOrEmpty(message) ? cause.GetType().Name : message; + if (cause.GetBaseException() is COMException comException) + { + detail += " (HRESULT 0x" + comException.ErrorCode.ToString("X8", CultureInfo.InvariantCulture) + ")"; + } + + bool localized = false; + foreach (char value in detail) + { + if (value >= '\u3400' && value <= '\u9fff') + { + localized = true; + break; + } + } + + if (cause is TimeoutException) + { + return "操作超时:" + detail; + } + + return localized ? detail : "操作失败:" + detail; + } +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/cjk-font-picker.mjs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/cjk-font-picker.mjs new file mode 100644 index 00000000..67b83dce --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/cjk-font-picker.mjs @@ -0,0 +1,11 @@ +export function populateCjkFonts(select, fonts, current, zh) { + select.replaceChildren(); + for (const font of fonts) select.add(new Option(font.label, font.id)); + if (!fonts.some(font => font.id === current)) { + const prompt = new Option(zh ? '请选择汉字字体' : 'Choose a Chinese font', current); + prompt.disabled = true; + prompt.hidden = true; + select.add(prompt); + } + select.value = current; +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/settings-typography.mjs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/settings-typography.mjs new file mode 100644 index 00000000..0c1d3d9c --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/settings-typography.mjs @@ -0,0 +1,28 @@ +import {populateCjkFonts} from './cjk-font-picker.mjs'; + +export function populateTypographyOptions(payload, current, locale) { + const zh = locale.startsWith('zh'); + const options = (id, values, selected) => { + const select = document.getElementById(id); + select.replaceChildren(); + for (const value of new Set([...values, selected])) { + select.add(new Option(value || (zh ? '跟随符号字体' : 'Follow symbol font'), value)); + } + select.value = selected; + }; + options('symbolFontId', payload.symbolFonts, current.symbolFontId); + options('numberFontFamily', ['', ...payload.systemFonts], current.numberFontFamily); + populateCjkFonts(document.getElementById('cjkFontFamily'), payload.cjkFonts, current.cjkFontFamily, zh); + + const styles = document.getElementById('formulaMathStyle'); + styles.replaceChildren(); + for (const style of payload.mathStyles) styles.add(new Option(zh ? style.zh : style.en, style.id)); + + const sizes = document.getElementById('formulaFontSizePoints'); + sizes.replaceChildren(); + for (const size of payload.namedSizes) sizes.add(new Option(size.name, String(size.points))); + for (const points of payload.commonPointSizes) sizes.add(new Option(String(points), String(points))); + if (![...sizes.options].some(option => Number(option.value) === current.formulaFontSizePoints)) { + sizes.add(new Option(String(current.formulaFontSizePoints), String(current.formulaFontSizePoints))); + } +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/settings.css b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/settings.css new file mode 100644 index 00000000..cd06be8c --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/settings.css @@ -0,0 +1,111 @@ +:root { + color-scheme: light; + --bg: #f4f7fc; + --panel: #fff; + --line: #dce4ef; + --text: #182b45; + --muted: #66788f; + --accent: #2563eb; + --accent-soft: #edf4ff; +} +* { box-sizing: border-box; } +body { + margin: 0; + color: var(--text); + background: var(--bg); + font: 14px/1.55 "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; +} +.shell { max-width: 960px; margin: 0 auto; padding: 28px; } +.header { margin-bottom: 24px; } +h1, h2, p { margin: 0; } +h1 { font-size: 22px; line-height: 1.4; font-weight: 650; } +h2 { font-size: 16px; font-weight: 650; } +.hint { margin-top: 6px; color: var(--muted); font-size: 13px; line-height: 1.65; } +.panel { + margin-top: 16px; + padding: 22px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); + box-shadow: 0 2px 6px #182b4503; +} +.settings-grid, .numbering-controls { margin-top: 20px; } +.settings-grid, .numbering-grid, .numbering-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.numbering-controls { display: grid; gap: 16px; } +.numbering-grid > .segmented, .follow-host-option { grid-column: 1 / -1; } +.segmented { + position: relative; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + padding: 4px; + border: 1px solid #e7edf5; + border-radius: 9px; + background: #f5f8fc; +} +.segmented::before { + content: ""; + position: absolute; + inset: 4px auto 4px 4px; + width: calc((100% - 14px) / 2); + border: 1px solid #c4d7f7; + border-radius: 7px; + background: #fff; + box-shadow: 0 1px 3px #182b4508; + transition: transform 180ms ease; + pointer-events: none; +} +.segmented:has(button:nth-child(2).active)::before { transform: translateX(calc(100% + 6px)); } +.backend-panel > .segmented { margin-top: 16px; } +label { display: grid; min-width: 0; gap: 7px; color: var(--muted); font-size: 13px; } +button, select, input[type="color"] { + min-height: 38px; + border: 1px solid #cad6e6; + border-radius: 7px; + background: #fff; + color: var(--text); + font: inherit; + transition: background-color 120ms, border-color 120ms, box-shadow 120ms; +} +select { width: 100%; min-width: 0; padding: 0 10px; cursor: pointer; } +button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 12px; cursor: pointer; } +button:hover, select:hover, input[type="color"]:hover { border-color: #8aaee9; background-color: #f7faff; } +button:active { background: #e3edfc; border-color: var(--accent); } +button:focus-visible, select:focus-visible, input:focus-visible { + outline: 2px solid #2563eb80; + outline-offset: 2px; +} +.segmented button { position: relative; border-color: transparent; background: transparent; } +.segmented button:hover { background: #eaf0f9; } +.segmented button.active { color: var(--accent); font-weight: 600; } +.segmented button.active:hover { background: transparent; } +.color-control { display: grid; grid-template-columns: 46px minmax(0, 1fr); gap: 8px; } +input[type="color"] { width: 100%; height: 38px; padding: 5px; cursor: pointer; } +input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; } +input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 4px; } +.size-control { position: relative; display: block; } +.size-control select { padding-right: 50px; } +.size-control > span { position: absolute; right: 30px; top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; } +.check-option { display: flex; align-items: center; gap: 9px; min-height: 34px; cursor: pointer; } +.check-option input { margin: 0; width: 16px; height: 16px; flex: none; accent-color: var(--accent); cursor: pointer; } +.check-option:hover { color: var(--accent); } +.follow-host-option { padding: 12px; border-radius: 7px; background: #f6f9fd; } +.preset-file-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 16px; } +.preset-file-actions button { min-width: 122px; } +.preset-file-actions svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +#presetStatus { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; } +#presetStatus:empty { display: none; } +#presetStatus.error { color: #b42318; } +.key-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 18px; margin-top: 18px; } +.key-list div { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; color: var(--muted); font-size: 13px; } +kbd { min-width: 30px; padding: 3px 6px; border: 1px solid #d9e2ee; border-bottom-width: 2px; border-radius: 5px; background: #f8fafd; color: var(--text); text-align: center; font: 12px "Cascadia Code", Consolas, monospace; } +@media (max-width: 520px) { + .shell { padding: 16px; } + .panel { padding: 18px; } + .settings-grid, .numbering-grid, .numbering-options, .key-list { grid-template-columns: 1fr; } +} +@media (prefers-reduced-motion: reduce) { button, select, input, .segmented::before { transition: none; } } diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/source-editor.css b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/source-editor.css index a1912371..10c00b32 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/source-editor.css +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/source-editor.css @@ -1,4 +1,5 @@ #latexSource { + container-type: inline-size; min-width: 0; min-height: 0; overflow: hidden; @@ -17,24 +18,58 @@ #latexSource .cm-cursor { border-left-color: var(--text); } #latexSource .cm-selectionBackground, #latexSource .cm-focused .cm-selectionBackground { background: #8db9ef55; } #latexSource .cm-panels, #latexSource .cm-tooltip { color: var(--text); background: var(--panel); border-color: var(--line); } +.workspace:has(#latexSource .cm-search) { grid-template-rows: minmax(0, 1fr) 8px max(var(--source-pane-height), 300px); } +#latexSource.source-host:has(.cm-search) { height: 300px; } #latexSource .cm-search { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); align-items: center; - gap: 6px; - padding: 8px 38px 8px 8px; + gap: 8px; + padding: 12px 44px 12px 12px; font: 12px/1.5 "Segoe UI", "Microsoft YaHei", sans-serif; } -#latexSource .cm-search br { flex-basis: 100%; height: 0; } -#latexSource .cm-search label { display: inline-flex; align-items: center; gap: 4px; margin: 0; font: inherit; white-space: nowrap; } -#latexSource .cm-search input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); } +#latexSource .cm-search br { display: none; } +#latexSource .cm-search label { display: inline-flex; align-items: center; gap: 7px; margin: 0; padding: 5px 7px; border: 1px solid transparent; border-radius: 6px; font: inherit; white-space: nowrap; cursor: pointer; transition: background-color 120ms, border-color 120ms; } +#latexSource .cm-search label:hover { background: var(--accent-soft); } +#latexSource .cm-search label:has(input:checked) { color: var(--accent); background: var(--accent-soft); border-color: var(--line); } +#latexSource .cm-search input[type="checkbox"] { width: 15px; height: 15px; margin: 0; accent-color: var(--accent); cursor: pointer; } #latexSource .cm-search input:not([type="checkbox"]), #latexSource .cm-search button { - color: var(--text); background: var(--source-bg); border: 1px solid var(--line); - font: inherit; min-height: 32px; padding: 5px 8px; + color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 6px; + font: inherit; min-height: 32px; min-width: 0; margin: 0; padding: 5px 8px; + transition: background-color 120ms, border-color 120ms, box-shadow 120ms; } -#latexSource .cm-search input:not([type="checkbox"]) { width: min(240px, 100%); min-width: 120px; } -#latexSource .cm-search button { white-space: nowrap; } -#latexSource .cm-search button[name="close"] { position: absolute; top: 8px; right: 6px; width: 28px; min-height: 28px; padding: 0; } +#latexSource .cm-search input:not([type="checkbox"]) { width: 100%; } +#latexSource .cm-search input::placeholder { color: var(--muted); } +#latexSource .cm-search button { background-image: none; white-space: nowrap; cursor: pointer; } +#latexSource .cm-search button:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } +#latexSource .cm-search button:active { box-shadow: inset 0 1px 3px #17203320; background: var(--accent-soft); } +#latexSource .cm-search input:focus-visible, #latexSource .cm-search button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +#latexSource .cm-search input[name="search"] { grid-area: 1 / 1 / 2 / 7; } +#latexSource .cm-search button[name="next"] { grid-area: 1 / 7 / 2 / 9; } +#latexSource .cm-search button[name="prev"] { grid-area: 1 / 9 / 2 / 11; } +#latexSource .cm-search button[name="select"] { grid-area: 1 / 11 / 2 / 13; } +#latexSource .cm-search label:has(input[name="case"]) { grid-area: 2 / 1 / 3 / 5; } +#latexSource .cm-search label:has(input[name="re"]) { grid-area: 2 / 5 / 3 / 9; } +#latexSource .cm-search label:has(input[name="word"]) { grid-area: 2 / 9 / 3 / 13; } +#latexSource .cm-search input[name="replace"] { grid-area: 3 / 1 / 4 / 7; } +#latexSource .cm-search button[name="replace"] { grid-area: 3 / 7 / 4 / 9; } +#latexSource .cm-search button[name="replaceAll"] { grid-area: 3 / 9 / 4 / 13; } +#latexSource .cm-search button[name="close"] { position: absolute; top: 12px; right: 8px; width: 28px; min-height: 28px; padding: 0; border-color: transparent; background: transparent; } +#latexSource .cm-search button[name="close"]:hover { background: var(--accent-soft); border-color: var(--line); } +@container (max-width: 480px) { + #latexSource .cm-search input[name="search"] { grid-area: 1 / 1 / 2 / 13; } + #latexSource .cm-search button[name="next"] { grid-area: 2 / 1 / 3 / 5; } + #latexSource .cm-search button[name="prev"] { grid-area: 2 / 5 / 3 / 9; } + #latexSource .cm-search button[name="select"] { grid-area: 2 / 9 / 3 / 13; } + #latexSource .cm-search label:has(input[name="case"]) { grid-area: 3 / 1 / 4 / 5; } + #latexSource .cm-search label:has(input[name="re"]) { grid-area: 3 / 5 / 4 / 9; } + #latexSource .cm-search label:has(input[name="word"]) { grid-area: 3 / 9 / 4 / 13; } + #latexSource .cm-search input[name="replace"] { grid-area: 4 / 1 / 5 / 13; } + #latexSource .cm-search button[name="replace"] { grid-area: 5 / 1 / 6 / 7; } + #latexSource .cm-search button[name="replaceAll"] { grid-area: 5 / 7 / 6 / 13; } + #latexSource .cm-search label { font-size: 11px; padding-inline: 0; gap: 4px; } +} +@media (prefers-reduced-motion: reduce) { #latexSource .cm-search * { transition: none; } } #latexSource .cm-matchingBracket { background: #65b8a540; outline: 1px solid #469f90; } #latexSource .cm-template-hole { display: inline-block; width: 5px; height: 12px; border: 1px solid var(--accent); vertical-align: text-bottom; } #latexSource .cm-template-field { background: var(--accent-soft); } diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/symbol-library.mjs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/symbol-library.mjs index 64eac0d2..2fbce553 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/symbol-library.mjs +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/symbol-library.mjs @@ -306,7 +306,7 @@ export const GROUPS = [ ["Calderon-Zygmund 估计", "\\|D^2u\\|_{L^p}\\le C\\|Lu\\|_{L^p}", "Calderon-Zygmund estimate"], ["Euler-Lagrange", "\\frac{\\partial L}{\\partial u}-\\partial_i\\frac{\\partial L}{\\partial u_i}=0", "Euler-Lagrange equation"], ["伪微分算子", "Pu(x)=\\int e^{ix\\cdot\\xi}p(x,\\xi)\\widehat u(\\xi)d\\xi", "Pseudodifferential operator"], - ["弱 Harnack", "\\left(\\fint_{B_R}u^p\\right)^{1/p}\\le C\\inf_{B_R}u", "Weak Harnack inequality"], + ["弱 Harnack", "\\left(\\frac{1}{|B_R|}\\int_{B_R}u^p\\right)^{1/p}\\le C\\inf_{B_R}u", "Weak Harnack inequality"], ["Strichartz 估计", "\\|e^{it\\Delta}f\\|_{L^q_tL^r_x}\\le C\\|f\\|_{L^2}", "Strichartz estimate"], ["能量估计", "\\|u\\|_{H^1}\\le C\\|f\\|_{H^{-1}}", "Energy estimate"], ["先验估计", "\\|u\\|_X\\le C\\|f\\|_Y", "A priori estimate"], @@ -494,7 +494,7 @@ export const GROUPS = [ ["Type II₁", "\\tau(1)=1", "Type II1 factor"], ["K₀", "K_0(A) = \\operatorname{Groth}(\\operatorname{Proj}(A))", "K0 group"], ["K₁", "K_1(A) = \\pi_0(\\operatorname{GL}_\\infty(A))", "K1 group"], - ["六项正合列", "\\begin{CD}K_0(I)@>>>K_0(A)@>>>K_0(A/I)\\\\@VVV@.@AAA\\\\K_1(A/I)@<< Cached = new Lazy<(string[], string[])>(Read); - - public static string[] List() => (string[])Cached.Value.All.Clone(); - - public static string[] ListCjk() => (string[])Cached.Value.Cjk.Clone(); - - private static (string[] All, string[] Cjk) Read() + private static readonly Lazy Installed = new Lazy(ReadInstalled); + private static readonly (string Id, string Label)[] ChineseFonts = + { + ("DengXian", "等线"), + ("DengXian Light", "等线 Light"), + ("FZShuTi", "方正舒体"), + ("FZYaoTi", "方正姚体"), + ("FangSong", "仿宋"), + ("SimHei", "黑体"), + ("STCaiyun", "华文彩云"), + ("STFangsong", "华文仿宋"), + ("STHupo", "华文琥珀"), + ("STKaiti", "华文楷体"), + ("STLiti", "华文隶书"), + ("STSong", "华文宋体"), + ("STXihei", "华文细黑"), + ("STXinwei", "华文新魏"), + ("STXingkai", "华文行楷"), + ("STZhongsong", "华文中宋"), + ("KaiTi", "楷体"), + ("LiSu", "隶书"), + ("SimSun", "宋体"), + ("Microsoft YaHei", "微软雅黑"), + ("Microsoft YaHei Light", "微软雅黑 Light"), + ("NSimSun", "新宋体"), + ("YouYuan", "幼圆"), + }; + + public static string[] List() => (string[])Installed.Value.Clone(); + + public static Dictionary[] ListCjk() => ChineseFonts.Select(font => + new Dictionary { ["id"] = font.Id, ["label"] = font.Label }).ToArray(); + + private static string[] ReadInstalled() { using var installed = new InstalledFontCollection(); var families = installed.Families; try { - return (families.Select(font => font.Name).Distinct().OrderBy(name => name).ToArray(), - families.Where(IsUsableHanFont).Select(font => font.Name).Distinct().OrderBy(name => name).ToArray()); + return families.Select(font => font.Name).Distinct().OrderBy(name => name).ToArray(); } finally { foreach (var family in families) family.Dispose(); } } - - private static bool IsUsableHanFont(FontFamily family) - { - try { return HasHanGlyph(family); } - catch (ArgumentException) { return false; } - catch (ExternalException) { return false; } - } - - private static bool HasHanGlyph(FontFamily family) - { - var styles = new[] { FontStyle.Regular, FontStyle.Bold, FontStyle.Italic, FontStyle.Bold | FontStyle.Italic }; - var style = styles.FirstOrDefault(family.IsStyleAvailable); - if (!family.IsStyleAvailable(style)) return false; - using var font = new Font(family, 12, style, GraphicsUnit.Pixel); - using var bitmap = new Bitmap(1, 1); - using var graphics = Graphics.FromImage(bitmap); - IntPtr handle = font.ToHfont(); - IntPtr dc = IntPtr.Zero; - IntPtr previous = IntPtr.Zero; - try - { - dc = graphics.GetHdc(); - previous = SelectObject(dc, handle); - if (previous == IntPtr.Zero || previous == new IntPtr(-1)) - throw new InvalidOperationException("无法检查已安装字体。"); - var glyphs = new ushort[HanProbe.Length]; - if (GetGlyphIndices(dc, HanProbe, HanProbe.Length, glyphs, 1) == uint.MaxValue) - throw new InvalidOperationException("无法检查汉字字体覆盖。"); - return glyphs.Any(glyph => glyph != ushort.MaxValue); - } - finally - { - if (previous != IntPtr.Zero && previous != new IntPtr(-1)) SelectObject(dc, previous); - if (dc != IntPtr.Zero) graphics.ReleaseHdc(dc); - DeleteObject(handle); - } - } - - [DllImport("gdi32.dll")] - private static extern IntPtr SelectObject(IntPtr dc, IntPtr value); - - [DllImport("gdi32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool DeleteObject(IntPtr value); - - [DllImport("gdi32.dll", EntryPoint = "GetGlyphIndicesW", CharSet = CharSet.Unicode, ExactSpelling = true)] - private static extern uint GetGlyphIndices(IntPtr dc, string text, int count, [Out] ushort[] glyphs, uint flags); } #endif diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathJaxTypographyAdapter.js b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathJaxTypographyAdapter.js index b2c18a86..cc3d08a7 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathJaxTypographyAdapter.js +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathJaxTypographyAdapter.js @@ -88,7 +88,8 @@ } function apply(math) { if (!active) return; - if (!math.root.attributes.hasExplicit('mathcolor')) math.root.attributes.set('mathcolor', active.typography.Color); + if (!math.root.attributes.hasExplicit('mathcolor') && active.typography.Color.toLowerCase() !== '#000000') + math.root.attributes.set('mathcolor', active.typography.Color); math.root.walkTree(node => { if (!node.isToken) return; const attrs = node.attributes; diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.MetadataSafety.Tests/AutomationApiClientTests.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.MetadataSafety.Tests/AutomationApiClientTests.cs index 095d5a9d..1cdd6110 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.MetadataSafety.Tests/AutomationApiClientTests.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.MetadataSafety.Tests/AutomationApiClientTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -32,6 +33,9 @@ public void UserFacingAutomationAndHostErrorsAreChinese() Assert.IsTrue(ContainsChinese(AutomationApiUserMessages.DesktopUnavailable)); Assert.IsTrue(ContainsChinese(WordAddInText.GetExceptionMessage(new InvalidOperationException("raw error")))); Assert.IsTrue(ContainsChinese(PowerPointAddInText.GetExceptionMessage(new InvalidOperationException("raw error")))); + StringAssert.Contains(WordAddInText.GetExceptionMessage(new InvalidOperationException("raw error")), "raw error"); + StringAssert.Contains(PowerPointAddInText.GetExceptionMessage(new InvalidOperationException("raw error")), "raw error"); + StringAssert.Contains(WordAddInText.GetExceptionMessage(new COMException("Command failed", unchecked((int)0x80040154))), "0x80040154"); Assert.AreEqual("已有中文提示。", WordAddInText.GetExceptionMessage(new InvalidOperationException("已有中文提示。"))); } diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/FailingPowerPointAdapter.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/FailingPowerPointAdapter.cs new file mode 100644 index 00000000..a27b7340 --- /dev/null +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/FailingPowerPointAdapter.cs @@ -0,0 +1,64 @@ +using System; +using System.Reflection; +using System.Runtime.Remoting.Messaging; +using System.Runtime.Remoting.Proxies; +using LaTeXSnipper.OfficePlugin.PowerPointAddIn; + +internal sealed class FailingPowerPointAdapter : RealProxy +{ + private readonly IPowerPointApplicationAdapter _inner; + private readonly string _equationId; + + private FailingPowerPointAdapter(IPowerPointApplicationAdapter inner, string equationId) + : base(typeof(IPowerPointApplicationAdapter)) + { + _inner = inner; + _equationId = equationId; + } + + public static IPowerPointApplicationAdapter Wrap(IPowerPointApplicationAdapter inner, string equationId) + { + return (IPowerPointApplicationAdapter)new FailingPowerPointAdapter(inner, equationId).GetTransparentProxy(); + } + + public override IMessage Invoke(IMessage message) + { + var call = (IMethodCallMessage)message; + var method = (MethodInfo)call.MethodBase; + try + { + if (method.Name == nameof(IPowerPointApplicationAdapter.ContainsFormula) + && string.Equals(call.Args[0] as string, _equationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Injected PowerPoint formula failure"); + } + + return new ReturnMessage(method.Invoke(_inner, call.Args), null, 0, call.LogicalCallContext, call); + } + catch (TargetInvocationException exception) + { + return new ReturnMessage(exception.InnerException ?? exception, call); + } + catch (Exception exception) + { + return new ReturnMessage(exception, call); + } + } +} + +internal sealed class BatchStatusSink : IPowerPointStatusSink +{ + public PowerPointStatusKind Kind { get; private set; } + public string Message { get; private set; } = string.Empty; + + public void Post(PowerPointStatusKind kind, string message) + { + Kind = kind; + Message = message; + Console.WriteLine("STATUS|" + kind + "|" + message); + } + + public void SetBusy(bool busy) { } + public void SetOcrActive(bool active) { } + public void SetCurrentFormula(string latex, bool updateMode) { } +} diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/PowerPointRoundTrip.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/PowerPointRoundTrip.cs index 59bf3045..e7434e13 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/PowerPointRoundTrip.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/PowerPointRoundTrip.cs @@ -1,34 +1,34 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; -using LaTeXSnipper.OfficePlugin.Abstractions; -using LaTeXSnipper.OfficePlugin.Automation; -using LaTeXSnipper.OfficePlugin.Editor; -using LaTeXSnipper.OfficePlugin.PowerPointAddIn; -using LaTeXSnipper.OfficePlugin.Rendering; - -internal static class PowerPointRoundTrip -{ - private static readonly CancellationToken Token = CancellationToken.None; - - public static async Task RunAsync(string output, bool includeOle) - { - if (Process.GetProcessesByName("POWERPNT").Length != 0) - throw new InvalidOperationException("Close PowerPoint before running this isolated test."); - if (File.Exists(output)) throw new IOException("Output already exists: " + output); - Directory.CreateDirectory(Path.GetDirectoryName(output)!); - string imagePath = Path.Combine(Path.GetTempPath(), "latexsnipper-ppt-" + Guid.NewGuid().ToString("N") + ".png"); - dynamic app = Activator.CreateInstance(Type.GetTypeFromProgID("PowerPoint.Application")!); - dynamic? presentation = null; - try - { - app.Visible = -1; - presentation = app.Presentations.Add(); +using LaTeXSnipper.OfficePlugin.Abstractions; +using LaTeXSnipper.OfficePlugin.Automation; +using LaTeXSnipper.OfficePlugin.Editor; +using LaTeXSnipper.OfficePlugin.PowerPointAddIn; +using LaTeXSnipper.OfficePlugin.Rendering; + +internal static class PowerPointRoundTrip +{ + private static readonly CancellationToken Token = CancellationToken.None; + + public static async Task RunAsync(string output, bool includeOle, bool batchOnly = false) + { + if (Process.GetProcessesByName("POWERPNT").Length != 0) + throw new InvalidOperationException("Close PowerPoint before running this isolated test."); + if (File.Exists(output)) throw new IOException("Output already exists: " + output); + Directory.CreateDirectory(Path.GetDirectoryName(output)!); + string imagePath = Path.Combine(Path.GetTempPath(), "latexsnipper-ppt-" + Guid.NewGuid().ToString("N") + ".png"); + dynamic app = Activator.CreateInstance(Type.GetTypeFromProgID("PowerPoint.Application")!); + dynamic? presentation = null; + try + { + app.Visible = -1; + presentation = app.Presentations.Add(); presentation.Slides.Add(1, 12); presentation.Slides.Add(2, 12); var adapter = new DynamicPowerPointApplicationAdapter(app); @@ -39,21 +39,29 @@ public static async Task RunAsync(string output, bool includeOle) new AutomationApiClient(new AutomationApiOptions()), adapter, renderer, new OlePresentationPipeline(new IOlePresentationRenderer[] { new EnhancedMetafilePresentationRenderer() }), optionsProvider: options); - var style = new FormulaTypography("mathjax-stix2", "Times New Roman", "SimSun", - FormulaMathStyle.BoldItalic, 15.5, "#123ABC"); - string[] sources = { @"\mathrm{\delta}+\text{条件概率 }P(A\mid B)", - @"\begin{align}x&=12\\y&=\frac{1}{2}\end{align}" }; - var originals = new Dictionary(); - for (int index = 0; index < sources.Length; index++) - { - var metadata = new FormulaMetadata(new FormulaIdentity(adapter.GetCurrentDocumentId(), Guid.NewGuid().ToString("N")), - sources[index], FormulaDisplayMode.Display, NumberingMode.None, "", RenderEngineKind.Image, - FormulaMetadata.CurrentSchemaVersion, style); - PowerPointRenderedImage image = await RenderAsync(renderer, metadata, imagePath); - await adapter.InsertFormulaImageOnSlideAsync(index + 1, image, metadata, 80, 90, 1.5f, Token); - originals.Add(metadata.Identity.EquationId, metadata.Latex); - } + var style = new FormulaTypography("mathjax-stix2", "Times New Roman", "SimSun", + FormulaMathStyle.BoldItalic, 15.5, "#123ABC"); + string[] sources = { @"\mathrm{\delta}+\text{条件概率 }P(A\mid B)", + @"\begin{align}x&=12\\y&=\frac{1}{2}\end{align}" }; + var originals = new Dictionary(); + for (int index = 0; index < sources.Length; index++) + { + var metadata = new FormulaMetadata(new FormulaIdentity(adapter.GetCurrentDocumentId(), Guid.NewGuid().ToString("N")), + sources[index], FormulaDisplayMode.Display, NumberingMode.None, "", RenderEngineKind.Image, + FormulaMetadata.CurrentSchemaVersion, style); + PowerPointRenderedImage image = await RenderAsync(renderer, metadata, imagePath); + await adapter.InsertFormulaImageOnSlideAsync(index + 1, image, metadata, 80, 90, 1.5f, Token); + originals.Add(metadata.Identity.EquationId, metadata.Latex); + } await VerifyAsync(adapter, style, originals, 1.5f); + if (batchOnly) + { + await VerifyBatchFailureHandlingAsync(adapter, (object)presentation, originals, style); + await controller.FormatAllAsync(Token); + await VerifyAsync(adapter, PowerPointPluginSettings.Load().Typography, originals, 1); + Console.WriteLine("PASS|PPT batch operations and retry"); + return; + } presentation.Windows.Item(1).View.GotoSlide(1); presentation.Slides.Item(1).Shapes.Item(1).Select(); Check(adapter.GetCurrentFontSizePoints() == 0, "Selected formula was treated as text font selection"); @@ -141,54 +149,69 @@ await controller.AcceptEditorFormulaAsync(new FormulaEditorAcceptedEventArgs( "The default formula was not inserted as a native equation at the text caret"); inlineTextBox.Delete(); Console.WriteLine("PASS|PPT default formula inserts as a native equation at the end of text"); + dynamic chineseTextBox = slide.Shapes.AddTextbox(1, 100, 450, 500, 100); + string chineseTextBoxName = Convert.ToString(chineseTextBox.Name); + chineseTextBox.TextFrame.TextRange.Text = "left right"; + chineseTextBox.TextFrame.TextRange.Characters(6, 0).Select(); + PowerPointTextInsertionTarget chineseTarget = adapter.CaptureTextInsertionTarget() + ?? throw new InvalidOperationException("PowerPoint text caret was not captured for a Chinese equation"); + string chineseMathMl = await renderer.ConvertTypographyToMathMlAsync(@"e^{i\pi}+1=0端", + FormulaDisplayMode.Inline, style, Token); + await adapter.InsertNativeEquationAsync(chineseTarget, chineseMathMl, 28, Token); + Check(Convert.ToInt32(chineseTextBox.TextFrame2.TextRange.MathZones(1, 1).Length) > 0, + "MathML containing Chinese did not become a native equation"); + Check(Convert.ToString(chineseTextBox.TextFrame.TextRange.Text).Contains("端"), + "The Chinese glyph was lost during native equation insertion"); + Console.WriteLine("PASS|PPT MathML with Chinese inserts as a native equation"); presentation.Slides.Item(1).Shapes.Item(1).Select(); Console.WriteLine("PASS|PPT selected formula still opens editor and inserts a centered formula"); var target = await adapter.LoadSelectedFormulaAsync(Token); - var edited = new FormulaMetadata(target.Metadata.Identity, target.Metadata.Latex + "+1", - FormulaDisplayMode.Display, NumberingMode.None, "", RenderEngineKind.Image, - FormulaMetadata.CurrentSchemaVersion, style); - await adapter.UpdateFormulaImageAsync(target, await RenderAsync(renderer, edited, imagePath), edited, Token); - originals[edited.Identity.EquationId] = edited.Latex; - await VerifyAsync(adapter, style, originals, 1.5f); - Console.WriteLine("PASS|PPT insert, selection, edit, scale and full typography"); - - if (includeOle) - { - await ConvertSlidesAsync((object)presentation, controller, toOle: true); - await VerifyAsync(adapter, style, originals, 1.5f, RenderEngineKind.MathJaxSvg); - presentation.Windows.Item(1).View.GotoSlide(1); - presentation.Slides.Item(1).Shapes.Item(1).Select(); - presentation.Slides.Item(1).Shapes.Item(1).OLEFormat.DoVerb(0); - var oleTarget = await adapter.LoadSelectedFormulaAsync(Token); - var oleEdit = new FormulaMetadata(oleTarget.Metadata.Identity, oleTarget.Metadata.Latex + "+2", - FormulaDisplayMode.Display, NumberingMode.None, "", RenderEngineKind.MathJaxSvg, - FormulaMetadata.CurrentSchemaVersion, style); - var vector = await renderer.RenderAsync(new RenderRequest(oleEdit.Latex, oleEdit.DisplayMode, - RenderEngineKind.MathJaxSvg, style), Token); - var emf = await new EnhancedMetafilePresentationRenderer().RenderPresentationAsync( - new OlePresentationRequest(vector, OlePresentationKind.EnhancedMetafile), Token); - await adapter.UpdateOleFormulaObjectAsync(oleTarget, oleEdit, emf, Token); - originals[oleEdit.Identity.EquationId] = oleEdit.Latex; - string olePath = Path.Combine(Path.GetDirectoryName(output)!, Path.GetFileNameWithoutExtension(output) + "-ole.pptx"); - if (File.Exists(olePath)) throw new IOException("Output already exists: " + olePath); - presentation.SaveAs(olePath, 24); - presentation.Close(); - presentation = app.Presentations.Open(olePath, 0, 0, -1); - await VerifyAsync(adapter, style, originals, 1.5f, RenderEngineKind.MathJaxSvg); - presentation.Windows.Item(1).View.GotoSlide(1); - presentation.Slides.Item(1).Shapes.Item(1).OLEFormat.DoVerb(0); - await ConvertSlidesAsync((object)presentation, controller, toOle: false); - await VerifyAsync(adapter, style, originals, 1.5f); - Console.WriteLine("PASS|PPT PNG/OLE conversion, activation, edit, save and reopen"); - } - - FormulaTypography defaults = PowerPointPluginSettings.Load().Typography; - await controller.FormatAllAsync(Token); - await VerifyAsync(adapter, defaults, originals, 1); - await controller.FormatAllAsync(Token); - await VerifyAsync(adapter, defaults, originals, 1); - presentation.SaveAs(output, 24); - presentation.Close(); + var edited = new FormulaMetadata(target.Metadata.Identity, target.Metadata.Latex + "+1", + FormulaDisplayMode.Display, NumberingMode.None, "", RenderEngineKind.Image, + FormulaMetadata.CurrentSchemaVersion, style); + await adapter.UpdateFormulaImageAsync(target, await RenderAsync(renderer, edited, imagePath), edited, Token); + originals[edited.Identity.EquationId] = edited.Latex; + await VerifyAsync(adapter, style, originals, 1.5f); + Console.WriteLine("PASS|PPT insert, selection, edit, scale and full typography"); + + if (includeOle) + { + await ConvertSlidesAsync((object)presentation, controller, toOle: true); + await VerifyAsync(adapter, style, originals, 1.5f, RenderEngineKind.MathJaxSvg); + presentation.Windows.Item(1).View.GotoSlide(1); + presentation.Slides.Item(1).Shapes.Item(1).Select(); + presentation.Slides.Item(1).Shapes.Item(1).OLEFormat.DoVerb(0); + var oleTarget = await adapter.LoadSelectedFormulaAsync(Token); + var oleEdit = new FormulaMetadata(oleTarget.Metadata.Identity, oleTarget.Metadata.Latex + "+2", + FormulaDisplayMode.Display, NumberingMode.None, "", RenderEngineKind.MathJaxSvg, + FormulaMetadata.CurrentSchemaVersion, style); + var vector = await renderer.RenderAsync(new RenderRequest(oleEdit.Latex, oleEdit.DisplayMode, + RenderEngineKind.MathJaxSvg, style), Token); + var emf = await new EnhancedMetafilePresentationRenderer().RenderPresentationAsync( + new OlePresentationRequest(vector, OlePresentationKind.EnhancedMetafile), Token); + await adapter.UpdateOleFormulaObjectAsync(oleTarget, oleEdit, emf, Token); + originals[oleEdit.Identity.EquationId] = oleEdit.Latex; + string olePath = Path.Combine(Path.GetDirectoryName(output)!, Path.GetFileNameWithoutExtension(output) + "-ole.pptx"); + if (File.Exists(olePath)) throw new IOException("Output already exists: " + olePath); + presentation.SaveAs(olePath, 24); + presentation.Close(); + presentation = app.Presentations.Open(olePath, 0, 0, -1); + await VerifyAsync(adapter, style, originals, 1.5f, RenderEngineKind.MathJaxSvg); + presentation.Windows.Item(1).View.GotoSlide(1); + presentation.Slides.Item(1).Shapes.Item(1).OLEFormat.DoVerb(0); + await ConvertSlidesAsync((object)presentation, controller, toOle: false); + await VerifyAsync(adapter, style, originals, 1.5f); + Console.WriteLine("PASS|PPT PNG/OLE conversion, activation, edit, save and reopen"); + } + + FormulaTypography defaults = PowerPointPluginSettings.Load().Typography; + await VerifyBatchFailureHandlingAsync(adapter, (object)presentation, originals, style); + await controller.FormatAllAsync(Token); + await VerifyAsync(adapter, defaults, originals, 1); + await controller.FormatAllAsync(Token); + await VerifyAsync(adapter, defaults, originals, 1); + presentation.SaveAs(output, 24); + presentation.Close(); presentation = app.Presentations.Open(output, 0, 0, -1); await VerifyAsync(adapter, defaults, originals, 1); dynamic reopenedSlide = presentation.Slides.Item(1); @@ -196,58 +219,110 @@ await controller.AcceptEditorFormulaAsync(new FormulaEditorAcceptedEventArgs( "Native equation was lost after reopening PowerPoint"); Check(Convert.ToInt32(reopenedSlide.Shapes.Item(secondTextBoxName).TextFrame2.TextRange.MathZones(1, 1).Length) > 0, "Task pane native equation was lost after reopening PowerPoint"); - Check(Convert.ToInt32(presentation.Slides.Count) == 2, "Slide count changed"); - Console.WriteLine("PASS|PPT repeated format, save and reopen|" + output); - } - finally - { - if (presentation != null) { presentation.Saved = -1; presentation.Close(); } - app.Quit(); - if (File.Exists(imagePath)) File.Delete(imagePath); - } - } - - private static async Task RenderAsync(MathJaxSvgRenderer renderer, FormulaMetadata metadata, string path) - { - RenderResult result = await renderer.RenderAsync(new RenderRequest(metadata.Latex, metadata.DisplayMode, - RenderEngineKind.MathJaxSvg, metadata.Typography), Token); - File.WriteAllBytes(path, SvgPngRasterizer.Rasterize(result, Token)); - return new PowerPointRenderedImage(path, (float)result.WidthPoints, (float)result.HeightPoints); - } - - private static async Task VerifyAsync(DynamicPowerPointApplicationAdapter adapter, FormulaTypography typography, - IReadOnlyDictionary sources, float scale, RenderEngineKind engine = RenderEngineKind.Image) - { - var entries = await adapter.LoadFormulaEntriesAsync(true, Token); - Check(entries.Count == sources.Count, "Formula count changed"); - Check(entries.Select(entry => entry.SlideIndex).Distinct().Count() == 2, "Formula moved between slides"); - foreach (var entry in entries) - { - Check(entry.Metadata.Typography.Equals(typography), "Typography snapshot changed"); - Check(entry.Metadata.Latex == sources[entry.Metadata.Identity.EquationId], "Source or identity changed"); - Check(entry.Metadata.RenderEngine == engine, "Unexpected formula backend"); - Check(Math.Abs(entry.Scale - scale) < .01, "User scale changed"); - Check(Math.Abs(entry.Left - 80) < .01 && Math.Abs(entry.Top - 90) < .01, "Position changed"); - } - } - - private static async Task ConvertSlidesAsync(object document, PowerPointPluginController controller, bool toOle) - { - dynamic presentation = document; - for (int index = 1; index <= Convert.ToInt32(presentation.Slides.Count); index++) - { - presentation.Windows.Item(1).View.GotoSlide(index); - presentation.Slides.Item(index).Shapes.Item(1).Select(); - if (toOle) await controller.ConvertSelectedToOleAsync(Token); - else await controller.ConvertSelectedToPngAsync(Token); - } - } - - private static void Check(bool value, string message) - { - if (!value) throw new InvalidOperationException(message); - } - + Check(Convert.ToInt32(reopenedSlide.Shapes.Item(chineseTextBoxName).TextFrame2.TextRange.MathZones(1, 1).Length) > 0, + "Native equation containing Chinese was lost after reopening PowerPoint"); + Check(Convert.ToInt32(presentation.Slides.Count) == 2, "Slide count changed"); + Console.WriteLine("PASS|PPT repeated format, save and reopen|" + output); + } + finally + { + if (presentation != null) { presentation.Saved = -1; presentation.Close(); } + app.Quit(); + if (File.Exists(imagePath)) File.Delete(imagePath); + } + } + + private static async Task VerifyBatchFailureHandlingAsync(DynamicPowerPointApplicationAdapter adapter, + object document, IReadOnlyDictionary originals, FormulaTypography style) + { + dynamic presentation = document; + FormulaTypography defaults = PowerPointPluginSettings.Load().Typography; + string failedId = originals.Keys.First(); + var status = new BatchStatusSink(); + using (var failingController = new PowerPointPluginController(new FormulaEditorSession(new TrackingEditor()), + new AutomationApiClient(new AutomationApiOptions()), FailingPowerPointAdapter.Wrap(adapter, failedId), + new MathJaxSvgRenderer(new WebView2MathJaxJavaScriptRuntime("PowerPointBatchFailureE2E")), + new OlePresentationPipeline(new IOlePresentationRenderer[] { new EnhancedMetafilePresentationRenderer() }), status)) + { + presentation.Windows.Item(1).View.GotoSlide(1); + dynamic shapes = presentation.Slides.Item(1).Shapes; + bool selected = false; + for (int index = 1; index <= Convert.ToInt32(shapes.Count); index++) + { + dynamic shape = shapes.Item(index); + if (string.Equals(Convert.ToString(shape.Tags.Item(PowerPointFormulaMetadataStore.EquationIdTag)), + failedId, StringComparison.OrdinalIgnoreCase)) + { + shape.Select(); + selected = true; + break; + } + } + Check(selected, "The failure test formula could not be selected"); + await failingController.FormatSelectedAsync(Token); + Check(status.Kind == PowerPointStatusKind.Error + && status.Message.Contains("Injected PowerPoint formula failure"), + "Selected formatting did not report the individual failure"); + await failingController.ConvertSelectedToOleAsync(Token); + Check(status.Kind == PowerPointStatusKind.Error + && status.Message.Contains("Injected PowerPoint formula failure"), + "Selected conversion did not report the individual failure"); + await failingController.FormatAllAsync(Token); + Check(status.Kind == PowerPointStatusKind.Info + && status.Message.Contains("Injected PowerPoint formula failure"), + "Full formatting did not summarize partial success and the failure reason"); + var partiallyFormatted = await adapter.LoadFormulaEntriesAsync(true, Token); + Check(partiallyFormatted.Count == originals.Count, "Failed formatting removed a formula"); + Check(partiallyFormatted.Single(entry => entry.Metadata.Identity.EquationId == failedId) + .Metadata.Typography.Equals(style), "The failed formula was changed"); + Check(partiallyFormatted.Where(entry => entry.Metadata.Identity.EquationId != failedId) + .All(entry => entry.Metadata.Typography.Equals(defaults) && Math.Abs(entry.Scale - 1) < .01), + "Formatting stopped after an individual failure"); + } + Console.WriteLine("PASS|PPT batch formatting continues after a failure; selected operations report the reason"); + } + + private static async Task RenderAsync(MathJaxSvgRenderer renderer, FormulaMetadata metadata, string path) + { + RenderResult result = await renderer.RenderAsync(new RenderRequest(metadata.Latex, metadata.DisplayMode, + RenderEngineKind.MathJaxSvg, metadata.Typography), Token); + File.WriteAllBytes(path, SvgPngRasterizer.Rasterize(result, Token)); + return new PowerPointRenderedImage(path, (float)result.WidthPoints, (float)result.HeightPoints); + } + + private static async Task VerifyAsync(DynamicPowerPointApplicationAdapter adapter, FormulaTypography typography, + IReadOnlyDictionary sources, float scale, RenderEngineKind engine = RenderEngineKind.Image) + { + var entries = await adapter.LoadFormulaEntriesAsync(true, Token); + Check(entries.Count == sources.Count, "Formula count changed"); + Check(entries.Select(entry => entry.SlideIndex).Distinct().Count() == 2, "Formula moved between slides"); + foreach (var entry in entries) + { + Check(entry.Metadata.Typography.Equals(typography), "Typography snapshot changed"); + Check(entry.Metadata.Latex == sources[entry.Metadata.Identity.EquationId], "Source or identity changed"); + Check(entry.Metadata.RenderEngine == engine, "Unexpected formula backend"); + Check(Math.Abs(entry.Scale - scale) < .01, "User scale changed"); + Check(Math.Abs(entry.Left - 80) < .01 && Math.Abs(entry.Top - 90) < .01, "Position changed"); + } + } + + private static async Task ConvertSlidesAsync(object document, PowerPointPluginController controller, bool toOle) + { + dynamic presentation = document; + for (int index = 1; index <= Convert.ToInt32(presentation.Slides.Count); index++) + { + presentation.Windows.Item(1).View.GotoSlide(index); + presentation.Slides.Item(index).Shapes.Item(1).Select(); + if (toOle) await controller.ConvertSelectedToOleAsync(Token); + else await controller.ConvertSelectedToPngAsync(Token); + } + } + + private static void Check(bool value, string message) + { + if (!value) throw new InvalidOperationException(message); + } + private sealed class TrackingEditor : IFormulaEditor { public bool OpenedForInsert { get; private set; } diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/Program.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/Program.cs index 64eacabb..a04426d5 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/Program.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/Program.cs @@ -7,15 +7,20 @@ internal static class Program [STAThread] private static int Main(string[] args) { - if (args.Length < 1 || args.Length > 2 || (args.Length == 2 && args[1] != "--ole")) - { Console.Error.WriteLine("Usage: PowerPointE2E [--ole]"); return 2; } + if (args.Length < 1 || args.Length > 2 || (args.Length == 2 && args[1] != "--ole" && args[1] != "--batch")) + { Console.Error.WriteLine("Usage: PowerPointE2E [--ole|--batch]"); return 2; } Application.EnableVisualStyles(); int exitCode = 1; using var host = new Form { ShowInTaskbar = false, Opacity = 0, Width = 1, Height = 1 }; host.Shown += async (_, _) => { host.Hide(); - try { await PowerPointRoundTrip.RunAsync(Path.GetFullPath(args[0]), args.Length == 2); exitCode = 0; } + try + { + await PowerPointRoundTrip.RunAsync(Path.GetFullPath(args[0]), + args.Length == 2 && args[1] == "--ole", args.Length == 2 && args[1] == "--batch"); + exitCode = 0; + } catch (Exception error) { Console.Error.WriteLine(error); } finally { host.Close(); } }; diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/README.md b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/README.md index 47648376..ce04db7c 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/README.md +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/README.md @@ -5,14 +5,18 @@ - 完整字体快照、中文和复杂多行源码往返; - 选中加载与修改源码后,原位置和用户手动缩放保持; - 全文格式化应用当前默认快照、恢复自然尺寸;重复格式化稳定; +- 文本光标处插入原生公式,包括中文;选中已有公式时可以新建其他公式; +- 多选格式化、转换和全文格式化的单项失败续跑、错误信息与重试; - 保存 PPTX、关闭并重新打开后,源码、身份、样式、页码与位置一致。 -测试使用独立渲染实例,不修改用户设置或 COM 注册;格式化读取当前设置。默认验证 PNG;可加 `--ole` 验证 PNG/OLE 双向转换、OLE 激活、重新编辑与保存重开,需要预先注册匹配的 schema 3 handler。 +测试使用独立渲染实例,不修改用户设置;格式化读取当前设置。完整测试包含选中对象后再次插入 OLE,不能作为纯 PNG 测试运行。`--ole` 还验证 PNG/OLE 双向转换、OLE 激活、重新编辑与保存重开;`--batch` 只验证 PNG 批处理,不依赖 OLE handler。 先关闭 PowerPoint,输出路径必须尚不存在: ```powershell -dotnet run --project office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E -- "$env:TEMP/latexsnipper-ppt-$([Guid]::NewGuid().ToString('N')).pptx" +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint +# 只检查批处理: +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode Batch ``` -测试保存的 PPTX 可用于人工复核;临时 PNG 在测试结束时清理。需要 Windows、PowerPoint、WebView2 和字体回归所需的系统字体。 +先构建 Release 版本;需要 Windows、PowerPoint、WebView2 和所需系统字体。统一脚本使用唯一的临时输出目录,并为完整测试临时注册 OLE handler;详细要求见 [插件说明](../../README.md)。完整测试保存的 PPTX 可用于人工复核;批处理模式不保存 PPTX,临时 PNG 在测试结束时清理。 diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/FailingWordAdapter.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/FailingWordAdapter.cs new file mode 100644 index 00000000..a665a27d --- /dev/null +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/FailingWordAdapter.cs @@ -0,0 +1,51 @@ +using System; +using System.Reflection; +using System.Runtime.Remoting.Messaging; +using System.Runtime.Remoting.Proxies; +using LaTeXSnipper.OfficePlugin.WordAddIn; + +namespace LaTeXSnipper.OfficePlugin.WordParsingE2E; + +internal sealed class FailingWordAdapter : RealProxy +{ + private readonly IWordApplicationAdapter _inner; + private readonly string _equationId; + + private FailingWordAdapter(IWordApplicationAdapter inner, string equationId) + : base(typeof(IWordApplicationAdapter)) + { + _inner = inner; + _equationId = equationId; + } + + public static IWordApplicationAdapter Wrap(IWordApplicationAdapter inner, string equationId) + { + return (IWordApplicationAdapter)new FailingWordAdapter(inner, equationId).GetTransparentProxy(); + } + + public override IMessage Invoke(IMessage message) + { + var call = (IMethodCallMessage)message; + var method = (MethodInfo)call.MethodBase; + try + { + if ((method.Name == nameof(IWordApplicationAdapter.UpdateFormulaAsync) + || method.Name == nameof(IWordApplicationAdapter.ResetOleFormulaObjectAsync)) + && call.Args.Length > 0 + && string.Equals(call.Args[0] as string, _equationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Injected formula update failure"); + } + + return new ReturnMessage(method.Invoke(_inner, call.Args), null, 0, call.LogicalCallContext, call); + } + catch (TargetInvocationException exception) + { + return new ReturnMessage(exception.InnerException ?? exception, call); + } + catch (Exception exception) + { + return new ReturnMessage(exception, call); + } + } +} diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md index d66ba853..6bb5a2ea 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md @@ -22,7 +22,7 @@ 运行前必须关闭所有 Word 窗口,且测试完成前不要再次启动 Word,以确保测试实例与日常文档隔离: ```powershell -.\office_plugin\tools\Test-WordFormulaParsingE2E.ps1 +.\office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope Word ``` -通过后,测试项目的 `artifacts` 目录会生成 OMML/OLE 两份 DOCX 和 Word 导出的同名 PDF。该目录已被 Git 忽略,只用于本地结构与视觉复核;可通过脚本的 `-OutputDirectory` 参数改写输出位置。 +先构建 Release 版本;统一脚本默认使用 Release,可通过 `-Configuration Debug` 改变。通过后,临时输出目录会生成 OMML/OLE 两份 DOCX、Word 导出的同名 PDF 和日志;可通过 `-OutputDirectory` 指定位置。只测原生公式时使用 `-WordBackend Omml`,不涉及 OLE 注册。 diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/WordParsingE2ERunner.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/WordParsingE2ERunner.cs index 40c8c28d..939f8b87 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/WordParsingE2ERunner.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/WordParsingE2ERunner.cs @@ -104,6 +104,30 @@ await adapter.FindLatexParseCandidatesAsync(all: true, CancellationToken.None) var sources = (await adapter.LoadFormulaEntriesAsync(true, CancellationToken.None).ConfigureAwait(true)) .ToDictionary(entry => entry.Metadata!.Identity.EquationId, entry => entry.Metadata!.Latex); settings = CreateSettings(_options.Backend, FormulaMathStyle.BoldItalic, 15.5); + string failedEquationId = sources.Keys.First(); + IWordApplicationAdapter failingAdapter = FailingWordAdapter.Wrap(adapter, failedEquationId); + using (WordPluginController failingController = WordAddInFactory.CreateController( + (object)word, + statusSink, + new TestFormulaOptionsProvider(), + () => settings, + "WordParsingFailure-" + _options.Backend, + failingAdapter)) + { + await failingController.FormatAllAsync(CancellationToken.None).ConfigureAwait(true); + } + E2EAssert.True(statusSink.Entries.Last().Message.Contains("Injected formula update failure"), + "Batch formatting did not report the first formula failure"); + IReadOnlyList partiallyFormatted = + await adapter.LoadFormulaEntriesAsync(true, CancellationToken.None).ConfigureAwait(true); + E2EAssert.True(partiallyFormatted.Any(entry => + entry.Metadata!.Identity.EquationId != failedEquationId + && entry.Metadata.Typography.Equals(settings.Typography)), + "Batch formatting stopped after a single formula failure"); + E2EAssert.True(partiallyFormatted.Any(entry => + entry.Metadata!.Identity.EquationId == failedEquationId + && !entry.Metadata.Typography.Equals(settings.Typography)), + "The injected formula was unexpectedly formatted"); await controller.FormatAllAsync(CancellationToken.None).ConfigureAwait(true); await VerifyTypographyAsync(adapter, settings.Typography, sources).ConfigureAwait(true); diff --git a/office_plugin/tools/Review-History.ps1 b/office_plugin/tools/Review-History.ps1 deleted file mode 100644 index 4dd35fc4..00000000 --- a/office_plugin/tools/Review-History.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [string] $BaseRevision, - - [string] $SearchText = "", - - [switch] $RegexSearch -) - -$ErrorActionPreference = "Stop" -Set-StrictMode -Version Latest - -$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path -$pluginRoot = Split-Path -Parent $scriptRoot -$repositoryRoot = Split-Path -Parent $pluginRoot -$pluginRelativePath = "office_plugin" - -function Invoke-Git { - param( - [Parameter(Mandatory = $true)] - [string[]] $Arguments - ) - - & git -C $repositoryRoot @Arguments - if ($LASTEXITCODE -ne 0) { - throw "Git command failed: git $($Arguments -join ' ')" - } -} - -$resolvedBase = (& git -C $repositoryRoot rev-parse --verify "$BaseRevision^{commit}").Trim() -if ($LASTEXITCODE -ne 0 -or -not $resolvedBase) { - throw "Base revision is not a commit: $BaseRevision" -} - -Write-Host "Repository: $repositoryRoot" -Write-Host "Baseline: $resolvedBase" -Write-Host "Head: $(& git -C $repositoryRoot rev-parse HEAD)" - -Write-Host "`nOffice plugin working tree:" -Invoke-Git -Arguments @("status", "--short", "--", $pluginRelativePath) - -Write-Host "`nCommits after baseline:" -Invoke-Git -Arguments @( - "log", - "--oneline", - "--decorate", - "$resolvedBase..HEAD", - "--", - $pluginRelativePath -) - -Write-Host "`nTracked file changes relative to baseline, including uncommitted work:" -Invoke-Git -Arguments @( - "diff", - "--name-status", - $resolvedBase, - "--", - $pluginRelativePath -) - -Write-Host "`nChange summary:" -Invoke-Git -Arguments @( - "diff", - "--stat", - $resolvedBase, - "--", - $pluginRelativePath -) - -Write-Host "`nWhitespace and conflict-marker validation:" -Invoke-Git -Arguments @( - "diff", - "--check", - $resolvedBase, - "--", - $pluginRelativePath -) - -if ($SearchText) { - $searchOption = if ($RegexSearch) { "-G$SearchText" } else { "-S$SearchText" } - $searchKind = if ($RegexSearch) { "regex" } else { "exact text" } - Write-Host "`nHistory containing $searchKind '$SearchText':" - Invoke-Git -Arguments @( - "log", - "--oneline", - "--decorate", - $searchOption, - "--", - $pluginRelativePath - ) -} diff --git a/office_plugin/tools/Test-MetadataSafety.ps1 b/office_plugin/tools/Test-MetadataSafety.ps1 deleted file mode 100644 index 1d8d25df..00000000 --- a/office_plugin/tools/Test-MetadataSafety.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -[CmdletBinding()] -param( - [ValidateSet("Debug", "Release")] - [string] $Configuration = "Debug" -) - -$ErrorActionPreference = "Stop" -Set-StrictMode -Version Latest - -$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path -$pluginRoot = Split-Path -Parent $scriptRoot -$projectPath = Join-Path ( - $pluginRoot -) "tests\LaTeXSnipper.OfficePlugin.MetadataSafety.Tests\LaTeXSnipper.OfficePlugin.MetadataSafety.Tests.csproj" -Write-Host "Running metadata safety tests..." -& (Get-Command dotnet -ErrorAction Stop).Source test $projectPath -c $Configuration -if ($LASTEXITCODE -ne 0) { - throw "Metadata safety tests failed. Exit code: $LASTEXITCODE." -} - -Write-Host "Office metadata safety tests completed successfully." diff --git a/office_plugin/tools/Test-OfficeTypographyE2E.ps1 b/office_plugin/tools/Test-OfficeTypographyE2E.ps1 index d9426f55..2b79c6b0 100644 --- a/office_plugin/tools/Test-OfficeTypographyE2E.ps1 +++ b/office_plugin/tools/Test-OfficeTypographyE2E.ps1 @@ -2,6 +2,15 @@ param( [ValidateSet('Word', 'PowerPoint', 'Both')] [string]$HostScope = 'Both', + + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + [ValidateSet('Omml', 'Ole', 'Both')] + [string]$WordBackend = 'Both', + + [ValidateSet('Full', 'Batch')] + [string]$PowerPointMode = 'Full', [string]$OutputDirectory = (Join-Path $env:TEMP ('latexsnipper-office-' + [Guid]::NewGuid().ToString('N'))) ) @@ -11,19 +20,26 @@ $pluginRoot = Split-Path -Parent $PSScriptRoot if (Get-Process WINWORD, POWERPNT -ErrorAction SilentlyContinue) { throw 'Close Word and PowerPoint before running isolated Office tests.' } -if (-not [Environment]::Is64BitProcess -or - (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration').Platform -ne 'x64') { +$runWord = $HostScope -in @('Word', 'Both') +$runPowerPoint = $HostScope -in @('PowerPoint', 'Both') +$needsOle = ($runWord -and $WordBackend -ne 'Omml') -or ($runPowerPoint -and $PowerPointMode -eq 'Full') +if ($needsOle -and (-not [Environment]::Is64BitProcess -or + (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration').Platform -ne 'x64')) { throw 'This Office integration runner requires 64-bit PowerShell and 64-bit Office.' } -$handler = Join-Path $pluginRoot 'hosts\OleFormulaObjectNative\bin\x64\Release\LaTeXSnipper.OfficePlugin.OleFormulaObject.Handler.dll' -$wordTest = Join-Path $pluginRoot 'tests\LaTeXSnipper.OfficePlugin.WordParsingE2E\bin\Debug\net48\LaTeXSnipper.OfficePlugin.WordParsingE2E.exe' -$pptTest = Join-Path $pluginRoot 'tests\LaTeXSnipper.OfficePlugin.PowerPointE2E\bin\Debug\net48\LaTeXSnipper.OfficePlugin.PowerPointE2E.exe' -foreach ($file in @($handler, $wordTest, $pptTest)) { +$handler = Join-Path $pluginRoot "hosts\OleFormulaObjectNative\bin\x64\$Configuration\LaTeXSnipper.OfficePlugin.OleFormulaObject.Handler.dll" +$wordTest = Join-Path $pluginRoot "tests\LaTeXSnipper.OfficePlugin.WordParsingE2E\bin\$Configuration\net48\LaTeXSnipper.OfficePlugin.WordParsingE2E.exe" +$pptTest = Join-Path $pluginRoot "tests\LaTeXSnipper.OfficePlugin.PowerPointE2E\bin\$Configuration\net48\LaTeXSnipper.OfficePlugin.PowerPointE2E.exe" +$requiredFiles = @() +if ($needsOle) { $requiredFiles += $handler } +if ($runWord) { $requiredFiles += $wordTest } +if ($runPowerPoint) { $requiredFiles += $pptTest } +foreach ($file in $requiredFiles) { if (-not (Test-Path -LiteralPath $file)) { throw "Build the handler and managed solution first: $file" } } $clsid = '{B7F5B4AB-5F94-4D87-A29F-9A41D41B3B9F}' $classes = 'HKCU:\Software\Classes' -$registrationRoots = @("$classes\LaTeXSnipper.Formula", "$classes\LaTeXSnipper.Formula.1", "$classes\CLSID\$clsid") +$registrationRoots = if ($needsOle) { @("$classes\LaTeXSnipper.Formula", "$classes\LaTeXSnipper.Formula.1", "$classes\CLSID\$clsid") } else { @() } foreach ($path in $registrationRoots) { if (Test-Path -LiteralPath $path) { throw "Existing user registration must not be overwritten: $path" } } @@ -33,34 +49,48 @@ function Set-TestRegistration([string]$Path, [string]$Value, [string]$Name = '') try { $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) } finally { $key.Close() } } -try { - foreach ($path in $registrationRoots) { - New-Item -Path $path -Force | Out-Null - $createdRoots.Add($path) - Set-TestRegistration $path 'LaTeXSnipper Formula' +function Wait-OfficeExit([string]$Name) { + $deadline = [DateTime]::UtcNow.AddSeconds(20) + while (Get-Process -Name $Name -ErrorAction SilentlyContinue) { + if ([DateTime]::UtcNow -ge $deadline) { throw "Test Office process did not exit: $Name" } + Start-Sleep -Milliseconds 200 } - Set-TestRegistration "$classes\LaTeXSnipper.Formula\CLSID" $clsid - Set-TestRegistration "$classes\LaTeXSnipper.Formula.1\CLSID" $clsid - $classPath = "$classes\CLSID\$clsid" - Set-TestRegistration "$classPath\ProgID" 'LaTeXSnipper.Formula.1' - Set-TestRegistration "$classPath\VersionIndependentProgID" 'LaTeXSnipper.Formula' - Set-TestRegistration "$classPath\InprocServer32" $handler - Set-TestRegistration "$classPath\InprocServer32" 'Apartment' 'ThreadingModel' - Set-TestRegistration "$classPath\MiscStatus" '672280' - Set-TestRegistration "$classPath\MiscStatus\1" '672280' - $registered = Get-Item -LiteralPath "$classPath\InprocServer32" - try { - if ($registered.GetValue('') -ne $handler -or $registered.GetValue('ThreadingModel') -ne 'Apartment') { - throw 'Temporary COM registration does not match the test handler.' +} +try { + if ($needsOle) { + foreach ($path in $registrationRoots) { + New-Item -Path $path -Force | Out-Null + $createdRoots.Add($path) + Set-TestRegistration $path 'LaTeXSnipper Formula' } - } finally { $registered.Close() } + Set-TestRegistration "$classes\LaTeXSnipper.Formula\CLSID" $clsid + Set-TestRegistration "$classes\LaTeXSnipper.Formula.1\CLSID" $clsid + $classPath = "$classes\CLSID\$clsid" + Set-TestRegistration "$classPath\ProgID" 'LaTeXSnipper.Formula.1' + Set-TestRegistration "$classPath\VersionIndependentProgID" 'LaTeXSnipper.Formula' + Set-TestRegistration "$classPath\InprocServer32" $handler + Set-TestRegistration "$classPath\InprocServer32" 'Apartment' 'ThreadingModel' + Set-TestRegistration "$classPath\MiscStatus" '672280' + Set-TestRegistration "$classPath\MiscStatus\1" '672280' + $registered = Get-Item -LiteralPath "$classPath\InprocServer32" + try { + if ($registered.GetValue('') -ne $handler -or $registered.GetValue('ThreadingModel') -ne 'Apartment') { + throw 'Temporary COM registration does not match the test handler.' + } + } finally { $registered.Close() } + } New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null - if ($HostScope -in @('Word', 'Both')) { - & $wordTest --backend ole --output (Join-Path $OutputDirectory 'word-ole.docx') | Tee-Object -FilePath (Join-Path $OutputDirectory 'word.log') - if ($LASTEXITCODE -ne 0) { throw "Word E2E failed: $LASTEXITCODE" } + if ($runWord) { + $backends = if ($WordBackend -eq 'Both') { @('omml', 'ole') } else { @($WordBackend.ToLowerInvariant()) } + foreach ($backend in $backends) { + & $wordTest --backend $backend --output (Join-Path $OutputDirectory "word-$backend.docx") | Tee-Object -FilePath (Join-Path $OutputDirectory "word-$backend.log") + if ($LASTEXITCODE -ne 0) { throw "Word $backend E2E failed: $LASTEXITCODE" } + Wait-OfficeExit 'WINWORD' + } } - if ($HostScope -in @('PowerPoint', 'Both')) { - & $pptTest (Join-Path $OutputDirectory 'powerpoint.pptx') --ole | Tee-Object -FilePath (Join-Path $OutputDirectory 'powerpoint.log') + if ($runPowerPoint) { + $mode = if ($PowerPointMode -eq 'Batch') { '--batch' } else { '--ole' } + & $pptTest (Join-Path $OutputDirectory 'powerpoint.pptx') $mode | Tee-Object -FilePath (Join-Path $OutputDirectory 'powerpoint.log') if ($LASTEXITCODE -ne 0) { throw "PowerPoint E2E failed: $LASTEXITCODE" } } Write-Host "PASS|Office typography|$OutputDirectory" @@ -69,5 +99,5 @@ finally { foreach ($path in $createdRoots) { Remove-Item -LiteralPath $path -Recurse -Force } - Write-Host 'Temporary user COM registrations removed; machine registration unchanged.' + if ($needsOle) { Write-Host 'Temporary user COM registrations removed; machine registration unchanged.' } } diff --git a/office_plugin/tools/Test-WordFormulaParsingE2E.ps1 b/office_plugin/tools/Test-WordFormulaParsingE2E.ps1 deleted file mode 100644 index d6bbec27..00000000 --- a/office_plugin/tools/Test-WordFormulaParsingE2E.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -[CmdletBinding()] -param( - [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Debug', - - [string]$OutputDirectory = (Join-Path $PSScriptRoot '..\tests\LaTeXSnipper.OfficePlugin.WordParsingE2E\artifacts') -) - -$ErrorActionPreference = 'Stop' -$projectPath = Join-Path $PSScriptRoot '..\tests\LaTeXSnipper.OfficePlugin.WordParsingE2E\LaTeXSnipper.OfficePlugin.WordParsingE2E.csproj' -$resolvedOutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) - -function Wait-WordProcessExit { - $deadline = [DateTime]::UtcNow.AddSeconds(20) - while (Get-Process -Name WINWORD -ErrorAction SilentlyContinue) { - if ([DateTime]::UtcNow -ge $deadline) { - throw '前一个 Word 测试实例未能在 20 秒内退出。' - } - - Start-Sleep -Milliseconds 200 - } -} - -if (Get-Process -Name WINWORD -ErrorAction SilentlyContinue) { - throw '请先关闭所有 Microsoft Word 窗口,以便测试使用隔离的 Word 实例。' -} - -New-Item -ItemType Directory -Path $resolvedOutputDirectory -Force | Out-Null -dotnet build $projectPath -c $Configuration -if ($LASTEXITCODE -ne 0) { - throw "Word 公式解析端到端测试项目编译失败,退出码:$LASTEXITCODE。" -} - -$executable = Join-Path (Split-Path $projectPath) "bin\$Configuration\net48\LaTeXSnipper.OfficePlugin.WordParsingE2E.exe" -$cases = @( - @{ Backend = 'omml'; FileName = 'word_formula_parsing_e2e_omml.docx' }, - @{ Backend = 'ole'; FileName = 'word_formula_parsing_e2e_ole.docx' } -) - -foreach ($case in $cases) { - $outputPath = Join-Path $resolvedOutputDirectory $case.FileName - & $executable --backend $case.Backend --output $outputPath - if ($LASTEXITCODE -ne 0) { - throw "Word 公式解析 $($case.Backend) 端到端测试失败,退出码:$LASTEXITCODE。" - } - - Wait-WordProcessExit -} - -Write-Host "Word 公式解析 OMML/OLE 端到端测试全部通过。" diff --git a/src/assets/i18n/latexsnipper_en_US.ts b/src/assets/i18n/latexsnipper_en_US.ts index 1afc392b..c0b3b791 100644 --- a/src/assets/i18n/latexsnipper_en_US.ts +++ b/src/assets/i18n/latexsnipper_en_US.ts @@ -29,7 +29,7 @@ % TODO: Complete the document body - + (无变更日志) (No changelog) @@ -158,7 +158,7 @@ GPU not detected - + GitHub API 本次返回了空发布列表,但这不代表项目没有发布版本。 The GitHub API returned an empty release list, but this does not mean the project has no releases. @@ -792,7 +792,7 @@ Example: {path} {message}; the previous PDF preview has been preserved. - + {message};建议稍后重试或设置 GITHUB_TOKEN。 {message}; try again later or set GITHUB_TOKEN. @@ -907,48 +907,50 @@ Example: {path} Download - + 下载失败 Download failed - + 下载失败:{message} Download failed: {message} - + 下载完成: {path} Download complete: {path} - + 下载完成,正在退出程序并启动安装器... Download completed. Exiting and launching the installer... - + 下载已暂停 Download paused - + 下载已暂停,可稍后继续下载。 Download paused. You can resume it later. - - + + 下载并安装 Download and Install - + + + 下载校验失败 Download verification failed - + 下载校验失败:安装包 SHA256 与线上发布信息不一致 Download verification failed: the package SHA256 does not match the online release information @@ -958,7 +960,7 @@ Example: {path} Download source: - + 下载链接已复制到剪贴板。 The download link has been copied to the clipboard. @@ -1258,7 +1260,7 @@ Restart and open Dependency Management now? - + 关闭 Close @@ -1290,7 +1292,7 @@ Restart and open Dependency Management now? Close Editor - + 关闭该窗口会暂停下载,是否继续? Closing this window will pause the download. Continue? @@ -1490,7 +1492,7 @@ Restart and open Dependency Management now? Report an issue - + 发现新版本: {latest} (当前 {current}) New version available: {latest} (current: {current}) @@ -1504,7 +1506,7 @@ Restart and open Dependency Management now? Cancel - + 变更日志 / 诊断输出... Changelog / diagnostic output... @@ -1539,7 +1541,8 @@ Restart and open Dependency Management now? Show the runtime log window after startup - + + 启动安装器失败 Failed to start installer @@ -1686,7 +1689,7 @@ Restart and open Dependency Management now? - + 复制失败 Copy Failed @@ -1706,7 +1709,7 @@ Restart and open Dependency Management now? Copy access key - + 复制链接 Copy Link @@ -1837,17 +1840,17 @@ Restart and open Dependency Management now? Secure tunnel (recommended) - + 安装包 SHA256 与线上发布信息不一致,请重新下载。 The installer SHA256 does not match the release metadata. Download it again. - + 安装包不存在 Installer not found - + 安装包已存在 Installer already exists @@ -1862,12 +1865,12 @@ Restart and open Dependency Management now? Installation complete - + 安装已下载 Installer Downloaded - + 安装已取消,更新包保留在本地 Installation canceled; the update package remains on this device @@ -2049,12 +2052,12 @@ Restart and open Dependency Management now? Workflow - + 已下载到 {path},SHA256 已生成 Downloaded to {path}; SHA256 generated - + 已下载更新包:{name} SHA256: @@ -2176,7 +2179,7 @@ Launch the installer now? Canceled - + 已取消安装 Installation canceled @@ -2216,7 +2219,7 @@ Launch the installer now? - + 已复制 Copied @@ -2307,7 +2310,7 @@ Launch the installer now? Undone - + 已改用本地缓存的发布信息。 Using cached release information. @@ -2395,7 +2398,7 @@ Launch the installer now? Confirmed and copied to clipboard - + 已经是最新版本: {version} Already up to date: {version} @@ -2452,7 +2455,7 @@ Path: {path} Apply preset - + 建议:检查网络、代理或 DNS;也可以直接打开发布页。 Suggestion: check the network, proxy, or DNS, or open the releases page directly. @@ -2659,12 +2662,12 @@ Select Download to initialize it before entering the main app. Current version: {version} - + 当前版本仅提供网页链接,请手动下载。 This release only provides a web link. Download it manually. - + 当前版本高于线上稳定版本: {latest} (当前 {current}) Current version is newer than the stable release: {latest} (current: {current}) @@ -2899,7 +2902,7 @@ Select Download to initialize it before entering the main app. Open Editor - + 打开链接 Open Link @@ -3188,7 +3191,7 @@ Select Download to initialize it before entering the main app. No formula - + 无可下载资产 No downloadable asset @@ -3198,7 +3201,7 @@ Select Download to initialize it before entering the main app. No available backend - + 无安装包 No Installer @@ -3344,12 +3347,12 @@ Select Download to initialize it before entering the main app. Pause Download - + 暂时无法确认更新:{message} Unable to check for updates: {message} - + 暂时无法获取更新信息。可稍后重试,或直接打开 GitHub Releases 页面查看。 Unable to retrieve update information. Try again later or open the GitHub Releases page directly. @@ -3369,22 +3372,22 @@ Select Download to initialize it before entering the main app. Updating; waiting for the current recognition task to finish... - + 更新包 Update package - + 更新包已保留,下次打开可继续下载。 The update package was retained and can resume next time. - + 更新包已保留:{path} The update package was retained: {path} - + 更新已下载 Update downloaded @@ -3461,7 +3464,7 @@ Select Download to initialize it before entering the main app. The MathCraft OCR package was not found. Check that the app files are complete. - + 未找到下载完成的安装包:{path} The downloaded installer was not found: {path} @@ -3627,7 +3630,7 @@ Select Download to initialize it before entering the main app. Insufficient Permissions - + 来源 Source @@ -3705,7 +3708,7 @@ Select Download to initialize it before entering the main app. MathCraft model weights are missing or incomplete. Completing them automatically. - + 检测到已存在安装包,是否继续重新下载并覆盖? An installer already exists. Download again and overwrite it? @@ -3838,17 +3841,17 @@ Select Download to initialize it before entering the main app. Welcome. Make working with formulas simpler. - + 正在下载 {name} ({percent}% , {current}/{total} 字节) Downloading {name} ({percent}%, {current}/{total} bytes) - + 正在下载 {name}... Downloading {name}... - + 正在下载更新包... Downloading update package... @@ -3926,12 +3929,12 @@ Select Download to initialize it before entering the main app. - + 正在联网获取最新版本信息,请保持与 GitHub 的连接畅通... Fetching the latest release information. Keep the connection to GitHub available... - + 正在获取... Fetching... @@ -4312,12 +4315,12 @@ The uninstall progress window will open after confirmation. Confirm - + 确认关闭 Confirm Close - + 确认安装更新 Confirm Update Installation @@ -4447,7 +4450,7 @@ The uninstall progress window will open after confirmation. - + 继续下载 Resume Download @@ -4689,7 +4692,7 @@ Install pymupdf in the dependency environment. Erase only the stroke segments inside a freeform selection for local rewriting - + 获取超时,可重新检查。 The request timed out. Try checking again. @@ -5064,7 +5067,7 @@ Install pymupdf in the dependency environment. The first page cannot be greater than the last page. - + 超出设定: connect={connect}s read={read}s 可点“重新检查”再次发起。 Timed out: connect={connect}s read={read}s @@ -5475,7 +5478,7 @@ Continue? Rename Formula - + 重新下载 Download Again @@ -5485,7 +5488,7 @@ Continue? Restart LaTeXSnipper to apply the language change. - + 重新检查 Check Again @@ -5587,6 +5590,7 @@ Continue? + 验证中... Validating... @@ -5625,7 +5629,7 @@ Continue? Advanced settings: - + (GitHub 限频) (GitHub rate limit) diff --git a/src/update/installer_cache.py b/src/update/installer_cache.py index fce7b2e1..b654c962 100644 --- a/src/update/installer_cache.py +++ b/src/update/installer_cache.py @@ -118,14 +118,24 @@ def _local_installer_valid(info: ReleaseInfo) -> bool: return False if any(str(meta.get(k, "")) != str(v) for k, v in _asset_fingerprint(info).items() if k != "asset_size"): return False - if int(meta.get("asset_size", 0) or 0) != int(info.asset_size or 0): + try: + saved_size = int(meta.get("asset_size", 0) or 0) + except (TypeError, ValueError): + return False + if saved_size != int(info.asset_size or 0): return False - if info.asset_size and dest_path.stat().st_size != int(info.asset_size): + try: + if info.asset_size and dest_path.stat().st_size != int(info.asset_size): + return False + except OSError: return False saved_sha256 = str(meta.get("sha256", "") or "").strip().lower() if not saved_sha256: return False - return _compute_file_sha256(str(dest_path)).lower() == saved_sha256 + try: + return _compute_file_sha256(str(dest_path)).lower() == saved_sha256 + except OSError: + return False def _ensure_latest_installer_only(info: ReleaseInfo) -> bool: diff --git a/src/update/update_dialog.py b/src/update/update_dialog.py index 19fdc678..63c2ec93 100644 --- a/src/update/update_dialog.py +++ b/src/update/update_dialog.py @@ -96,22 +96,21 @@ def check_update_dialog(parent=None): lay.addWidget(lbl_current) lbl_status = QLabel(tr("正在联网获取最新版本信息,请保持与 GitHub 的连接畅通...")) lay.addWidget(lbl_status) - bar = ProgressBar(dlg) + bar = ProgressBar(dlg, useAni=False) + bar.setRange(0, 100) busy_bar = IndeterminateProgressBar(dlg) lay.addWidget(bar) lay.addWidget(busy_bar) - def set_progress_range(minimum, maximum): - busy = minimum == maximum == 0 + def set_progress_busy(busy: bool) -> None: bar.setVisible(not busy) busy_bar.setVisible(busy) if busy: busy_bar.start() else: busy_bar.stop() - bar.setRange(minimum, maximum) - set_progress_range(0, 0) + set_progress_busy(True) txt = RemoteImageBrowser() txt.setOpenExternalLinks(True) @@ -180,7 +179,7 @@ def watchdog_timeout(): if state["aborted"] or state["done"] or (not dlg.isVisible()): return state["done"] = True - set_progress_range(0, 1) + set_progress_busy(False) lbl_status.setText(tr("获取超时,可重新检查。")) txt.start_new_html( "
"
@@ -219,7 +218,7 @@ def on_result(info, err, diag):
             return
         state["done"] = True
         watchdog.stop()
-        set_progress_range(0, 1)
+        set_progress_busy(False)
         dlg.unsetCursor()
         if err:
             message = _brief_error_message(err)
@@ -351,7 +350,21 @@ def _maybe_launch_installer(path: str):
             )
             return
         ext = Path(path).suffix.lower()
-        sha256_hex = _compute_file_sha256(path)
+        logging.info("更新包下载完成,开始校验:%s", Path(path).name)
+        lbl_status.setText(tr("验证中..."))
+        try:
+            sha256_hex = _compute_file_sha256(path)
+        except OSError as error:
+            logging.exception("更新包读取失败,无法计算 SHA256")
+            lbl_status.setText(tr("下载校验失败"))
+            InfoBar.error(
+                title=tr("下载校验失败"),
+                content=_brief_error_message(error),
+                parent=dlg,
+                duration=4500,
+                position=InfoBarPosition.TOP,
+            )
+            return
         info = state.get("info")
         expected_sha256 = (
             _normalize_sha256(info.asset_sha256)
@@ -370,7 +383,9 @@ def _maybe_launch_installer(path: str):
                 position=InfoBarPosition.TOP,
             )
             return
+        logging.info("更新包 SHA256 校验通过:%s", Path(path).name)
         signature_status = _read_signature_status(path)
+        logging.info("更新包签名检查完成:%s", signature_status)
         if isinstance(info, ReleaseInfo):
             _save_installer_meta(info, path, sha256_hex)
         if os.name != "nt" or not getattr(sys, "frozen", False) or ext != ".exe":
@@ -394,38 +409,37 @@ def _maybe_launch_installer(path: str):
             )
             return
         try:
-            lbl_status.setText(tr("下载完成,正在退出程序并启动安装器..."))
-            _prepare_app_for_update_exit()
             _schedule_windows_installer(path)
-            app = QApplication.instance()
-            if app is not None:
-                QTimer.singleShot(0, app.quit)
-                QTimer.singleShot(2000, lambda: os._exit(0))
-        except Exception as e:
+            logging.info("更新安装器启动任务已创建:%s", Path(path).name)
+        except Exception:
+            logging.exception("无法创建更新安装器启动任务,尝试直接启动")
             try:
-                _prepare_app_for_update_exit()
                 subprocess.Popen([path], close_fds=True, **_hidden_subprocess_kwargs())
-                app = QApplication.instance()
-                if app is not None:
-                    QTimer.singleShot(0, app.quit)
-                    QTimer.singleShot(2000, lambda: os._exit(0))
-            except Exception:
+            except Exception as launch_error:
+                logging.exception("更新安装器启动失败")
                 InfoBar.error(
                     title=tr("启动安装器失败"),
-                    content=_brief_error_message(e),
+                    content=_brief_error_message(launch_error),
                     parent=dlg,
                     duration=4000,
                     position=InfoBarPosition.TOP,
                 )
+                lbl_status.setText(tr("启动安装器失败"))
+                return
+        lbl_status.setText(tr("下载完成,正在退出程序并启动安装器..."))
+        _prepare_app_for_update_exit()
+        app = QApplication.instance()
+        if app is not None:
+            QTimer.singleShot(0, app.quit)
 
     def _on_download_progress(cur: int, total: int, path: object):
         if state["aborted"] or (not dlg.isVisible()):
             return
-        set_progress_range(0, total if total > 0 else 0)
-        bar.setValue(max(0, min(cur, max(total, 1))))
+        set_progress_busy(total <= 0)
         name = Path(str(path or "")).name or tr("更新包")
         if total > 0:
-            pct = int((cur * 100) / total) if total > 0 else 0
+            pct = max(0, min(100, cur * 100 // total))
+            bar.setValue(pct)
             lbl_status.setText(
                 tr("正在下载 {name} ({percent}% , {current}/{total} 字节)").format(
                     name=name, percent=pct, current=cur, total=total
@@ -444,7 +458,7 @@ def _on_download_done(path: object, err: object):
             btn_copy.setEnabled(bool(state.get("info")))
             btn_retry.setEnabled(True)
             dlg.unsetCursor()
-            set_progress_range(0, 1)
+            set_progress_busy(False)
             lbl_status.setText(tr("下载已暂停,可稍后继续下载。"))
             InfoBar.info(
                 title=tr("下载已暂停"),
@@ -461,7 +475,7 @@ def _on_download_done(path: object, err: object):
             btn_copy.setEnabled(bool(state.get("info")))
             btn_retry.setEnabled(True)
             dlg.unsetCursor()
-            set_progress_range(0, 1)
+            set_progress_busy(False)
             lbl_status.setText(tr("下载失败:{message}").format(message=message))
             InfoBar.error(
                 title=tr("下载失败"),
@@ -471,8 +485,8 @@ def _on_download_done(path: object, err: object):
                 position=InfoBarPosition.TOP,
             )
             return
-        set_progress_range(0, 1)
-        bar.setValue(1)
+        set_progress_busy(False)
+        bar.setValue(100)
         _maybe_launch_installer(str(path or ""))
         _refresh_download_button()
         btn_open.setEnabled(bool(state.get("info")))
@@ -501,7 +515,8 @@ def start_fetch():
         txt.start_new_html(
             f"

{html.escape(tr('正在获取...'))}

" ) - set_progress_range(0, 0) + set_progress_busy(True) + bar.setValue(0) btn_open.setEnabled(False) btn_copy.setEnabled(False) btn_retry.setEnabled(False) @@ -581,7 +596,7 @@ def do_download(): btn_copy.setEnabled(False) btn_retry.setEnabled(False) dlg.setCursor(Qt.CursorShape.BusyCursor) - set_progress_range(0, 100) + set_progress_busy(False) bar.setValue(0) lbl_status.setText(tr("正在下载更新包...")) state["downloading"] = True diff --git a/tools/mathjax/README.md b/tools/mathjax/README.md index f048c84b..f3c602eb 100644 --- a/tools/mathjax/README.md +++ b/tools/mathjax/README.md @@ -27,34 +27,21 @@ python -X utf8 .\tools\mathjax\prepare.py --verify 升级时修改清单及完整性校验值,再重建生成文件。验证会拒绝清单漂移、被修改的上游文件和多余资源;有意移除的旧资源须同步删除,不保留版本回退目录。Git 属性保留上游文件和生成清单的字节,避免 Windows 换行转换破坏校验。 -## 第一阶段验证记录(2026-09-12) +## 验证入口 -- Python 导出、源码保留、加载配置和资源测试:34 项通过。 -- Ruff:本次修改的 Python 文件通过。 -- Office Rendering:.NET Framework 4.8 与 .NET 9 编译通过;Word/PowerPoint 主机由已有测试项目一并编译。 -- Office 元数据安全测试:14 项通过。 -- Qt WebEngine:3 个真实 SVG/MathML 转换、6 个 CHTML 页面通过,包含本地 TeX/STIX2、客户端预览和 CDN HTML 独立页。 -- WebView2:5 组真实 SVG、MathML、EMF 转换通过,包含多行中文、化学公式、正体宏、Office 输入处理和 MathML;缓存、Promise 延迟结果与取消请求通过。 -- 安装资源暂存:Office 86 个文件与清单逐文件一致。 - -复验入口: +先使用开发者选定的 Python 环境,从仓库根目录运行: ```powershell +python -X utf8 tools/mathjax/prepare.py --verify python -X utf8 -m pytest test/test_mathjax_runtime.py test/test_formula_export_matrix.py test/test_formula_omml_export.py test/test_content_preview.py test/test_handwriting_preview.py test/test_pandoc_export_formats.py -q python -X utf8 tools/mathjax/smoke_qt.py --cdn -dotnet run --project office_plugin/tests/LaTeXSnipper.OfficePlugin.Rendering.Smoke -dotnet run --project office_plugin/tests/LaTeXSnipper.OfficePlugin.Rendering.Smoke -- --typography -dotnet test office_plugin/tests/LaTeXSnipper.OfficePlugin.MetadataSafety.Tests +dotnet test office_plugin/LaTeXSnipper.OfficePlugin.slnx -c Release ``` -真实浏览器测试需允许 WebEngine/WebView2 子进程;`--cdn` 还需网络。单独运行 Qt 验证时省略此参数即可只验证离线链路。 - -`--typography` 直接回归共享字体渲染服务,要求 Windows 已安装 Times New Roman、宋体、微软雅黑及 Office 的 MathML→OMML 转换资源。它在真实 WebView2 中验证两套数学字体、局部样式、混排与布局,检查 SVG / EMF 轮廓、不同 DPI 的 PNG、缓存和 OMML 属性映射;当前 61 组矢量样例与 122 个 PNG 通过。当前公式与字体处理见[公式工作流文档](../../docs/office_plugin_formula_workflows.md)。 +Qt 验证省略 `--cdn` 时只验证离线链路;CDN 验证需要网络,WebEngine 子进程必须允许运行。 -以上第一阶段记录仅对应运行时升级。当前插件使用 schema 3、统一字体渲染和绝对字号;公式链路与元数据边界见[公式工作流文档](../../docs/office_plugin_formula_workflows.md)。 +Office 实际渲染与文档往返使用 `office_plugin/tools/Test-OfficeTypographyE2E.ps1`,覆盖 Word OMML/OLE、PowerPoint 文本内原生公式、OLE/PNG 对象和批处理。先构建插件,再按 [Office 插件说明](../../office_plugin/README.md) 选择宿主与测试范围。浏览器编辑器另有 [共享编辑器回归](../office-editor/README.md),使用模拟桥接,不能替代真实 Office 验证。 -## tools 目录的用途 +## 工具与发布边界 -- `tools/mathjax` 是长期维护工具:固定资源清单、可复现的生成脚本和被测试直接引用的回归入口;不属于应用运行环境,也不是一次性试验目录。此前直接复制整套 MathJax 3 资源,不需要此处的裁剪与校验工具。 -- Python 维护命令使用开发者自行选择的环境;发布包运行时由 GitHub Actions 准备。 -- 本次验证曾在 `tools/deps/nuget` 下载 .NET 构建包,属于可重建缓存,不是新增运行依赖;收尾时已删除。后续正常 `dotnet restore` 使用开发者原有 NuGet 配置和缓存。 +`tools/mathjax` 是固定资源清单、可复现生成脚本和回归入口,不进入用户运行环境。Python 维护命令使用开发者选定的环境;桌面发布包运行时由 GitHub Actions 准备,Office 发布安装包在本地构建。 diff --git a/tools/office-editor/README.md b/tools/office-editor/README.md index dd78de47..f2e4d7c6 100644 --- a/tools/office-editor/README.md +++ b/tools/office-editor/README.md @@ -6,10 +6,11 @@ - `template-fields.js` 在源码事务中保存占位符位置,支持 Tab / Shift-Tab、选区包裹和独立撤销;用户源码不经过 snippet 字符串转义或模板重解析。 - `EditorAssets/editor-layout.mjs` 与 `editor.css` 是两个宿主唯一的布局与样式入口;宿主 HTML 只加载共享资源。顶部工具栏、左侧符号库、公式 / 源码分隔线和提交栏共用实现。 - `EditorAssets/symbol-library.mjs` 只保留公式模板,不包含用英文说明凑成的磁贴;`template-catalog.mjs` 统一分类、别名、快捷键、模板与补全目录。新增条目只修改目录数据;矩阵尺寸由 `matrix-templates.mjs` 生成。 -- `EditorAssets/symbol-panel.mjs` 提供一个跨分类搜索、纵向分类导航与磁贴滚动、键盘导航和折叠。“常用”初始为空,右键磁贴或工具栏星标可加入或移除,本地保存选择;在“常用”视图不显示矩阵行列控件。只渲染可见磁贴,按实际公式和标题宽度测量并重新排布,同一公式在各分类宽度一致;MathLive 无法解析的少数模板显示名称。缓存上限 256 项,不创建额外 MathLive 编辑器。矩阵模板保留在“结构”组。 +- `EditorAssets/symbol-panel.mjs` 提供一个跨分类搜索、纵向分类导航与磁贴滚动、键盘导航和折叠。“常用”初始为空,右键磁贴或工具栏星标可加入或移除,本地保存选择;在“常用”视图不显示矩阵行列控件。只渲染可见磁贴,按实际公式和标题宽度测量并重新排布,同一公式在各分类宽度一致。缓存上限 256 项,不创建额外 MathLive 编辑器。矩阵模板保留在“结构”组。 - `EditorAssets/template-insertion.mjs` 记录活动编辑器与可视化选区,通过同一入口插入模板。源码补全、磁贴及 MathLive 原有 Ctrl+F/R/H/L/J 快捷键共用目录;源码区 Ctrl+F/H 继续用于查找 / 替换。 - `EditorAssets/source-sync.mjs` 协调源码和 MathLive;源码是唯一文档状态。异步预览核对修订号,仅明确的可视化修改才回写,中文组合输入期间延迟同步。源码移除旧的多行容器时重建 MathLive 控件,避免旧结构滞留。可无损往返的公式打开时直接聚焦可视化区。 -- `EditorAssets/typography-panel.mjs` 使用原生宿主提供的字体目录与样式快照;全局设置和编辑器共用字形选项目录。数字字体显示全部系统字体,汉字字体只显示本机具备汉字字形的字体,并保留当前选中的字体。选中字体排在下拉列表首位。`font-size-picker.mjs` 从共享字号表生成可键盘操作的中文字号 / pt 菜单,并允许输入自定义 pt。`draft-preview.mjs` 用会话与修订筛选最终预览,处理合并、取消、组合输入和提交锁定。原生桥接复用 `IFormulaRenderer`,预览与提交使用当前草稿。 +- `EditorAssets/typography-panel.mjs` 使用原生宿主提供的字体目录与样式快照;全局设置和编辑器共用 17 项字形选项。数字字体显示全部系统字体,汉字字体使用从等线到幼圆的 23 项固定列表,以中文名称显示、不探测字形。下拉列表顺序固定,选择不会重排选项;列表外的当前字体显示选择提示,不作为可选字体追加。`font-size-picker.mjs` 从共享字号表生成可键盘操作的中文字号 / pt 菜单,并允许输入自定义 pt。`draft-preview.mjs` 用会话与修订筛选最终预览,处理合并、取消、组合输入和提交锁定。原生桥接复用 `IFormulaRenderer`,预览与提交使用当前草稿。 +- Word / PowerPoint 设置页共用 `settings.css`、`settings-typography.mjs` 和 `cjk-font-picker.mjs`,字体选项由同一实现生成;宿主脚本维护各自的偏好状态和保存桥接。分段选择使用滑块动画,并遵循系统的减少动画偏好。 - Word / PowerPoint 设置页分别保存全局公式默认字体、字号、样式和颜色,并提供 JSON 导入、导出;导出文件名即预设名称。编辑器内字体调整只应用于当前公式。`TypographySettingsStore` 遇到不兼容的用户配置会删除并以当前默认值重建,不做迁移。 - `EditorAssets/latex-structure.mjs` 提供括号 / 环境诊断及保守的往返判定,不代替 TeX 渲染器。普通 `align` 在 MathLive 保留结构时可直接编辑;注释、MathML、结构错误、无法识别的命令,或 `\displaylines` 外含其他内容且被 MathLive 重组时,保留源码并限制可视化编辑。用户可点击提示中的“用上方结果替换源码(可撤销)”明确采用 MathLive 的表示,再继续可视化编辑。 - 编辑器 WebView2 关闭默认右键菜单和浏览器专属快捷键;可视化区保留 MathLive 右键菜单中的局部上色、剪切、粘贴和全选,隐藏键盘与菜单图标。全局颜色由源码外层 `\textcolor` 表示,并同步工具栏、可视化区、提交属性和最终预览;局部上色立即写回内层源码。源码查找、复制粘贴、撤销,以及 MathLive 的模板快捷键照常由编辑器处理。顶部撤销、重做和符号库开关为带可访问名称的图标按钮。 @@ -32,6 +33,6 @@ npm run test:browser 参考:[CodeMirror API](https://codemirror.net/docs/ref/)、[MathLive 输入事件与静默更新](https://mathlive.io/mathfield/guides/interacting/)。 -6B 回归覆盖字体 / 字号 / 颜色、预览和提交快照一致、失效响应、取消、新会话、渲染错误、提交失败恢复及面板小窗口布局。浏览器测试使用模拟桥接;真实渲染需另外执行 `office_plugin/tests/LaTeXSnipper.OfficePlugin.Rendering.Smoke` 的 `--typography` 检查。 +字体回归覆盖字体 / 字号 / 颜色、预览和提交快照一致、失效响应、取消、新会话、渲染错误、提交失败恢复及面板小窗口布局。浏览器测试使用模拟桥接;真实宿主渲染使用 `office_plugin/tools/Test-OfficeTypographyE2E.ps1` 验证,参数与环境要求见 [Office 插件说明](../../office_plugin/README.md)。 MathLive 与源码均可用 Escape 后再按 Tab 离开编辑器;预览往返在源码未改动时恢复原编辑目标与选区。字号组合输入纳入编辑保护。字体面板在外部点击或 Escape 时关闭,避免原生字体下拉列表因短暂失焦而无法选中;真实输入法与 Office 内下拉交互仍待宿主验收。 diff --git a/tools/office-editor/tests/editor.spec.mjs b/tools/office-editor/tests/editor.spec.mjs index 2f574355..7be8d07e 100644 --- a/tools/office-editor/tests/editor.spec.mjs +++ b/tools/office-editor/tests/editor.spec.mjs @@ -4,6 +4,21 @@ import {resolve, extname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; import {tmpdir} from 'node:os'; +test('catalog previews are renderable formulas', async ({page}) => { + await open(page, ''); + const invalid = await page.evaluate(async () => { + const base = 'https://latexsnipper-editor-shared.officeplugin.local/'; + const [{CATALOG, entryTemplate, templateParts}, {validateLatex}] = await Promise.all([ + import(`${base}template-catalog.mjs`), import(`${base}vendor/mathlive.min.mjs`)]); + return CATALOG.flatMap(entry => { + const latex = templateParts(entryTemplate(entry)).map(part => part.hole ? '\\square' : part.text).join(''); + const errors = validateLatex(latex); + return errors.length ? [{name: entry.zh, latex, errors}] : []; + }); + }); + expect(invalid).toEqual([]); +}); + const root = fileURLToPath(new URL('../../../', import.meta.url)); const shared = resolve(root, 'office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets'); const source = page => page.locator('#latexSource .cm-content'); @@ -24,7 +39,7 @@ async function open(page, latex = 'x+1', host = 'word', color = '#000000') { window.editorInit = {latex, locale: 'zh', mode: 'update', session: 1, display: true, referencePreview: false, typography: {typographyVersion: 1, symbolFontId: 'mathjax-tex', numberFontFamily: '', cjkFontFamily: 'Microsoft YaHei', defaultMathStyle: 'Automatic', fontSizePoints: 12, color}, catalog: {symbolFonts: ['mathjax-tex', 'mathjax-stix2'], systemFonts: ['Microsoft YaHei', 'SimSun', 'Arial'], - cjkFonts: ['Microsoft YaHei', 'SimSun'], mathStyles: [ + cjkFonts: [{id: 'Microsoft YaHei', label: '微软雅黑'}, {id: 'SimSun', label: '宋体'}], mathStyles: [ {id: 'Automatic', zh: '自动数学样式', en: 'Automatic'}, {id: 'Upright', zh: '正体', en: 'Upright'}, {id: 'Bold', zh: '粗体', en: 'Bold'}, {id: 'BoldFraktur', zh: '哥特粗体', en: 'Bold Fraktur'}], @@ -189,7 +204,9 @@ test('environment completion consumes the existing auto-closed brace', async ({p }); test('find/replace, source symbol insertion, session reset and submission locking', async ({page}) => { + await page.setViewportSize({width: 735, height: 670}); const errors = await open(page, 'x+x'); + const initialSourceHeight = (await page.locator('#latexSource').boundingBox()).height; await source(page).press('Control+f'); await expect(page.locator('.cm-search input[name="search"]')).toHaveAttribute('placeholder', '查找'); await expect(page.locator('.cm-search button[name="next"]')).toHaveText('下一个'); @@ -200,11 +217,20 @@ test('find/replace, source symbol insertion, session reset and submission lockin return Math.abs((box.top + box.bottom) / 2 - (checkbox.top + checkbox.bottom) / 2); }); expect(alignment).toBeLessThan(2); + const next = page.locator('.cm-search button[name="next"]'); + const idleBackground = await next.evaluate(button => getComputedStyle(button).backgroundColor); + await next.hover(); + await expect.poll(() => next.evaluate(button => getComputedStyle(button).backgroundColor)).not.toBe(idleBackground); + const sourceBox = await page.locator('#latexSource').boundingBox(); + const replaceBox = await page.locator('.cm-search button[name="replaceAll"]').boundingBox(); + expect(replaceBox.y + replaceBox.height).toBeLessThanOrEqual(sourceBox.y + sourceBox.height); await page.locator('.cm-search input[name="search"]').fill('x'); await page.locator('.cm-search input[name="replace"]').fill('y'); await page.locator('.cm-search button[name="replaceAll"]').click(); expect(await submitted(page)).toBe('y+y'); await source(page).press('Escape'); + await expect(page.locator('.cm-search')).toBeHidden(); + expect((await page.locator('#latexSource').boundingBox()).height).toBe(initialSourceHeight); await source(page).press('End'); await page.locator('[data-group="greek"]').click(); await page.locator('#symbolGrid').getByRole('button', {name: 'α', exact: true}).click(); @@ -731,16 +757,6 @@ test('symbol tiles render LaTeX and pack to their measured width', async ({page} }); expect(compactGreekCount).toBeGreaterThanOrEqual(3); await page.screenshot({path: join(tmpdir(), 'latexsnipper-editor-compact-greek.png')}); - const invalidGreek = await page.evaluate(async () => { - const base = 'https://latexsnipper-editor-shared.officeplugin.local/'; - const [{findEntries, entryTemplate, templateParts}, {validateLatex}] = await Promise.all([ - import(`${base}template-catalog.mjs`), import(`${base}vendor/mathlive.min.mjs`)]); - return findEntries('greek').flatMap(entry => { - const latex = templateParts(entryTemplate(entry)).map(part => part.hole ? '\\square' : part.text).join(''); - return validateLatex(latex).length ? [entry.zh] : []; - }); - }); - expect(invalidGreek).toEqual([]); await page.locator('[data-group="topology"]').click(); for (const name of ['开集', '闭包']) { const rendered = tile(page, name).locator('.tile-preview-content'); @@ -791,9 +807,14 @@ test('symbol tiles render LaTeX and pack to their measured width', async ({page} expect(chemistryRow).toBeGreaterThan(1); await page.screenshot({path: join(tmpdir(), 'latexsnipper-editor-adaptive-tiles.png')}); await searchTile(page, 'Dirac operator'); - await expect(tile(page, 'Dirac 算子').locator('.tile-preview-content')).toHaveText('Dirac 算子'); + await expect(tile(page, 'Dirac 算子').locator('.tile-preview-content [class*="ML__"]')).not.toHaveCount(0); + for (const name of ['道路复合', '六项正合列']) { + await searchTile(page, name); + await expect(tile(page, name).locator('.tile-preview-content [class*="ML__"]')).not.toHaveCount(0); + } + await searchTile(page, 'Dirac operator'); await page.evaluate(() => window.LaTeXSnipperEditor.init({...window.editorInit, locale: 'en'})); - await expect(tile(page, 'Dirac operator').locator('.tile-preview-content')).toHaveText('Dirac operator'); + await expect(tile(page, 'Dirac operator').locator('.tile-preview-content [class*="ML__"]')).not.toHaveCount(0); expect(errors).toEqual([]); }); diff --git a/tools/office-editor/tests/settings.spec.mjs b/tools/office-editor/tests/settings.spec.mjs index f288587e..aea4ddb9 100644 --- a/tools/office-editor/tests/settings.spec.mjs +++ b/tools/office-editor/tests/settings.spec.mjs @@ -13,9 +13,12 @@ for (const host of ['Word', 'PowerPoint']) { const errors = []; page.on('pageerror', error => errors.push(error.message)); await page.route('https://*.officeplugin.local/**', async route => { - const path = resolve(root, `office_plugin/hosts/${host}AddIn/EditorAssets`, - decodeURIComponent(new URL(route.request().url()).pathname.slice(1))); - const types = {'.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html'}; + const url = new URL(route.request().url()); + const directory = url.hostname.includes('editor-shared') + ? 'office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets' + : `office_plugin/hosts/${host}AddIn/EditorAssets`; + const path = resolve(root, directory, decodeURIComponent(url.pathname.slice(1))); + const types = {'.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html'}; await route.fulfill({body: await readFile(path), contentType: types[extname(path)]}); }); await page.addInitScript(() => { @@ -25,7 +28,7 @@ for (const host of ['Word', 'PowerPoint']) { locale: 'zh', formulaFontSizePoints: 12, symbolFontId: 'mathjax-tex', numberFontFamily: '', cjkFontFamily: 'Microsoft YaHei', symbolFonts: ['mathjax-tex', 'mathjax-stix2'], systemFonts: ['Microsoft YaHei', 'SimSun', 'Arial'], - cjkFonts: ['Microsoft YaHei', 'SimSun'], mathStyles: [ + cjkFonts: [{id: 'Microsoft YaHei', label: '微软雅黑'}, {id: 'SimSun', label: '宋体'}], mathStyles: [ {id: 'Automatic', zh: '自动数学样式', en: 'Automatic'}, {id: 'Upright', zh: '正体', en: 'Upright'}, {id: 'BoldFraktur', zh: '哥特粗体', en: 'Bold Fraktur'}], @@ -34,6 +37,17 @@ for (const host of ['Word', 'PowerPoint']) { }; }); await page.goto(`https://latexsnipper-${host.toLowerCase()}.officeplugin.local/settings.html`); + const backend = page.locator('[data-backend]').first().locator('..'); + const slider = () => backend.evaluate(el => { + const style = getComputedStyle(el, '::before'); + return {transform: style.transform, duration: style.transitionDuration}; + }); + expect((await slider()).duration).toBe('0.18s'); + await page.locator('[data-backend]').nth(1).click(); + await expect.poll(async () => (await slider()).transform).not.toBe('none'); + expect(await page.evaluate(() => window.posted.at(-1).insertionBackend)).toBe(host === 'Word' ? 'WordOmml' : 'PowerPointPng'); + await page.emulateMedia({reducedMotion: 'reduce'}); + expect((await slider()).duration).toBe('0s'); expect(await page.locator('html').evaluate(el => getComputedStyle(el).colorScheme)).toBe('light'); await expect(page.locator('#formulaColor')).toHaveValue('#000000'); await expect(page.locator('#formulaMathStyle')).toHaveValue('Automatic'); @@ -59,6 +73,7 @@ for (const host of ['Word', 'PowerPoint']) { expect(await page.locator('#cjkFontFamily option').allTextContents()).not.toContain('Arial'); await expect(page.locator('#cjkFontFamily option').first()).toHaveAttribute('value', 'Microsoft YaHei'); await expect(page.locator('#cjkFontFamily')).toHaveValue('SimSun'); + await expect(page.locator('#cjkFontFamily option:checked')).toHaveText('宋体'); expect(await page.evaluate(() => window.posted.at(-1))).toMatchObject({ symbolFontId: 'mathjax-stix2', numberFontFamily: 'Arial', cjkFontFamily: 'SimSun'}); await page.locator('#importTypography').click(); From b4fcdfd5dc021a8e27024e8968e691465503157f Mon Sep 17 00:00:00 2001 From: LaTeXSnipper Developer Date: Fri, 2 Oct 2026 21:15:36 +0800 Subject: [PATCH 2/5] fix: complete native OLE editing and MathType conversion --- docs/office_plugin_formula_workflows.md | 83 ++--- office_plugin/README.md | 6 +- .../src/FormulaOleObject.cpp | 97 ++++- .../src/FormulaOleObject.h | 7 + ...icPowerPointApplicationAdapter.MathType.cs | 157 ++++++++ .../DynamicPowerPointApplicationAdapter.cs | 337 +++++++++--------- .../PowerPointAddIn/EditorAssets/help.html | 16 +- .../IPowerPointApplicationAdapter.cs | 12 + ...nipper.OfficePlugin.PowerPointAddIn.csproj | 4 - .../PowerPointAddIn/PowerPointAddInText.cs | 6 +- .../PowerPointAddIn/PowerPointFormulaEntry.cs | 12 +- .../PowerPointFormulaMetadataStore.cs | 20 +- ...rPointPluginController.DocumentCommands.cs | 59 ++- .../PowerPointPluginController.cs | 3 + .../PowerPointRibbonCallbacks.cs | 9 + .../Ribbon/PowerPointRibbon.xml | 1 + .../PowerPointRibbonExtensibility.cs | 2 + .../hosts/PowerPointVstoAddIn/ThisAddIn.cs | 8 + ...ynamicWordApplicationAdapter.ComInterop.cs | 1 + ...WordApplicationAdapter.FormulaLifecycle.cs | 22 +- .../DynamicWordApplicationAdapter.MathType.cs | 160 +++++++++ .../DynamicWordApplicationAdapter.Metadata.cs | 18 +- ...DynamicWordApplicationAdapter.Selection.cs | 54 ++- ...rdApplicationAdapter.SelectionDiscovery.cs | 96 ++--- .../DynamicWordApplicationAdapter.cs | 5 +- .../hosts/WordAddIn/EditorAssets/help.html | 16 +- .../WordAddIn/IWordApplicationAdapter.cs | 12 + ...LaTeXSnipper.OfficePlugin.WordAddIn.csproj | 4 - .../hosts/WordAddIn/Ribbon/WordRibbon.xml | 1 + .../hosts/WordAddIn/WordAddInText.cs | 6 +- .../hosts/WordAddIn/WordFormulaEntry.cs | 8 + .../WordAddIn/WordFormulaMetadataStore.cs | 68 ++-- .../WordPluginController.DocumentCommands.cs | 67 +++- .../hosts/WordAddIn/WordPluginController.cs | 3 + .../hosts/WordAddIn/WordRibbonCallbacks.cs | 9 + .../hosts/WordVstoAddIn/ThisAddIn.cs | 8 + .../WordVstoAddIn/WordRibbonExtensibility.cs | 1 + office_plugin/installer/setup.iss | 8 +- .../LaTeXSnipperOffice_3.1.0_amd64.exe | 4 +- .../LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 | 2 +- .../MathTypeFormulaTarget.cs | 18 + .../MathTypeText.cs | 22 ++ .../EditorAssets/source-sync.mjs | 12 +- .../EditorAssets/symbol-panel.mjs | 6 +- .../EditorAssets/template-insertion.mjs | 9 + .../LaTeXSnipper.OfficePlugin.Editor.csproj | 8 + ...LaTeXSnipper.OfficePlugin.Rendering.csproj | 1 + .../MathTypeMathMl.cs | 20 ++ .../MathTypeOleBridge.cs | 179 ++++++++++ .../OfficeFormulaDoubleClickListener.cs | 166 +++++++++ .../OfficeStaDispatcher.cs | 53 +++ .../OleFormulaContent.cs | 81 +++++ .../WordMetadataSafetyTests.cs | 11 +- .../FailingPowerPointAdapter.cs | 9 + .../MathTypeRoundTrip.cs | 129 +++++++ .../PowerPointOleCopyRoundTrip.cs | 74 ++++ .../PowerPointRoundTrip.cs | 70 +++- .../Program.cs | 9 +- .../README.md | 12 +- .../MathTypeMathMlTests.cs | 34 ++ .../FailingWordAdapter.cs | 9 + .../MathTypeRoundTrip.cs | 122 +++++++ .../Program.cs | 9 +- .../README.md | 4 + .../WordOleCopyRoundTrip.cs | 88 +++++ .../WordParsingE2ERunner.cs | 22 +- .../tests/NativeOleHandler/RoundTrip.cpp | 20 ++ .../tools/Test-OfficeTypographyE2E.ps1 | 53 ++- tools/office-editor/README.md | 4 +- tools/office-editor/tests/editor.spec.mjs | 50 +++ tools/office-editor/tests/taskpane.spec.mjs | 28 ++ 71 files changed, 2292 insertions(+), 452 deletions(-) create mode 100644 office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.MathType.cs create mode 100644 office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.MathType.cs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/MathTypeFormulaTarget.cs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/MathTypeText.cs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMathMl.cs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeOleBridge.cs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OfficeFormulaDoubleClickListener.cs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OfficeStaDispatcher.cs create mode 100644 office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OleFormulaContent.cs create mode 100644 office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/MathTypeRoundTrip.cs create mode 100644 office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/PowerPointOleCopyRoundTrip.cs create mode 100644 office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypeMathMlTests.cs create mode 100644 office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/MathTypeRoundTrip.cs create mode 100644 office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/WordOleCopyRoundTrip.cs diff --git a/docs/office_plugin_formula_workflows.md b/docs/office_plugin_formula_workflows.md index db49ba29..77e7e35e 100644 --- a/docs/office_plugin_formula_workflows.md +++ b/docs/office_plugin_formula_workflows.md @@ -6,7 +6,7 @@ - 新建和格式化从当前设置产生样式快照;普通编辑、重编号及转换保留公式已有快照。 - 局部字体与颜色命令优先于默认样式。默认样式在 MathJax 解析后、布局前应用。 -- 编辑器以源码最外层 `\textcolor{#RRGGBB}{…}` 表示当前公式的全局颜色;工具栏修改该命令,源码区修改该命令则同步工具栏。提交和预览的 `Typography.Color` 从源码读取,MathLive 继承同一颜色,局部 `\textcolor` 可覆盖外层。右键局部上色立即写入源码。 +- 编辑器以源码最外层 `\textcolor{#RRGGBB}{…}` 表示当前公式的全局颜色;工具栏修改该命令,源码区修改该命令则同步工具栏。提交和预览的 `Typography.Color` 从源码读取,MathLive 继承同一颜色,局部 `\textcolor` 可覆盖外层。右键局部上色立即写入源码。 MathLive 的编辑视图只解析最外层颜色命令内的公式,并从同一源码读取颜色用于显示,避免 `\displaylines` 被嵌套颜色组吞掉;任何可视化编辑写回时重新保留该外层命令,局部颜色命令始终留在公式内。状态窗格与独立编辑器共享此投影和写回逻辑。 - Word OLE、PPT OLE / PNG 使用同一 SVG 轮廓;Word OMML 与 PowerPoint 文本内原生公式复用带样式 MathML,由各自宿主管理排版。 - MathLive 提供可视化编辑;符号、数字、汉字三类字体和独立的默认字形共同构成样式快照。设置页的 JSON 导入/导出只管理当前宿主的全局公式默认属性;编辑器内调整只作用于当前公式。 - 数字字体枚举全部系统字体;汉字字体使用从等线到幼圆的 23 项固定列表,中文名称与稳定字体 ID 分开,不探测字形。全局设置和编辑器共享列表与 17 项字形选项。字体列表顺序固定,当前选择不改变顺序。 @@ -21,7 +21,7 @@ | PowerPoint 插入偏好 | 同一注册表项 | `PowerPointInsertionBackend` | | Word / PowerPoint 公式默认属性 | `%APPDATA%\LaTeXSnipper\OfficePlugin\settings.json` | 两个宿主各自的符号字体、数字字体、汉字字体、默认字形、字号、颜色和新建时跟随文字字号;JSON 导入/导出只读写对应宿主的这组属性 | | 公式编辑器常用内容 | `%LOCALAPPDATA%\LaTeXSnipper\OfficePlugin\WordEditorWebView2` 和 `PowerPointEditorWebView2` | 各宿主 WebView2 的 `localStorage`:内置磁贴收藏、自定义公式收藏、符号库当前标签与折叠状态;两宿主不互相覆盖 | -| 文档内托管公式 | Word 文档变量与公式对象;PowerPoint OLE / PNG shape tags | 公式源码、身份及各公式自己的样式快照;随 Office 文档保存。PowerPoint 文本内原生公式由 Office 保存,不使用插件 shape tags | +| 文档内托管公式 | OLE 内部存储;Word 文档变量与对象短标签;PowerPoint shape tags | Word OLE 从对象内部读取源码、样式和自然尺寸,文档变量保存实例身份;PowerPoint OLE / PNG 从完整 shape tags 读取同一公式快照。Word OMML 保留完整文档变量。文本内 PowerPoint 原生公式由 Office 保存 | 安装包在升级、重装前运行 `ForceClean.ps1`,清理旧安装目录、VSTO / ClickOnce 注册与缓存、OLE 注册,以及 `%LOCALAPPDATA%\LaTeXSnipper\OfficePlugin\WordAddIn` / `PowerPointAddIn` 渲染缓存和 PowerPoint 临时图片。脚本不删除上述用户偏好注册表项、`settings.json`、两个编辑器 WebView2 目录或 Office 文档。卸载时也调用同一清理脚本并保留这些用户数据。`TypographySettingsStore` 只在读取到损坏或不支持版本的 `settings.json` 时删除该文件并重建默认值,不执行配置迁移;此时注册表中的插入与编号偏好和编辑器常用内容仍保留。 @@ -43,62 +43,46 @@ `Typography` 保存完整快照;文档重绘不依赖本机预设。只读取 schema 3,损坏或不支持的版本在入口拒绝,不迁移或删除原对象。 -## Word 元数据存储 +## OLE 内容与宿主实例 -Word 中公式对象自身只保存短标签: +Word OLE 识别先验证 `OLEFormat.ProgID` 为本产品对象,再通过 `OleFormulaContent` 的 `IDispatch.GetPayload` 读取 schema 3 内容,恢复完整源码、样式、显示/编号信息与自然尺寸。损坏内容或 COM 读取失败保留具体错误,不当作普通选区忽略。 -```text -latexsnipper-eq-{equationId}|{revision} -``` +原生对象的 `Payload` / `PresentationEmf` 随复制和文档保存。创建时同步写入存储;Word 的 `UpdatePayload` 校验内容并准备新预览后才提交,失败保留原内容和预览。重编号通过同一接口更新相关内容字段。Word 宿主变量不重复保存 OLE 的源码、样式或自然尺寸。 -短标签写入: +PowerPoint 保留已验证的完整 shape tags 读取路径;复制后的 `OLEFormat.Object` 激活返回 `REGDB_E_CLASSNOTREG` 的具体原因尚未查明,不能把这一接口作为 PowerPoint 内容读取的前提。PowerPoint 编辑、格式化与转换统一读取 tags 中的 `FormulaMetadata`,更新时由同一快照生成对象 Payload 和 tags;不使用当前设置替代既有属性,不增加内部读取失败后的第二条兜底分支。 -- OMML 公式:content control 的 `Tag` -- OLE 公式:inline shape 的 `AlternativeText` +同文档副本和跨文档副本在读取有效内容后建立新的实例身份。关闭来源文档后仍能加载,编辑一个副本不会更新其他副本。编辑会话固定文档和具体对象,切换活动文档不改变保存目标。 -完整 JSON 元数据写入 Word `Document.Variables`: +## Word 元数据存储 -```text -LS.E.{equationId}.{revision} -``` +对象使用短标签 `latexsnipper-eq-{equationId}|{revision}`;OMML 放在 content control `Tag`,OLE 放在 inline shape `AlternativeText`。文档变量名为 `LS.E.{equationId}.{revision}`,每次绑定生成新的 10 位 revision。 -这样可以避开 Word content control tag 的 64 字符限制。每次保存都会生成新的 10 位 revision,并把短标签指向新 revision。加载时必须同时拿到 equationId 和 revision,然后从 `Document.Variables` 读取完整 JSON。 +- **OLE:**变量只保存 schema、文档 ID 和公式 ID。完整内容与自然尺寸来自 OLE Payload。复制导致变量或标签缺失时,验证对象内容后重新绑定当前实例。 +- **OMML:**变量保存完整 `FormulaMetadata`。加载仍需要控制项短标签与对应文档变量;跨文档复制后的源码精确保留本轮未接入,不能用近似 LaTeX 覆盖原始内容。 -OLE 公式还会在同一份 JSON 中保存自然宽高: +OMML 的自然字号取 `Typography.FontSizePoints`。 -| 字段 | 用途 | -| --- | --- | -| `naturalWidthPoints` | OLE 初始自然宽度 | -| `naturalHeightPoints` | OLE 初始自然高度 | +## PowerPoint 元数据存储 -这些字段用于判断用户是否手动拉伸公式,并支持格式化时恢复自然尺寸。 +OLE / PNG 的 shape tags 保存 `LaTeXSnipperDocumentId`、`LaTeXSnipperEquationId`、`LaTeXSnipperSchemaVersion`、`LaTeXSnipperRenderEngine`、显示模式和自然宽高;PNG 另保存临时图片路径。完整源码与样式 JSON 按 UTF-8 十六进制分块保存,前缀分别为 `LaTeXSnipperLatex` / `LaTeXSnipperTypography`,含 `Bytes`、`Chunks` 和 `0000` 起的分块。每块 200 个字符,读取校验长度、块数和十六进制内容。 -OMML 的自然字号直接取 `Typography.FontSizePoints`,不另建文档变量。 +OLE / PNG 均要求完整 tags;复制时保留这些数据并重新绑定实例身份。丢弃 tags 的粘贴方式不能恢复原始源码。临时图片路径只用于本机文件清理。 -## PowerPoint 元数据存储 +## 双击编辑 -PowerPoint 公式以 shape 为单位保存元数据。短字段写入 shape tags: +加载项在独立消息线程检测系统双击手势,限定前台 Office 进程;回调只投递点击位置,随后在 Office UI 线程确认自己的 OLE 对象并复用“加载所选”流程。Word 通过范围命中和对象屏幕边界定位,PowerPoint 在普通视图中等待 Office 完成对象选中,再通过 `PointsToScreenPixelsX/Y` 核对实际点击是否落在所选 OLE 对象的边界内;不使用不存在的 `DocumentWindow.HWND`,也不依赖会对 OLE 返回空的 `RangeFromPoint`。普通文本、其他 OLE 与编辑器内部点击不打开公式编辑器。 -| Tag | 用途 | -| --- | --- | -| `LaTeXSnipperDocumentId` | 当前演示文稿标识 | -| `LaTeXSnipperEquationId` | 公式 ID | -| `LaTeXSnipperDisplayMode` | 显示模式 | -| `LaTeXSnipperSchemaVersion` | schema | -| `LaTeXSnipperRenderEngine` | 渲染类型 | -| `LaTeXSnipperNaturalWidthPoints` | 自然宽度 | -| `LaTeXSnipperNaturalHeightPoints` | 自然高度 | -| `LaTeXSnipperImagePath` | PNG 插入时的临时图片路径 | - -完整 LaTeX 源码使用 UTF-8 转十六进制后分块存储: - -| Tag | 用途 | -| --- | --- | -| `LaTeXSnipperLatexBytes` | 原始 UTF-8 字节数 | -| `LaTeXSnipperLatexChunks` | 分块数量 | -| `LaTeXSnipperLatex0000` 起 | 十六进制源码分块 | +监听启动失败显示具体错误,其他插件功能继续可用;卸载释放钩子和待处理请求。对象内容读取与保存不依赖双击监听。 + +## 转为 MathType -每块长度为 200 个十六进制字符,并校验字节数、分块数量和十六进制解析结果。样式 JSON 使用同一编码与分块实现,前缀为 `LaTeXSnipperTypography`,保存 `Bytes`、`Chunks` 和 `0000` 起的分块。这样同时保留长源码、中文字体名称和字体名称的大小写。 +独立的“转为 MathType”入口处理所选普通 LaTeXSnipper OLE;Word 带编号公式暂不纳入。需要本机安装提供桌面 SDK 的 MathType。转换开始前固定整个选区的目标,逐项执行并沿用统一命令互斥和超时兜底;单项失败保留原公式,继续其余项,最终报告成功、失败、跳过数量及首个具体错误。宿主读取、对象创建、MathType SDK 导入与保存均由适配器持有的调度句柄返回创建它的 Office STA 线程执行,不依赖异步续体的 `SynchronizationContext`。 + +现有 MathJax 样式链路输出 MathML,宿主创建 `Equation.DSMT4` 对象,安装的 MathType SDK 导入 MathML并保存原生内容。导入前移除单侧定界符产生的空 ``,避免 MathType SDK -9999;原源码与样式不变。Word 在隐藏的临时文档完成原生保存,将原宽、高以 pt 写入对象的 OOXML 显示框,再把完整原生存储与显示框一起插入目标文档,避免目标对象受到 SDK 保存后的尺寸缓存更新影响;临时文档立即关闭。PowerPoint 在原生保存返回后的下一次 STA 调度中分别恢复原宽、高,并保留位置(Office OLE 对象不支持旋转)。只有创建、导入、内容回读和尺寸恢复成功后才删除原 LaTeXSnipper 对象;失败移除新对象并保留原对象。 + +转换后由 MathType 原生内容管理公式,双击进入 MathType,排版和字体由 MathType 管理。插件不附带另一份原始源码或字体快照,也不从 MathType 内容推导近似源码。该出口不承诺任意 TeX 命令或字体方案的完全等价;MathType 回读 MathML 的表示也不能用于证明所有显示属性均可精确恢复。 + +“转为 OLE”同时接收 MathType 原生对象。转换前固定所选对象的位置,逐项读取当前 MathML(保存为单个完整、默认命名空间的 `` 根节点),交给现有 MathJax 渲染与插件 OLE 创建链路。成功生成并保存新对象后删除 MathType 对象;读取或渲染失败保留原对象并继续。支持与 Word 公式 / PowerPoint PNG 混合多选,沿用现有批量统计、命令互斥和超时。新 OLE 的唯一源码是当前 MathML,字体使用当前插件默认设置及 MathML 中显式样式;不会恢复旧 LaTeX 或旧字体快照,MathType 导出未表达的样式无法精确保留。 ## 设置项影响边界 @@ -237,7 +221,7 @@ REF LaTeXSnipperEq_{equationId} \h 加载所选只读取被选中的公式对象: - OMML:读取 content control `Tag`,再读取 `Document.Variables`。 -- OLE:读取 inline shape `AlternativeText`,再读取 `Document.Variables`。 +- OLE:验证类身份并读取对象 Payload;inline shape `AlternativeText` 与文档变量只承担实例绑定。 加载后: @@ -390,7 +374,7 @@ PowerPoint 文本内原生公式不参与插件的加载、OLE / PNG 转换和 ## OLE payload 与字体输出 -pending payload 的 schema 为 3,包含源码、显示 / 编号 / 渲染类型、完整样式字段、运行时版本,以及 presentation 自然宽高、基线、MIME 与 base64 数据。`FormulaTypographyFields` 统一定义样式字段;OLE 使用平铺字段,Word JSON 使用 `typography` 对象,PPT 使用编码后的样式 JSON。 +pending payload 的 schema 为 3,包含源码、显示 / 编号 / 渲染类型、完整样式字段、运行时版本,以及 presentation 自然宽高、基线、MIME 与 base64 数据。`FormulaTypographyFields` 统一定义样式字段;OLE 使用平铺字段,Word OMML JSON 使用 `typography` 对象,PPT OLE / PNG 使用编码后的样式 JSON。 原生 handler 校验 schema 与样式版本、字体方案、数学样式、字号范围和呈现数据,原样保存 payload。身份仍由宿主对象与文档元数据管理,不写入 OLE payload。 @@ -400,11 +384,6 @@ MathJax 4.1.3 在排版前取得字形度量与轮廓。最终 SVG 不包含依 Word 插入、更新、删除、编号、解析、转换和格式化都在必要位置使用 Word undo record。用户撤销后,Word 会同时回撤对象、字段和文档变量的变化。 -文档发给其他用户再返回时,插件能否加载取决于 Office 是否保留: - -- Word content control `Tag` 或 OLE inline shape `AlternativeText`。 -- Word `Document.Variables`。 -- PowerPoint shape tags。 -- OLE 对象本身或 PNG shape。 +OLE 的内容随对象存储传递,Word 中缺失的实例绑定可以根据有效对象内容重建;PowerPoint 使用随复制保留的完整 shape tags。接收方需安装插件才能再次用 LaTeXSnipper 编辑;MathType 转换后的对象需 MathType 编辑。 -这些都是 Office 文档内的原生持久化载体,不依赖本机临时内存。PowerPoint PNG 的临时图片路径只用于本机清理文件,不影响公式元数据加载。 +Word OMML 仍依赖 content control `Tag` 和完整 `Document.Variables`,PowerPoint OLE / PNG 依赖完整 shape tags;只保留图像或丢弃这些载体的粘贴方式不能恢复原始源码。所有载体均在 Office 文档内持久化,不依赖本机临时内存。 diff --git a/office_plugin/README.md b/office_plugin/README.md index 4510254f..bb171b70 100644 --- a/office_plugin/README.md +++ b/office_plugin/README.md @@ -39,6 +39,8 @@ Office 2016 is not officially supported (requires manual .NET 4.8 and WebView2 i ### Shared +- Double-click editing and independent cross-document copies of LaTeXSnipper OLE formulas +- Explicit conversion of unnumbered LaTeXSnipper OLE formulas to native MathType objects, using the installed MathType desktop SDK; multi-selection continues after individual failures and reports specific errors; MathType owns subsequent editing and layout - Reusable WebView2/MathLive formula editor - 18-category shared symbol and formula library - Chinese and English Ribbon, task pane, editor, settings, and help @@ -98,10 +100,12 @@ Build the installer or the managed solution and native handler first, then close ```powershell office_plugin\tools\Test-OfficeTypographyE2E.ps1 +# Include native MathType conversion and save/reopen checks (requires installed MathType): +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -IncludeMathType # Word OMML only, without temporary OLE registration: office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope Word -WordBackend Omml # PowerPoint batch failure and retry only, without OLE: office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode Batch ``` -The unified runner defaults to Release binaries, Word OMML/OLE, and the full PowerPoint suite. Use `-Configuration Debug`, `-WordBackend Ole`, or `-HostScope` to select a smaller run. Evidence goes to a unique temporary directory, or `-OutputDirectory`. OLE runs require 64-bit Click-to-Run Office and temporarily register the built handler under HKCU; existing user registration is refused, and the runner removes only the keys it creates. The plugin installer itself registers the OLE handler under HKLM. +The unified runner defaults to Release binaries, Word OMML/OLE, and the full PowerPoint suite. Use `-Configuration Debug`, `-WordBackend Ole`, or `-HostScope` to select a smaller run. Evidence goes to a unique temporary directory, or `-OutputDirectory`. OLE runs require 64-bit Click-to-Run Office and temporarily register the built handler under HKCU; valid existing user registration is refused; original values are restored, and the runner removes only the roots it creates. The plugin installer itself registers the OLE handler under HKLM. diff --git a/office_plugin/hosts/OleFormulaObjectNative/src/FormulaOleObject.cpp b/office_plugin/hosts/OleFormulaObjectNative/src/FormulaOleObject.cpp index 18241478..0cee5096 100644 --- a/office_plugin/hosts/OleFormulaObjectNative/src/FormulaOleObject.cpp +++ b/office_plugin/hosts/OleFormulaObjectNative/src/FormulaOleObject.cpp @@ -15,7 +15,6 @@ namespace { volatile LONG g_objectCount = 0; volatile LONG g_lockCount = 0; - void LogInterfaceQuery(REFIID iid, HRESULT result) { LPOLESTR iidText = nullptr; @@ -106,6 +105,10 @@ FormulaOleObject::FormulaOleObject() : presentation_(CreatePresentationFromPayload(ConsumePendingPayload())) { WriteNativeOleLog(L"FormulaOleObject constructed."); + wchar_t details[160]{}; + swprintf_s(details, L"Formula payload characters=%zu, EMF bytes=%zu, supported=%d.", + presentation_.payloadJson.size(), presentation_.enhancedMetafile.size(), IsSupportedFormulaPayload(presentation_.payloadJson)); + WriteNativeOleLog(details); InterlockedIncrement(&g_objectCount); } @@ -137,10 +140,6 @@ void FormulaOleObject::NotifyPresentationChanged() } } - if (clientSite_ != nullptr) - { - clientSite_->SaveObject(); - } } STDMETHODIMP FormulaOleObject::QueryInterface(REFIID iid, void** object) @@ -182,6 +181,10 @@ STDMETHODIMP FormulaOleObject::QueryInterface(REFIID iid, void** object) { *object = static_cast(this); } + else if (iid == IID_IDispatch) + { + *object = static_cast(this); + } else { *object = nullptr; @@ -217,6 +220,73 @@ STDMETHODIMP FormulaOleObject::SetClientSite(IOleClientSite* clientSite) return S_OK; } +STDMETHODIMP FormulaOleObject::GetTypeInfoCount(UINT* count) +{ + if (count == nullptr) return E_POINTER; + *count = 0; + return S_OK; +} + +STDMETHODIMP FormulaOleObject::GetTypeInfo(UINT, LCID, ITypeInfo** info) +{ + if (info == nullptr) return E_POINTER; + *info = nullptr; + return E_NOTIMPL; +} + +STDMETHODIMP FormulaOleObject::GetIDsOfNames(REFIID iid, LPOLESTR* names, UINT count, LCID, DISPID* ids) +{ + if (iid != IID_NULL) return DISP_E_UNKNOWNINTERFACE; + if (names == nullptr || ids == nullptr) return E_POINTER; + if (count != 1) return DISP_E_UNKNOWNNAME; + ids[0] = _wcsicmp(names[0], L"GetPayload") == 0 ? 1 + : _wcsicmp(names[0], L"UpdatePayload") == 0 ? 2 : DISPID_UNKNOWN; + return ids[0] == DISPID_UNKNOWN ? DISP_E_UNKNOWNNAME : S_OK; +} + +STDMETHODIMP FormulaOleObject::Invoke(DISPID id, REFIID iid, LCID, WORD flags, + DISPPARAMS* parameters, VARIANT* result, EXCEPINFO*, UINT*) +{ + if (iid != IID_NULL) return DISP_E_UNKNOWNINTERFACE; + if ((flags & DISPATCH_METHOD) == 0) return DISP_E_MEMBERNOTFOUND; + if (parameters == nullptr) return E_POINTER; + if (id == 1) + { + if (parameters->cArgs != 0) return DISP_E_BADPARAMCOUNT; + if (result == nullptr) return E_POINTER; + if (!IsSupportedFormulaPayload(presentation_.payloadJson)) return STG_E_INVALIDHEADER; + VariantInit(result); + result->vt = VT_BSTR; + result->bstrVal = SysAllocStringLen(presentation_.payloadJson.data(), + static_cast(presentation_.payloadJson.size())); + return result->bstrVal == nullptr ? E_OUTOFMEMORY : S_OK; + } + if (id != 2) return DISP_E_MEMBERNOTFOUND; + if (parameters->cArgs != 1) return DISP_E_BADPARAMCOUNT; + const VARIANT& input = parameters->rgvarg[0]; + if (input.vt != VT_BSTR || input.bstrVal == nullptr) return DISP_E_TYPEMISMATCH; + const UINT length = SysStringLen(input.bstrVal); + if (length == 0 || length > 32 * 1024 * 1024) return E_INVALIDARG; + std::wstring payload(input.bstrVal, length); + if (!IsSupportedFormulaPayload(payload)) return STG_E_INVALIDHEADER; + if (storage_ == nullptr) return STG_E_REVERTED; + FormulaPresentation previous = presentation_; + presentation_ = CreatePresentationFromPayload(payload); + dirty_ = true; + HRESULT status = SavePresentationToStorage(storage_, presentation_); + if (SUCCEEDED(status)) status = storage_->Commit(STGC_DEFAULT); + if (SUCCEEDED(status) && clientSite_ != nullptr) status = clientSite_->SaveObject(); + if (FAILED(status)) + { + presentation_ = std::move(previous); + SavePresentationToStorage(storage_, presentation_); + storage_->Commit(STGC_DEFAULT); + return status; + } + NotifyPresentationChanged(); + return S_OK; +} + STDMETHODIMP FormulaOleObject::GetClientSite(IOleClientSite** clientSite) { if (clientSite == nullptr) @@ -413,8 +483,7 @@ STDMETHODIMP FormulaOleObject::GetMiscStatus(DWORD aspect, DWORD* status) return aspectResult; } - *status = OLEMISC_STATIC - | OLEMISC_CANTLINKINSIDE + *status = OLEMISC_CANTLINKINSIDE | OLEMISC_RENDERINGISDEVICEINDEPENDENT | OLEMISC_NOUIACTIVATE | OLEMISC_IGNOREACTIVATEWHENVISIBLE @@ -828,13 +897,17 @@ STDMETHODIMP FormulaOleObject::InitNew(IStorage* storage) return result; } + result = SavePresentationToStorage(storage, presentation_); + if (SUCCEEDED(result)) result = storage->Commit(STGC_DEFAULT); + if (FAILED(result)) return result; storage_ = storage; - dirty_ = true; + dirty_ = false; return S_OK; } STDMETHODIMP FormulaOleObject::Load(IStorage* storage) { + WriteNativeOleLog(L"FormulaOleObject Load."); FormulaPresentation loaded; HRESULT result = LoadPresentationFromStorage(storage, &loaded); if (SUCCEEDED(result)) @@ -844,7 +917,10 @@ STDMETHODIMP FormulaOleObject::Load(IStorage* storage) dirty_ = false; } - return SUCCEEDED(result) ? S_OK : result; + wchar_t message[96]{}; + swprintf_s(message, L"FormulaOleObject Load -> 0x%08X", static_cast(result)); + WriteNativeOleLog(message); + return result; } STDMETHODIMP FormulaOleObject::Save(IStorage* storage, BOOL) @@ -858,8 +934,9 @@ STDMETHODIMP FormulaOleObject::Save(IStorage* storage, BOOL) return result; } -STDMETHODIMP FormulaOleObject::SaveCompleted(IStorage*) +STDMETHODIMP FormulaOleObject::SaveCompleted(IStorage* storage) { + if (storage != nullptr) storage_ = storage; if (storage_ != nullptr) { storage_->Commit(STGC_DEFAULT); diff --git a/office_plugin/hosts/OleFormulaObjectNative/src/FormulaOleObject.h b/office_plugin/hosts/OleFormulaObjectNative/src/FormulaOleObject.h index 693cf679..17a38212 100644 --- a/office_plugin/hosts/OleFormulaObjectNative/src/FormulaOleObject.h +++ b/office_plugin/hosts/OleFormulaObjectNative/src/FormulaOleObject.h @@ -14,6 +14,7 @@ class FormulaOleObject final , public IOleCache , public IExternalConnection , public IPersistStorage + , public IDispatch { public: FormulaOleObject(); @@ -23,6 +24,12 @@ class FormulaOleObject final STDMETHOD_(ULONG, AddRef)() override; STDMETHOD_(ULONG, Release)() override; + STDMETHOD(GetTypeInfoCount)(UINT* count) override; + STDMETHOD(GetTypeInfo)(UINT index, LCID locale, ITypeInfo** info) override; + STDMETHOD(GetIDsOfNames)(REFIID iid, LPOLESTR* names, UINT count, LCID locale, DISPID* ids) override; + STDMETHOD(Invoke)(DISPID id, REFIID iid, LCID locale, WORD flags, DISPPARAMS* parameters, + VARIANT* result, EXCEPINFO* exception, UINT* argumentError) override; + STDMETHOD(SetClientSite)(IOleClientSite* clientSite) override; STDMETHOD(GetClientSite)(IOleClientSite** clientSite) override; STDMETHOD(SetHostNames)(LPCOLESTR containerApp, LPCOLESTR containerObject) override; diff --git a/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.MathType.cs b/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.MathType.cs new file mode 100644 index 00000000..9326ceb8 --- /dev/null +++ b/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.MathType.cs @@ -0,0 +1,157 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using LaTeXSnipper.OfficePlugin.Abstractions; +using LaTeXSnipper.OfficePlugin.Rendering; + +namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; + +public sealed partial class DynamicPowerPointApplicationAdapter +{ + public Task> LoadConversionEntriesAsync(bool includeMathType, CancellationToken cancellationToken) + => _officeThread.InvokeAsync(() => + { + var entries = new System.Collections.Generic.List(CollectFormulaEntries(false, cancellationToken)); + if (includeMathType) + { + dynamic selection = _application.ActiveWindow.Selection; + if (selection.Type == 2) + { + dynamic shapes = selection.ShapeRange; + string documentId = GetCurrentDocumentId(); + for (int i = 1; i <= Convert.ToInt32(shapes.Count); i++) + { + dynamic shape = shapes.Item(i); + if (MathTypeOleBridge.IsEquation((object)shape)) + entries.Add(new PowerPointFormulaEntry(new MathTypeFormulaTarget(_application.ActivePresentation, documentId, + Convert.ToInt32(shape.Id), Convert.ToInt32(shape.Parent.SlideIndex)), Convert.ToInt32(shape.Parent.SlideIndex))); + } + } + } + return (System.Collections.Generic.IReadOnlyList)entries; + }, cancellationToken); + + private static dynamic FindMathTypeFormula(MathTypeFormulaTarget target) + { + dynamic document = target.Document; + dynamic shapes = document.Slides.Item(target.ContainerIndex).Shapes; + for (int i = 1; i <= Convert.ToInt32(shapes.Count); i++) + { + dynamic shape = shapes.Item(i); + if (Convert.ToInt32(shape.Id) == target.Location && MathTypeOleBridge.IsEquation((object)shape)) return shape; + } + throw new InvalidOperationException(PowerPointAddInText.Get("SelectedFormulaRequired")); + } + + public Task ReadMathTypeMathMlAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken) + => _officeThread.InvokeAsync(() => + { + dynamic shape = FindMathTypeFormula(target); + shape.OLEFormat.Activate(); + object server = shape.OLEFormat.Object; + try { return MathTypeOleBridge.ReadAndCloseMathMl(server); } + finally { if (Marshal.IsComObject(server)) Marshal.ReleaseComObject(server); } + }, cancellationToken); + + public Task ReplaceMathTypeWithOleAsync(MathTypeFormulaTarget target, FormulaMetadata metadata, + OlePresentationResult presentation, CancellationToken cancellationToken) + => _officeThread.InvokeAsync(() => + { + dynamic original = FindMathTypeFormula(target); + float height = Convert.ToSingle(original.Height); + float width = (float)(height * presentation.WidthPoints / presentation.HeightPoints); + dynamic? converted = null; + try + { + converted = CreateOleObjectAt(original.Parent, metadata, presentation, + Convert.ToSingle(original.Left), Convert.ToSingle(original.Top), width, height); + original.Delete(); + } + catch + { + if (converted != null) converted.Delete(); + throw; + } + TryCom(() => converted.Select()); + return true; + }, cancellationToken); + + public Task> LoadSelectedFormulaTargetsAsync(CancellationToken cancellationToken) + { + return _officeThread.InvokeAsync(() => + { + object presentation = _application.ActivePresentation; + var shapes = GetSelectedFormulaShapes(); + EnsureUniqueShapeIdentities(shapes); + var targets = new System.Collections.Generic.List(); + foreach (object shape in shapes) + targets.Add(new PowerPointFormulaEditTarget(PowerPointFormulaMetadataStore.LoadFromShape(shape), presentation)); + return (System.Collections.Generic.IReadOnlyList)targets; + }, cancellationToken); + } + + public async Task ReplaceWithMathTypeAsync(PowerPointFormulaEditTarget target, string mathMl, CancellationToken cancellationToken) + { + object? replacement = null; + float left = 0, top = 0, width = 0, height = 0; + try + { + await _officeThread.InvokeAsync(() => + { + cancellationToken.ThrowIfCancellationRequested(); + dynamic original = FindFormulaShapeById(target.Presentation, target.Metadata.Identity.EquationId); + if (!OleFormulaContent.IsFormula((object)original)) + throw new InvalidOperationException(PowerPointAddInText.Get("SelectedFormulaRequired")); + MathTypeOleBridge.EnsureAvailable(); + dynamic slide = original.Parent; + left = Convert.ToSingle(original.Left); + top = Convert.ToSingle(original.Top); + width = Convert.ToSingle(original.Width); + height = Convert.ToSingle(original.Height); + dynamic converted = slide.Shapes.AddOLEObject( + Left: original.Left, Top: original.Top, Width: original.Width, Height: original.Height, + ClassName: MathTypeOleBridge.ProgId, DisplayAsIcon: false, Link: false); + replacement = converted; + if (Convert.ToString(converted.OLEFormat.ProgID) != MathTypeOleBridge.ProgId) + throw new InvalidOperationException(PowerPointAddInText.Get("MathTypeNativeRequired")); + object server = converted.OLEFormat.Object; + try { MathTypeOleBridge.WriteMathMl(server, mathMl); } + finally { if (Marshal.IsComObject(server)) Marshal.ReleaseComObject(server); } + return true; + }, cancellationToken).ConfigureAwait(false); + + // Wait until the native save has returned to Office before undoing + // its width/height changes with the source object's dimensions. + await _officeThread.InvokeAsync(() => + { + dynamic converted = replacement!; + dynamic original = FindFormulaShapeById(target.Presentation, target.Metadata.Identity.EquationId); + converted.LockAspectRatio = 0; + converted.Width = width; + converted.Height = height; + converted.Left = left; + converted.Top = top; + if (Math.Abs(Convert.ToSingle(converted.Width) - width) > 0.1f + || Math.Abs(Convert.ToSingle(converted.Height) - height) > 0.1f) + throw new InvalidOperationException("MathType 对象未能恢复转换前的宽度和高度。"); + converted.LockAspectRatio = -1; + original.Delete(); + TryCom(() => converted.Select()); + return true; + }, cancellationToken, defer: true).ConfigureAwait(false); + } + catch + { + if (replacement != null) + await _officeThread.InvokeAsync(() => + { + dynamic converted = replacement; + converted.Delete(); + return true; + }, CancellationToken.None).ConfigureAwait(false); + throw; + } + } + +} diff --git a/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs b/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs index ab51d0d9..8e621414 100644 --- a/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs +++ b/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs @@ -1,13 +1,14 @@ using System; using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using LaTeXSnipper.OfficePlugin.Abstractions; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using LaTeXSnipper.OfficePlugin.Abstractions; +using LaTeXSnipper.OfficePlugin.Rendering; namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; -public sealed class DynamicPowerPointApplicationAdapter : IPowerPointApplicationAdapter +public sealed partial class DynamicPowerPointApplicationAdapter : IPowerPointApplicationAdapter, IDisposable { private const int MsoFalse = 0; private const int MsoTrue = -1; @@ -15,37 +16,61 @@ public sealed class DynamicPowerPointApplicationAdapter : IPowerPointApplication private const float DefaultTopPoints = 96f; private const string OleFormulaProgId = "LaTeXSnipper.Formula"; - private readonly dynamic _application; + private readonly dynamic _application; + private readonly LaTeXSnipper.OfficePlugin.Rendering.OfficeStaDispatcher _officeThread = new(); [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); - public DynamicPowerPointApplicationAdapter(object application) - { - _application = application ?? throw new ArgumentNullException(nameof(application)); - if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) - throw new InvalidOperationException("PowerPoint 插件必须在 Office STA 线程上初始化。"); - } + public DynamicPowerPointApplicationAdapter(object application) + { + _application = application ?? throw new ArgumentNullException(nameof(application)); + if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) + throw new InvalidOperationException("PowerPoint 插件必须在 Office STA 线程上初始化。"); + } - public double GetCurrentFontSizePoints() - { - try - { - dynamic selection = _application.ActiveWindow.Selection; - if (Convert.ToInt32(selection.Type) != 3) - { - return 0; - } - - double points = Convert.ToDouble(selection.TextRange.Font.Size); - return FormulaFontSize.IsValid(points) ? points : 0; - } - catch (Exception error) when (error is COMException || error is ArgumentException || error is Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) + public void Dispose() => _officeThread.Dispose(); + + public double GetCurrentFontSizePoints() + { + try + { + dynamic selection = _application.ActiveWindow.Selection; + if (Convert.ToInt32(selection.Type) != 3) + { + return 0; + } + + double points = Convert.ToDouble(selection.TextRange.Font.Size); + return FormulaFontSize.IsValid(points) ? points : 0; + } + catch (Exception error) when (error is COMException || error is ArgumentException || error is Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) { return 0; } } + public bool TrySelectOleFormulaAtScreenPoint(int x, int y) + { + try + { + dynamic window = _application.ActiveWindow; + if (Convert.ToInt32(window.Active) != MsoTrue || Convert.ToInt32(window.ViewType) != 9) return false; + // PowerPoint's RangeFromPoint can return null for an embedded OLE shape. + // The delayed gesture runs after Office selected the clicked object. + dynamic selection = window.Selection; + if (Convert.ToInt32(selection.Type) != 2 || Convert.ToInt32(selection.ShapeRange.Count) != 1) return false; + dynamic shape = selection.ShapeRange.Item(1); + if (!OleFormulaContent.IsFormula((object)shape)) return false; + int left = window.PointsToScreenPixelsX(shape.Left), top = window.PointsToScreenPixelsY(shape.Top); + int right = window.PointsToScreenPixelsX(shape.Left + shape.Width), bottom = window.PointsToScreenPixelsY(shape.Top + shape.Height); + return x >= Math.Min(left, right) && x <= Math.Max(left, right) + && y >= Math.Min(top, bottom) && y <= Math.Max(top, bottom); + } + catch (Exception error) when (error is COMException || error is Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) { } + return false; + } + public Task ActivateForEditingAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -80,9 +105,9 @@ public Task InsertFormulaImageAsync(PowerPointRenderedImage image, FormulaMetada throw new ArgumentNullException(nameof(metadata)); } - dynamic slide = GetActiveSlide(); - InsertionPoint insertionPoint = GetInsertionPoint(slide, image.WidthPoints, image.HeightPoints); - return InsertPictureAtAsync(slide, image, metadata, insertionPoint.Left, insertionPoint.Top); + dynamic slide = GetActiveSlide(); + InsertionPoint insertionPoint = GetInsertionPoint(slide, image.WidthPoints, image.HeightPoints); + return InsertPictureAtAsync(slide, image, metadata, insertionPoint.Left, insertionPoint.Top); } public Task InsertFormulaImageOnSlideAsync( @@ -119,7 +144,7 @@ public Task UpdateFormulaImageAsync( float left = Convert.ToSingle(shape.Left, System.Globalization.CultureInfo.InvariantCulture); float top = Convert.ToSingle(shape.Top, System.Globalization.CultureInfo.InvariantCulture); float scale = Convert.ToSingle(shape.Width, System.Globalization.CultureInfo.InvariantCulture) - / ReadRequiredFloatTag(shape, PowerPointFormulaMetadataStore.NaturalWidthPointsTag); + / PowerPointFormulaMetadataStore.NaturalSize((object)shape).Width; string oldImagePath = ReadTag(shape, PowerPointFormulaMetadataStore.ImagePathTag); dynamic replacement = CreatePictureAt( slide, @@ -146,9 +171,9 @@ public Task InsertOleFormulaObjectAsync(FormulaMetadata metadata, OlePresentatio throw new ArgumentNullException(nameof(presentation)); } - dynamic slide = GetActiveSlide(); - InsertionPoint insertionPoint = GetInsertionPoint(slide, (float)presentation.WidthPoints, (float)presentation.HeightPoints); - return InsertOleObjectAtAsync(slide, metadata, presentation, insertionPoint.Left, insertionPoint.Top); + dynamic slide = GetActiveSlide(); + InsertionPoint insertionPoint = GetInsertionPoint(slide, (float)presentation.WidthPoints, (float)presentation.HeightPoints); + return InsertOleObjectAtAsync(slide, metadata, presentation, insertionPoint.Left, insertionPoint.Top); } public Task InsertOleFormulaObjectOnSlideAsync( @@ -185,7 +210,7 @@ public Task UpdateOleFormulaObjectAsync( float left = Convert.ToSingle(shape.Left, System.Globalization.CultureInfo.InvariantCulture); float top = Convert.ToSingle(shape.Top, System.Globalization.CultureInfo.InvariantCulture); float scale = Convert.ToSingle(shape.Width, System.Globalization.CultureInfo.InvariantCulture) - / ReadRequiredFloatTag(shape, PowerPointFormulaMetadataStore.NaturalWidthPointsTag); + / PowerPointFormulaMetadataStore.NaturalSize((object)shape).Width; string oldImagePath = ReadTag(shape, PowerPointFormulaMetadataStore.ImagePathTag); dynamic replacement = CreateOleObjectAt( slide, @@ -277,25 +302,26 @@ public Task LoadSelectedFormulaAsync(CancellationTo dynamic shape = GetSelectedShape(); EnsureUniqueShapeIdentity(shape); return Task.FromResult(new PowerPointFormulaEditTarget( - ReadMetadataFromShape(shape, PowerPointDocumentIdentityStore.GetOrCreate(presentation)), + PowerPointFormulaMetadataStore.LoadFromShape(shape), presentation)); } - public Task> LoadFormulaEntriesAsync(bool all, CancellationToken cancellationToken) + public Task> LoadFormulaEntriesAsync(bool all, CancellationToken cancellationToken) + => Task.FromResult(CollectFormulaEntries(all, cancellationToken)); + + private IReadOnlyList CollectFormulaEntries(bool all, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var entries = new List(); - dynamic presentation = _application.ActivePresentation; - string documentId = PowerPointDocumentIdentityStore.GetOrCreate(presentation); IReadOnlyList shapes = all ? GetAllFormulaShapes(cancellationToken) : GetSelectedFormulaShapes(); EnsureUniqueShapeIdentities(shapes); foreach (object item in shapes) { dynamic shape = item; - entries.Add(CreateEntry(shape, Convert.ToInt32(shape.Parent.SlideIndex), documentId)); + entries.Add(CreateEntry(shape, Convert.ToInt32(shape.Parent.SlideIndex))); } - return Task.FromResult>(entries); + return entries; } public bool ContainsFormula(string equationId) @@ -338,7 +364,7 @@ private IReadOnlyList GetAllFormulaShapes(CancellationToken cancellation { cancellationToken.ThrowIfCancellationRequested(); dynamic shape = shapes.Item(shapeIndex); - if (!string.IsNullOrWhiteSpace(ReadTag(shape, PowerPointFormulaMetadataStore.EquationIdTag))) + if (IsManagedFormula((object)shape)) formulas.Add(shape); } } @@ -405,8 +431,7 @@ private System.Collections.Generic.IReadOnlyList GetSelectedFormulaShape for (int i = 1; i <= Convert.ToInt32(shapeRange.Count); i++) { dynamic shape = shapeRange[i]; - string equationId = ReadTag(shape, PowerPointFormulaMetadataStore.EquationIdTag); - if (!string.IsNullOrWhiteSpace(equationId)) + if (IsManagedFormula((object)shape)) { shapes.Add(shape); } @@ -424,6 +449,14 @@ private System.Collections.Generic.IReadOnlyList GetSelectedFormulaShape return shapes; } + private static bool IsManagedFormula(object shapeObject) + { + if (MathTypeOleBridge.IsEquation(shapeObject)) return false; + dynamic shape = shapeObject; + return !string.IsNullOrWhiteSpace(ReadTag(shape, PowerPointFormulaMetadataStore.EquationIdTag)) + || OleFormulaContent.IsFormula(shapeObject); + } + private void EnsureUniqueShapeIdentities(IEnumerable shapes) { foreach (object item in shapes) @@ -442,7 +475,7 @@ private static void EnsureUniqueShapeIdentity(object shape, object presentation) dynamic formulaShape = shape; string documentId = PowerPointDocumentIdentityStore.GetOrCreate(presentation); string equationId = ReadTag(formulaShape, PowerPointFormulaMetadataStore.EquationIdTag); - FormulaMetadata current = ReadMetadataFromShape(formulaShape, documentId); + FormulaMetadata current = PowerPointFormulaMetadataStore.LoadFromShape(formulaShape); if (string.Equals(current.Identity.DocumentId, documentId, StringComparison.Ordinal) && CountFormulaShapesById(presentation, equationId) <= 1) { @@ -450,14 +483,8 @@ private static void EnsureUniqueShapeIdentity(object shape, object presentation) } FormulaMetadata metadata = WithNewIdentity(current, documentId); - float naturalWidth = ReadRequiredFloatTag(formulaShape, PowerPointFormulaMetadataStore.NaturalWidthPointsTag); - float naturalHeight = ReadRequiredFloatTag(formulaShape, PowerPointFormulaMetadataStore.NaturalHeightPointsTag); - PowerPointFormulaMetadataStore.ApplyToShape(formulaShape, metadata, naturalWidth, naturalHeight); - } - - private int CountFormulaShapesById(string equationId) - { - return CountFormulaShapesById(_application.ActivePresentation, equationId); + var size = PowerPointFormulaMetadataStore.NaturalSize(shape); + PowerPointFormulaMetadataStore.ApplyToShape(formulaShape, metadata, size.Width, size.Height); } private static int CountFormulaShapesById(object presentationObject, string equationId) @@ -490,11 +517,6 @@ private static int CountFormulaShapesById(object presentationObject, string equa return count; } - private static FormulaMetadata ReadMetadataFromShape(dynamic shape, string documentId) - { - return PowerPointFormulaMetadataStore.LoadFromShape(shape); - } - private static FormulaMetadata WithNewIdentity(FormulaMetadata metadata, string documentId) { return new FormulaMetadata( @@ -508,10 +530,10 @@ private static FormulaMetadata WithNewIdentity(FormulaMetadata metadata, string metadata.Typography); } - private static PowerPointFormulaEntry CreateEntry(dynamic shape, int slideIndex, string documentId) + private static PowerPointFormulaEntry CreateEntry(dynamic shape, int slideIndex) { - FormulaMetadata metadata = ReadMetadataFromShape(shape, documentId); - float naturalWidth = ReadRequiredFloatTag(shape, PowerPointFormulaMetadataStore.NaturalWidthPointsTag); + FormulaMetadata metadata = PowerPointFormulaMetadataStore.LoadFromShape(shape); + float naturalWidth = PowerPointFormulaMetadataStore.NaturalSize((object)shape).Width; return new PowerPointFormulaEntry( metadata, slideIndex, @@ -651,121 +673,110 @@ private static string ReadTag(dynamic shape, string tagName) } } - private static float ReadRequiredFloatTag(dynamic shape, string tagName) + private dynamic GetActiveSlide() + { + try + { + return _application.ActiveWindow.View.Slide; + } + catch (Exception exc) + { + throw new InvalidOperationException("请先打开一个 PowerPoint 幻灯片,再插入公式。", exc); + } + } + + public PowerPointTextInsertionTarget? CaptureTextInsertionTarget() { - string value = ReadTag(shape, tagName); - if (!float.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float result) || result <= 0) + try + { + dynamic selection = _application.ActiveWindow.Selection; + if (Convert.ToInt32(selection.Type) != 3) return null; + if (Convert.ToInt32(selection.TextRange.Length) != 0) + throw new InvalidOperationException(PowerPointAddInText.Get("TextCaretRequired")); + + dynamic shape = selection.ShapeRange.Item(1); + if (Convert.ToInt32(shape.HasTextFrame) != MsoTrue) return null; + dynamic slide = GetActiveSlide(); + if (Convert.ToInt32(shape.Parent.SlideID) != Convert.ToInt32(slide.SlideID)) return null; + dynamic range = shape.TextFrame2.TextRange; + return new PowerPointTextInsertionTarget( + shape, + GetCurrentDocumentId(), + Convert.ToInt32(slide.SlideID), + Convert.ToInt32(shape.Id), + Convert.ToInt32(selection.TextRange2.Start), + Convert.ToInt32(range.Length), + Convert.ToString(range.Text) ?? string.Empty, + GetCurrentFontSizePoints()); + } + catch (Exception error) when (error is COMException || error is ArgumentException || error is Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) { - throw new InvalidOperationException(PowerPointAddInText.Get("SelectedFormulaMetadataMissing")); + return null; } + } - return result; + public Task InsertNativeEquationAsync(PowerPointTextInsertionTarget target, string mathMl, double fontSizePoints, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (target == null) throw new ArgumentNullException(nameof(target)); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var worker = new Thread(() => + { + try + { + InsertNativeEquation(target, mathMl, fontSizePoints, cancellationToken); + completion.TrySetResult(true); + } + catch (Exception error) { completion.TrySetException(error); } + }) { IsBackground = true, Name = "LaTeXSnipper PowerPoint MathML paste" }; + worker.SetApartmentState(ApartmentState.STA); + worker.Start(); + return completion.Task; } - private dynamic GetActiveSlide() + private void InsertNativeEquation(PowerPointTextInsertionTarget target, string mathMl, double fontSizePoints, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + if (!string.Equals(target.DocumentId, GetCurrentDocumentId(), StringComparison.Ordinal) + || Convert.ToInt32(GetActiveSlide().SlideID) != target.SlideId) + throw new InvalidOperationException(PowerPointAddInText.Get("TextCaretChanged")); + + dynamic shape = target.Shape; + dynamic fullRange = shape.TextFrame2.TextRange; + if (Convert.ToInt32(shape.Id) != target.ShapeId + || Convert.ToInt32(shape.Parent.SlideID) != target.SlideId + || !string.Equals(Convert.ToString(fullRange.Text), target.OriginalText, StringComparison.Ordinal) + || Convert.ToInt32(fullRange.Length) != target.TextLength) + throw new InvalidOperationException(PowerPointAddInText.Get("TextCaretChanged")); + + (int Start, int Length) pasted = default; try { - return _application.ActiveWindow.View.Slide; + ActivateForEditingAsync(cancellationToken).GetAwaiter().GetResult(); + dynamic insertionRange = shape.TextFrame.TextRange.Characters(target.Start, 0); + insertionRange.Select(); + pasted = PowerPointMathMlClipboard.PasteAtRange(insertionRange, mathMl); + if (pasted.Length <= 0) + throw new InvalidOperationException(PowerPointAddInText.Get("NativeEquationInsertFailed")); + dynamic inserted = shape.TextFrame2.TextRange.Characters(pasted.Start, pasted.Length); + dynamic equation = inserted.MathZones(1, 1); + int zoneStart = Convert.ToInt32(equation.Start); + int zoneEnd = zoneStart + Convert.ToInt32(equation.Length); + if (zoneStart >= pasted.Start + pasted.Length || zoneEnd <= pasted.Start) + throw new InvalidOperationException(PowerPointAddInText.Get("NativeEquationInsertFailed")); + equation.Font.Size = fontSizePoints; } - catch (Exception exc) + catch { - throw new InvalidOperationException("请先打开一个 PowerPoint 幻灯片,再插入公式。", exc); + if (pasted.Length > 0) + shape.TextFrame.TextRange.Characters(pasted.Start, pasted.Length).Text = string.Empty; + throw; } - } - - public PowerPointTextInsertionTarget? CaptureTextInsertionTarget() - { - try - { - dynamic selection = _application.ActiveWindow.Selection; - if (Convert.ToInt32(selection.Type) != 3) return null; - if (Convert.ToInt32(selection.TextRange.Length) != 0) - throw new InvalidOperationException(PowerPointAddInText.Get("TextCaretRequired")); - - dynamic shape = selection.ShapeRange.Item(1); - if (Convert.ToInt32(shape.HasTextFrame) != MsoTrue) return null; - dynamic slide = GetActiveSlide(); - if (Convert.ToInt32(shape.Parent.SlideID) != Convert.ToInt32(slide.SlideID)) return null; - dynamic range = shape.TextFrame2.TextRange; - return new PowerPointTextInsertionTarget( - shape, - GetCurrentDocumentId(), - Convert.ToInt32(slide.SlideID), - Convert.ToInt32(shape.Id), - Convert.ToInt32(selection.TextRange2.Start), - Convert.ToInt32(range.Length), - Convert.ToString(range.Text) ?? string.Empty, - GetCurrentFontSizePoints()); - } - catch (Exception error) when (error is COMException || error is ArgumentException || error is Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) - { - return null; - } - } - - public Task InsertNativeEquationAsync(PowerPointTextInsertionTarget target, string mathMl, double fontSizePoints, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - if (target == null) throw new ArgumentNullException(nameof(target)); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var worker = new Thread(() => - { - try - { - InsertNativeEquation(target, mathMl, fontSizePoints, cancellationToken); - completion.TrySetResult(true); - } - catch (Exception error) { completion.TrySetException(error); } - }) { IsBackground = true, Name = "LaTeXSnipper PowerPoint MathML paste" }; - worker.SetApartmentState(ApartmentState.STA); - worker.Start(); - return completion.Task; - } - - private void InsertNativeEquation(PowerPointTextInsertionTarget target, string mathMl, double fontSizePoints, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!string.Equals(target.DocumentId, GetCurrentDocumentId(), StringComparison.Ordinal) - || Convert.ToInt32(GetActiveSlide().SlideID) != target.SlideId) - throw new InvalidOperationException(PowerPointAddInText.Get("TextCaretChanged")); - - dynamic shape = target.Shape; - dynamic fullRange = shape.TextFrame2.TextRange; - if (Convert.ToInt32(shape.Id) != target.ShapeId - || Convert.ToInt32(shape.Parent.SlideID) != target.SlideId - || !string.Equals(Convert.ToString(fullRange.Text), target.OriginalText, StringComparison.Ordinal) - || Convert.ToInt32(fullRange.Length) != target.TextLength) - throw new InvalidOperationException(PowerPointAddInText.Get("TextCaretChanged")); - - (int Start, int Length) pasted = default; - try - { - ActivateForEditingAsync(cancellationToken).GetAwaiter().GetResult(); - dynamic insertionRange = shape.TextFrame.TextRange.Characters(target.Start, 0); - insertionRange.Select(); - pasted = PowerPointMathMlClipboard.PasteAtRange(insertionRange, mathMl); - if (pasted.Length <= 0) - throw new InvalidOperationException(PowerPointAddInText.Get("NativeEquationInsertFailed")); - dynamic inserted = shape.TextFrame2.TextRange.Characters(pasted.Start, pasted.Length); - dynamic equation = inserted.MathZones(1, 1); - int zoneStart = Convert.ToInt32(equation.Start); - int zoneEnd = zoneStart + Convert.ToInt32(equation.Length); - if (zoneStart >= pasted.Start + pasted.Length || zoneEnd <= pasted.Start) - throw new InvalidOperationException(PowerPointAddInText.Get("NativeEquationInsertFailed")); - equation.Font.Size = fontSizePoints; - } - catch - { - if (pasted.Length > 0) - shape.TextFrame.TextRange.Characters(pasted.Start, pasted.Length).Text = string.Empty; - throw; - } - - } - + + } + private static InsertionPoint GetInsertionPoint(dynamic slide, float widthPoints, float heightPoints) { try diff --git a/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html b/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html index 28da798d..6d2969c4 100644 --- a/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html +++ b/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html @@ -57,7 +57,7 @@

开始使用

1 · 打开插件在 Word 或 PowerPoint 顶部的 LaTeXSnipper 选项卡中选择插入公式。
2 · 写入公式在上方可视化区域输入,或在下方源码区写 LaTeX;也可以从左侧符号库选择公式。
-
3 · 插入文档确认字号和颜色后点击“插入”。选中已有公式并点击“加载所选”,可修改后点击“更新”。
+
3 · 插入文档确认字号和颜色后点击“插入”。双击已有 OLE 公式,或选中公式后点击“加载所选”,可修改后点击“更新”。
截图识别需要打开 LaTeXSnipper 桌面端,并在桌面端设置中启用自动化接口。普通公式编辑和插入可直接在 Office 插件中完成。
@@ -67,7 +67,7 @@

编辑公式

  • 可视化区与源码区:两者同步。源码含有可视化编辑器无法原样保留的写法时,上方只显示参考结果;继续在源码区编辑,或点击“用上方结果替换源码(可撤销)”。
  • 符号库与常用:左侧可搜索公式和符号,点击磁贴插入。右键点击磁贴可加入或移出“常用”;工具栏星形按钮可收藏当前公式。常用内容在升级后保留。
  • 字体与预览:工具栏的字体、字形、字号和颜色优先用于当前公式。全局颜色会同步到源码和可视化编辑区;右键可对选中部分上色、剪切、粘贴或全选,局部颜色会立即写入源码。“最终预览”按当前属性渲染,小字号会在屏幕上放大以便检查,插入仍使用选定字号;修改已有公式时沿用该公式保存的属性。
  • -
  • 快捷键:可视化区按 Enter 新建数学行,Shift + Enter 插入或更新,Esc 收起虚拟键盘。源码区按 Tab 在光标处缩进;补全列表按 Enter 接受。
  • +
  • 快捷键:可视化区按 Shift + Enter 插入或更新。源码区按 Tab 在光标处缩进;补全列表按 Enter 接受。
  • @@ -78,7 +78,7 @@

    设置与字体

    符号字体决定数学字母、符号及公式结构的字体方案。可选打包提供的 TeX 和 STIX2。 数字字体单独指定普通数字使用的系统字体;选“跟随符号字体”时与公式符号保持一致。 - 汉字字体指定公式内汉字使用的系统字体;列表只显示检测到汉字字形的字体。 + 汉字字体指定公式内汉字使用的系统字体;使用与 Word 常用汉字字体一致的固定列表(等线到幼圆)。 默认字形控制数学字符的正体、斜体、粗体、花体等表现,与字体方案分别选择。“自动”保留公式原有的数学排版;源码中明确指定的局部字形优先。 字号、颜色决定新公式的默认大小和颜色。“新建时跟随文字字号”启用后优先使用光标或选区文字字号,无有效字号时使用设置中的固定值。 @@ -90,6 +90,7 @@

    Word 中的常用操作

    插入与编号

    “插入行内公式”放在当前文字中;“插入行间公式”单独占一行;“插入带编号公式”按设置的左右位置与外框显示编号。设置中的“带编号公式默认布局”还可选择章、节编号、层级分隔符,以及是否显示章/节分隔符。

    加载、转换和格式化

    +

    OLE 公式复制到另一份文档后仍可加载并独立编辑。选择普通 OLE 公式并点击“转为 MathType”,可得到由 MathType 管理的原生对象;需要安装 MathType,转换后双击由 MathType 打开,不再保留插件源码与字体快照。Word 带编号公式暂不支持此转换。支持同时选择多个公式逐项转换,失败项保留原公式并继续处理其他项,结束后显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    选中插件插入的公式后,用“加载所选”修改或用“删除所选”移除。设置中的“公式插入方式”可选 OLE 对象或 Word 原生公式;“转换为 OLE / Word 原生公式”可处理已选公式。“格式化所选 / 全文”按当前默认属性整理公式。

    解析正文中的 LaTeX

    “解析所选 / 全文”识别完整的 $...$、\(...\)、$$...$$ 和 \[...\]。未闭合或转换失败的内容会保留原文;行间公式中的有效 \tag{...} 可作为自定义编号。

    @@ -99,6 +100,7 @@

    重编号与引用

    PowerPoint 中的常用操作

    +

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。“转为 MathType”将所选插件 OLE 公式转换为 MathType 原生对象,需要安装 MathType;转换后由 MathType 管理内容与排版,双击进入 MathType,不再保留插件源码和字体快照。支持多选批量转换;失败项保留原公式,继续处理其他项,并显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    在文本框内把光标放在文字中间并点击“插入公式”,会得到随文字排版的 PowerPoint 原生公式,可在 PowerPoint 中继续编辑。光标不在文字中时,新公式作为居中的独立幻灯片对象插入,并按设置选择 OLE 对象或 PNG 图片。

    选中插件公式后,可“加载所选”修改、“删除所选”、在 OLE 与 PNG 间转换,或用“格式化所选 / 全文”应用当前默认属性。更新与转换会尽量保留对象在幻灯片中的位置和手动缩放。

    侧边栏用于新公式草稿;“加载所选”、OLE / PNG 转换和批量格式化适用于插件插入的独立对象。文本内原生公式由 PowerPoint 管理。

    @@ -129,7 +131,7 @@

    Get started

    1 · Open the pluginUse the LaTeXSnipper tab at the top of Word or PowerPoint to insert a formula.
    2 · Write the formulaType in the visual area or the LaTeX source pane, or choose a formula from the symbol library.
    -
    3 · Insert itCheck the size and color, then select Insert. To change an existing formula, select it and use Load Selected, then Update.
    +
    3 · Insert itCheck the size and color, then select Insert. Double-click an existing OLE formula, or select a formula and use Load Selected, then Update.
    Screenshot recognition requires the LaTeXSnipper desktop app with the Automation API enabled in its settings. Ordinary formula editing and insertion work inside the Office plugin.
    @@ -139,7 +141,7 @@

    Edit formulas

  • Visual and source panes:They stay in sync. When the visual editor cannot preserve source exactly, the upper pane is a reference. Keep editing below, or choose “Replace source with result (undoable)”.
  • Library and Common:Search and click a tile to insert it. Right-click a tile to add or remove it from Common. Use the toolbar star to save the current formula. Common survives upgrades.
  • Typography and preview:Toolbar choices apply to the current formula. The formula color is synchronized with the source and visual editor. Right-click in the visual editor to color a selection, cut, paste, or select all; local colors are written to the source immediately. Final Preview enlarges small formulas on screen for inspection; insertion still uses the selected point size. Existing formulas reopen with their saved properties.
  • -
  • Keys:In the visual pane, Enter starts a math row, Shift + Enter inserts or updates, and Esc hides the virtual keyboard. In source, Tab indents at the caret and Enter accepts a completion.
  • +
  • Keys:In the visual pane, Shift + Enter inserts or updates. In source, Tab indents at the caret and Enter accepts a completion.
  • @@ -150,7 +152,7 @@

    Settings and fonts

    Symbol fontThe font scheme for mathematical letters, symbols, and formula structures. The bundled choices are TeX and STIX2. Number fontAn optional system font for ordinary digits. Follow Symbol Font keeps numbers with the mathematical symbols. - CJK fontA system font for Chinese characters within formulas. The list shows fonts found to contain Chinese glyphs. + CJK fontA system font for Chinese characters within formulas. The fixed list contains common Chinese fonts, from DengXian to YouYuan. Default math styleUpright, italic, bold, script, and other mathematical shapes, selected independently from the font scheme. Automatic preserves normal mathematical styling; explicit source styles take priority. Size and colorDefaults for new formulas. Follow Text Size uses the text at the insertion point when available and falls back to the fixed size. @@ -162,6 +164,7 @@

    Common tasks in Word

    Insert and number

    Insert Inline places a formula within text. Insert Display gives it a separate line. Insert Numbered positions the number and enclosure according to settings. Default Numbered Formula Layout also controls chapter and section numbering, separators, and boundary visibility.

    Load, convert, and format

    +

    Copied OLE formulas remain independently editable in another document. To MathType converts an unnumbered OLE formula into a native MathType object; desktop MathType must be installed. Double-click the converted object to edit it in MathType. Its content and fonts are then managed by MathType, without a separate plugin source snapshot. You can convert multiple selected formulas; failed items remain unchanged while the rest continue, followed by a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    Select a plugin formula to Load Selected or Delete Selected. Formula Insertion offers OLE objects or native Word equations. Convert to OLE / Word Equation changes selected formulas. Format Selected / All applies the current default properties.

    Parse LaTeX in document text

    Parse Selected / All recognizes complete $...$, \(...\), $$...$$, and \[...\] expressions. Incomplete or failed conversions leave the original text in place. A valid \tag{...} can provide a custom number for a display formula.

    @@ -171,6 +174,7 @@

    Renumber and reference

    Common tasks in PowerPoint

    +

    Plugin OLE formulas remain independently editable after copying to another presentation. To MathType converts the selected plugin OLE formula into a native MathType object and requires desktop MathType. MathType then manages its content and layout; double-click to edit it in MathType. The plugin source and font snapshot are not retained. Multiple selected formulas are converted individually; failed items remain unchanged while the rest continue, with a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    Place the text caret inside a text box to insert a native PowerPoint equation that flows with the surrounding text and remains editable in PowerPoint. Outside text, Insert Formula adds a centered slide object using the OLE or PNG insertion setting.

    Select a plugin formula to Load Selected, Delete Selected, convert between OLE and PNG, or apply Format Selected / All. Updates and conversions preserve the slide position and manual scale where possible.

    The side pane holds a new formula draft. Load Selected, OLE / PNG conversion, and bulk formatting apply to standalone objects inserted by the plugin. PowerPoint manages equations within text.

    diff --git a/office_plugin/hosts/PowerPointAddIn/IPowerPointApplicationAdapter.cs b/office_plugin/hosts/PowerPointAddIn/IPowerPointApplicationAdapter.cs index 43c0690b..9001a991 100644 --- a/office_plugin/hosts/PowerPointAddIn/IPowerPointApplicationAdapter.cs +++ b/office_plugin/hosts/PowerPointAddIn/IPowerPointApplicationAdapter.cs @@ -9,6 +9,8 @@ public interface IPowerPointApplicationAdapter { string GetCurrentDocumentId(); + bool TrySelectOleFormulaAtScreenPoint(int x, int y); + double GetCurrentFontSizePoints(); PowerPointTextInsertionTarget? CaptureTextInsertionTarget(); @@ -31,6 +33,16 @@ public interface IPowerPointApplicationAdapter Task LoadSelectedFormulaAsync(CancellationToken cancellationToken); + Task> LoadSelectedFormulaTargetsAsync(CancellationToken cancellationToken); + + Task ReplaceWithMathTypeAsync(PowerPointFormulaEditTarget target, string mathMl, CancellationToken cancellationToken); + + Task> LoadConversionEntriesAsync(bool includeMathType, CancellationToken cancellationToken); + + Task ReadMathTypeMathMlAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken); + + Task ReplaceMathTypeWithOleAsync(MathTypeFormulaTarget target, FormulaMetadata metadata, OlePresentationResult presentation, CancellationToken cancellationToken); + Task> LoadFormulaEntriesAsync(bool all, CancellationToken cancellationToken); bool ContainsFormula(string equationId); diff --git a/office_plugin/hosts/PowerPointAddIn/LaTeXSnipper.OfficePlugin.PowerPointAddIn.csproj b/office_plugin/hosts/PowerPointAddIn/LaTeXSnipper.OfficePlugin.PowerPointAddIn.csproj index b4455c09..8ae1761a 100644 --- a/office_plugin/hosts/PowerPointAddIn/LaTeXSnipper.OfficePlugin.PowerPointAddIn.csproj +++ b/office_plugin/hosts/PowerPointAddIn/LaTeXSnipper.OfficePlugin.PowerPointAddIn.csproj @@ -16,10 +16,6 @@ icon.ico PreserveNewest - - EditorSharedAssets\%(RecursiveDir)%(Filename)%(Extension) - PreserveNewest - diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs index e6b374e8..e8ed1b04 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs @@ -13,6 +13,8 @@ public static string GetExceptionMessage(Exception exception) public static string Get(string key) { + if (key == "ToMathTypeButton" || key == "ToMathTypeTip" || key.StartsWith("MathType", StringComparison.Ordinal)) + return MathTypeText.Get(key); return CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "zh" ? GetChinese(key) : GetEnglish(key); @@ -44,7 +46,7 @@ private static string GetEnglish(string key) "ScreenshotOcrTip" => "Wait for the next LaTeXSnipper recognition result; click again to cancel.", "LoadSelectedTip" => "Load the selected formula into the editor.", "DeleteSelectedTip" => "Delete the selected managed formulas.", - "ToOleTip" => "Convert the selected PNG formulas to OLE.", + "ToOleTip" => "Convert selected PNG or MathType equations to plugin OLE objects. Failed items remain unchanged while the rest continue.", "ToPngTip" => "Convert the selected OLE formulas to PNG.", "FormatSelectedTip" => "Reset selected formulas to the default font, color, and natural size.", "FormatAllTip" => "Apply the current default font and color to all plugin formulas and restore their natural size.", @@ -128,7 +130,7 @@ private static string GetChinese(string key) "ScreenshotOcrTip" => "等待 LaTeXSnipper 的下一次识别结果;再次单击可取消。", "LoadSelectedTip" => "将所选公式加载到编辑器中。", "DeleteSelectedTip" => "删除所选受管理公式。", - "ToOleTip" => "将所选 PNG 公式转换为 OLE。", + "ToOleTip" => "将所选 PNG 或 MathType 公式转换为插件 OLE 对象。支持多选;失败项保留并继续处理其余公式。", "ToPngTip" => "将所选 OLE 公式转换为 PNG。", "FormatSelectedTip" => "将所选公式恢复为默认字体、颜色和自然大小。", "FormatAllTip" => "将全文插件公式应用当前默认字体和颜色,并恢复自然大小。", diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointFormulaEntry.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointFormulaEntry.cs index 4205a82c..480548b5 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointFormulaEntry.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointFormulaEntry.cs @@ -4,16 +4,24 @@ namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; public sealed class PowerPointFormulaEntry { + public PowerPointFormulaEntry(MathTypeFormulaTarget target, int slideIndex) + { + MathTypeTarget = target; + SlideIndex = slideIndex; + } + + public MathTypeFormulaTarget? MathTypeTarget { get; } + private readonly FormulaMetadata? _metadata; public PowerPointFormulaEntry(FormulaMetadata metadata, int slideIndex, float left, float top, float scale) { - Metadata = metadata; + _metadata = metadata; SlideIndex = slideIndex; Left = left; Top = top; Scale = scale; } - public FormulaMetadata Metadata { get; } + public FormulaMetadata Metadata => _metadata ?? throw new System.InvalidOperationException("MathType 内容尚未读取。"); public int SlideIndex { get; } diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointFormulaMetadataStore.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointFormulaMetadataStore.cs index 641e2f3c..8c8feb56 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointFormulaMetadataStore.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointFormulaMetadataStore.cs @@ -51,10 +51,10 @@ private static void ApplyMetadataTags(dynamic shape, FormulaMetadata metadata) { shape.Tags.Add(DocumentIdTag, metadata.Identity.DocumentId); shape.Tags.Add(EquationIdTag, metadata.Identity.EquationId); - WriteEncodedText(shape, metadata.Latex); - shape.Tags.Add(DisplayModeTag, metadata.DisplayMode.ToString()); shape.Tags.Add(SchemaVersionTag, metadata.SchemaVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); shape.Tags.Add(RenderEngineTag, metadata.RenderEngine.ToString()); + WriteEncodedText(shape, metadata.Latex); + shape.Tags.Add(DisplayModeTag, metadata.DisplayMode.ToString()); WriteEncodedText(shape, new JavaScriptSerializer().Serialize(FormulaTypographyFields.Write(metadata.Typography)), TypographyPrefix); } @@ -77,8 +77,9 @@ private static FormulaMetadata LoadFromShapeCore(dynamic shape) throw MetadataMissing(); } + var identity = new FormulaIdentity(ReadRequiredTag(shape, DocumentIdTag), equationId); return new FormulaMetadata( - new FormulaIdentity(ReadRequiredTag(shape, DocumentIdTag), equationId), + identity, ReadEncodedText(shape), ReadRequiredEnumTag(shape, DisplayModeTag), NumberingMode.None, @@ -88,6 +89,19 @@ private static FormulaMetadata LoadFromShapeCore(dynamic shape) FormulaTypographyFields.Read(new JavaScriptSerializer().Deserialize>(ReadEncodedText(shape, TypographyPrefix)))); } + public static (float Width, float Height) NaturalSize(object shapeObject) + { + dynamic shape = shapeObject; + return (ReadNaturalSize(shape, NaturalWidthPointsTag), ReadNaturalSize(shape, NaturalHeightPointsTag)); + } + + private static float ReadNaturalSize(dynamic shape, string name) + { + if (!float.TryParse(ReadRequiredTag(shape, name), NumberStyles.Float, CultureInfo.InvariantCulture, out float size) + || size <= 0 || float.IsNaN(size) || float.IsInfinity(size)) throw MetadataMissing(); + return size; + } + private static void WriteEncodedText(dynamic shape, string value, string prefix = LatexChunkTagPrefix) { byte[] bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs index 98dee82b..2f338663 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs @@ -2,10 +2,12 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using LaTeXSnipper.OfficePlugin.Abstractions; +using LaTeXSnipper.OfficePlugin.Rendering; namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; @@ -13,6 +15,47 @@ public sealed partial class PowerPointPluginController { private const int BatchFormulaOperationSize = 5; + public async Task ConvertSelectedToMathTypeAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + // Capture every target before conversion changes the host selection. + IReadOnlyList targets = await _powerPointAdapter.LoadSelectedFormulaTargetsAsync(cancellationToken); + if (targets.Count == 0) throw new InvalidOperationException(MathTypeText.Get("MathTypeOleRequired")); + int converted = 0, skipped = 0, failed = 0; + string? firstFailure = null; + for (int index = 0; index < targets.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + PowerPointFormulaEditTarget target = targets[index]; + try + { + if (target.Metadata.RenderEngine != RenderEngineKind.MathJaxSvg) + { + skipped++; + continue; + } + string mathMl = await _mathJaxRenderer.ConvertTypographyToMathMlAsync(target.Metadata.Latex, + target.Metadata.DisplayMode, target.Metadata.Typography, cancellationToken); + await _powerPointAdapter.ReplaceWithMathTypeAsync(target, mathMl, cancellationToken); + converted++; + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) + { + failed++; + firstFailure ??= PowerPointAddInText.GetExceptionMessage(exception); + Trace.TraceWarning("MathType conversion failed for {0}: {1}", target.Metadata.Identity.EquationId, exception); + } + finally + { + if ((index + 1) % BatchFormulaOperationSize == 0 || index + 1 == targets.Count) + PostBatchProgress("BatchConvertingStatus", index + 1, targets.Count); + } + } + PostBatchResult(targets.Count, converted, skipped, failed, firstFailure, + "ConvertedStatus", "ConvertedWithSkippedStatus", "ConvertedWithFailuresStatus", "NoConversionNeededStatus"); + } + public Task ConvertSelectedToOleAsync(CancellationToken cancellationToken) { return ConvertSelectedAsync(RenderEngineKind.MathJaxSvg, cancellationToken); @@ -36,7 +79,7 @@ public Task FormatAllAsync(CancellationToken cancellationToken) private async Task ConvertSelectedAsync(RenderEngineKind target, CancellationToken cancellationToken) { IReadOnlyList entries = - await _powerPointAdapter.LoadFormulaEntriesAsync(false, cancellationToken); + await _powerPointAdapter.LoadConversionEntriesAsync(target == RenderEngineKind.MathJaxSvg, cancellationToken); int converted = 0; int skipped = 0; int failed = 0; @@ -52,6 +95,18 @@ private async Task ConvertSelectedAsync(RenderEngineKind target, CancellationTok cancellationToken.ThrowIfCancellationRequested(); try { + if (entry.MathTypeTarget is MathTypeFormulaTarget mathType) + { + string mathMl = await _powerPointAdapter.ReadMathTypeMathMlAsync(mathType, cancellationToken); + var imported = new FormulaMetadata( + new FormulaIdentity(mathType.DocumentId, Guid.NewGuid().ToString("N")), + mathMl, FormulaDisplayMode.Display, NumberingMode.None, string.Empty, + RenderEngineKind.MathJaxSvg, FormulaMetadata.CurrentSchemaVersion, PowerPointPluginSettings.Load().Typography); + OlePresentationResult presentation = await RenderOlePresentationAsync(imported, cancellationToken); + await _powerPointAdapter.ReplaceMathTypeWithOleAsync(mathType, imported, presentation, cancellationToken); + converted++; + continue; + } if (entry.Metadata.RenderEngine == target) { continue; @@ -81,7 +136,7 @@ private async Task ConvertSelectedAsync(RenderEngineKind target, CancellationTok failed++; firstFailure ??= PowerPointAddInText.GetExceptionMessage(exception); Trace.TraceWarning("Formula conversion failed on slide {0}, formula {1}: {2}", - entry.SlideIndex, entry.Metadata.Identity.EquationId, exception); + entry.SlideIndex, entry.MathTypeTarget?.Location.ToString() ?? entry.Metadata.Identity.EquationId, exception); } } diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.cs index 928d7bae..583f3ef6 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.cs @@ -49,6 +49,8 @@ public PowerPointPluginController( _imageFileStore = imageFileStore ?? new PowerPointImageFileStore(); } + internal bool TrySelectOleFormulaAtScreenPoint(int x, int y) => _powerPointAdapter.TrySelectOleFormulaAtScreenPoint(x, y); + public async Task TestConnectionAsync(CancellationToken cancellationToken) { ThrowIfDisposed(); @@ -552,6 +554,7 @@ public void Dispose() _editorSession.Dispose(); _automationClient.Dispose(); _mathJaxRenderer.Dispose(); + (_powerPointAdapter as IDisposable)?.Dispose(); _commandGate.Dispose(); } diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointRibbonCallbacks.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointRibbonCallbacks.cs index 459e04e1..c98df470 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointRibbonCallbacks.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointRibbonCallbacks.cs @@ -45,6 +45,14 @@ public void OnLoadSelected(object control) FireAndForgetSerial(ct => _controller.LoadSelectedAsync(ct)); } + public IDisposable ListenForFormulaDoubleClick(int officeProcessId) + { + return new LaTeXSnipper.OfficePlugin.Rendering.OfficeFormulaDoubleClickListener(officeProcessId, (x, y) => + { + if (_controller.TrySelectOleFormulaAtScreenPoint(x, y)) OnLoadSelected(this); + }); + } + public void OnScreenshotOcr(object control) { if (Interlocked.CompareExchange(ref _ocrRunning, 1, 0) == 1) @@ -63,6 +71,7 @@ public void OnDeleteSelected(object control) } public void OnConvertSelectedToOle(object control) => FireAndForgetSerial(ct => _controller.ConvertSelectedToOleAsync(ct)); + public void OnConvertSelectedToMathType(object control) => FireAndForgetSerial(ct => _controller.ConvertSelectedToMathTypeAsync(ct)); public void OnConvertSelectedToPng(object control) => FireAndForgetSerial(ct => _controller.ConvertSelectedToPngAsync(ct)); diff --git a/office_plugin/hosts/PowerPointAddIn/Ribbon/PowerPointRibbon.xml b/office_plugin/hosts/PowerPointAddIn/Ribbon/PowerPointRibbon.xml index a8983ed5..08778891 100644 --- a/office_plugin/hosts/PowerPointAddIn/Ribbon/PowerPointRibbon.xml +++ b/office_plugin/hosts/PowerPointAddIn/Ribbon/PowerPointRibbon.xml @@ -36,6 +36,7 @@ onAction="OnDeleteSelected" /> +
    @@ -67,7 +67,7 @@

    编辑公式

  • 可视化区与源码区:两者同步。源码含有可视化编辑器无法原样保留的写法时,上方只显示参考结果;继续在源码区编辑,或点击“用上方结果替换源码(可撤销)”。
  • 符号库与常用:左侧可搜索公式和符号,点击磁贴插入。右键点击磁贴可加入或移出“常用”;工具栏星形按钮可收藏当前公式。常用内容在升级后保留。
  • 字体与预览:工具栏的字体、字形、字号和颜色优先用于当前公式。全局颜色会同步到源码和可视化编辑区;右键可对选中部分上色、剪切、粘贴或全选,局部颜色会立即写入源码。“最终预览”按当前属性渲染,小字号会在屏幕上放大以便检查,插入仍使用选定字号;修改已有公式时沿用该公式保存的属性。
  • -
  • 快捷键:可视化区按 Enter 新建数学行,Shift + Enter 插入或更新,Esc 收起虚拟键盘。源码区按 Tab 在光标处缩进;补全列表按 Enter 接受。
  • +
  • 快捷键:可视化区按 Shift + Enter 插入或更新。源码区按 Tab 在光标处缩进;补全列表按 Enter 接受。
  • @@ -78,7 +78,7 @@

    设置与字体

    符号字体决定数学字母、符号及公式结构的字体方案。可选打包提供的 TeX 和 STIX2。 数字字体单独指定普通数字使用的系统字体;选“跟随符号字体”时与公式符号保持一致。 - 汉字字体指定公式内汉字使用的系统字体;列表只显示检测到汉字字形的字体。 + 汉字字体指定公式内汉字使用的系统字体;使用与 Word 常用汉字字体一致的固定列表(等线到幼圆)。 默认字形控制数学字符的正体、斜体、粗体、花体等表现,与字体方案分别选择。“自动”保留公式原有的数学排版;源码中明确指定的局部字形优先。 字号、颜色决定新公式的默认大小和颜色。“新建时跟随文字字号”启用后优先使用光标或选区文字字号,无有效字号时使用设置中的固定值。 @@ -90,6 +90,7 @@

    Word 中的常用操作

    插入与编号

    “插入行内公式”放在当前文字中;“插入行间公式”单独占一行;“插入带编号公式”按设置的左右位置与外框显示编号。设置中的“带编号公式默认布局”还可选择章、节编号、层级分隔符,以及是否显示章/节分隔符。

    加载、转换和格式化

    +

    OLE 公式复制到另一份文档后仍可加载并独立编辑。选择普通 OLE 公式并点击“转为 MathType”,可得到由 MathType 管理的原生对象;需要安装 MathType,转换后双击由 MathType 打开,不再保留插件源码与字体快照。Word 带编号公式暂不支持此转换。支持同时选择多个公式逐项转换,失败项保留原公式并继续处理其他项,结束后显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    选中插件插入的公式后,用“加载所选”修改或用“删除所选”移除。设置中的“公式插入方式”可选 OLE 对象或 Word 原生公式;“转换为 OLE / Word 原生公式”可处理已选公式。“格式化所选 / 全文”按当前默认属性整理公式。

    解析正文中的 LaTeX

    “解析所选 / 全文”识别完整的 $...$、\(...\)、$$...$$ 和 \[...\]。未闭合或转换失败的内容会保留原文;行间公式中的有效 \tag{...} 可作为自定义编号。

    @@ -99,6 +100,7 @@

    重编号与引用

    PowerPoint 中的常用操作

    +

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。“转为 MathType”将所选插件 OLE 公式转换为 MathType 原生对象,需要安装 MathType;转换后由 MathType 管理内容与排版,双击进入 MathType,不再保留插件源码和字体快照。支持多选批量转换;失败项保留原公式,继续处理其他项,并显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    在文本框内把光标放在文字中间并点击“插入公式”,会得到随文字排版的 PowerPoint 原生公式,可在 PowerPoint 中继续编辑。光标不在文字中时,新公式作为居中的独立幻灯片对象插入,并按设置选择 OLE 对象或 PNG 图片。

    选中插件公式后,可“加载所选”修改、“删除所选”、在 OLE 与 PNG 间转换,或用“格式化所选 / 全文”应用当前默认属性。更新与转换会尽量保留对象在幻灯片中的位置和手动缩放。

    侧边栏用于新公式草稿;“加载所选”、OLE / PNG 转换和批量格式化适用于插件插入的独立对象。文本内原生公式由 PowerPoint 管理。

    @@ -129,7 +131,7 @@

    Get started

    1 · Open the pluginUse the LaTeXSnipper tab at the top of Word or PowerPoint to insert a formula.
    2 · Write the formulaType in the visual area or the LaTeX source pane, or choose a formula from the symbol library.
    -
    3 · Insert itCheck the size and color, then select Insert. To change an existing formula, select it and use Load Selected, then Update.
    +
    3 · Insert itCheck the size and color, then select Insert. Double-click an existing OLE formula, or select a formula and use Load Selected, then Update.
    Screenshot recognition requires the LaTeXSnipper desktop app with the Automation API enabled in its settings. Ordinary formula editing and insertion work inside the Office plugin.
    @@ -139,7 +141,7 @@

    Edit formulas

  • Visual and source panes:They stay in sync. When the visual editor cannot preserve source exactly, the upper pane is a reference. Keep editing below, or choose “Replace source with result (undoable)”.
  • Library and Common:Search and click a tile to insert it. Right-click a tile to add or remove it from Common. Use the toolbar star to save the current formula. Common survives upgrades.
  • Typography and preview:Toolbar choices apply to the current formula. The formula color is synchronized with the source and visual editor. Right-click in the visual editor to color a selection, cut, paste, or select all; local colors are written to the source immediately. Final Preview enlarges small formulas on screen for inspection; insertion still uses the selected point size. Existing formulas reopen with their saved properties.
  • -
  • Keys:In the visual pane, Enter starts a math row, Shift + Enter inserts or updates, and Esc hides the virtual keyboard. In source, Tab indents at the caret and Enter accepts a completion.
  • +
  • Keys:In the visual pane, Shift + Enter inserts or updates. In source, Tab indents at the caret and Enter accepts a completion.
  • @@ -150,7 +152,7 @@

    Settings and fonts

    Symbol fontThe font scheme for mathematical letters, symbols, and formula structures. The bundled choices are TeX and STIX2. Number fontAn optional system font for ordinary digits. Follow Symbol Font keeps numbers with the mathematical symbols. - CJK fontA system font for Chinese characters within formulas. The list shows fonts found to contain Chinese glyphs. + CJK fontA system font for Chinese characters within formulas. The fixed list contains common Chinese fonts, from DengXian to YouYuan. Default math styleUpright, italic, bold, script, and other mathematical shapes, selected independently from the font scheme. Automatic preserves normal mathematical styling; explicit source styles take priority. Size and colorDefaults for new formulas. Follow Text Size uses the text at the insertion point when available and falls back to the fixed size. @@ -162,6 +164,7 @@

    Common tasks in Word

    Insert and number

    Insert Inline places a formula within text. Insert Display gives it a separate line. Insert Numbered positions the number and enclosure according to settings. Default Numbered Formula Layout also controls chapter and section numbering, separators, and boundary visibility.

    Load, convert, and format

    +

    Copied OLE formulas remain independently editable in another document. To MathType converts an unnumbered OLE formula into a native MathType object; desktop MathType must be installed. Double-click the converted object to edit it in MathType. Its content and fonts are then managed by MathType, without a separate plugin source snapshot. You can convert multiple selected formulas; failed items remain unchanged while the rest continue, followed by a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    Select a plugin formula to Load Selected or Delete Selected. Formula Insertion offers OLE objects or native Word equations. Convert to OLE / Word Equation changes selected formulas. Format Selected / All applies the current default properties.

    Parse LaTeX in document text

    Parse Selected / All recognizes complete $...$, \(...\), $$...$$, and \[...\] expressions. Incomplete or failed conversions leave the original text in place. A valid \tag{...} can provide a custom number for a display formula.

    @@ -171,6 +174,7 @@

    Renumber and reference

    Common tasks in PowerPoint

    +

    Plugin OLE formulas remain independently editable after copying to another presentation. To MathType converts the selected plugin OLE formula into a native MathType object and requires desktop MathType. MathType then manages its content and layout; double-click to edit it in MathType. The plugin source and font snapshot are not retained. Multiple selected formulas are converted individually; failed items remain unchanged while the rest continue, with a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    Place the text caret inside a text box to insert a native PowerPoint equation that flows with the surrounding text and remains editable in PowerPoint. Outside text, Insert Formula adds a centered slide object using the OLE or PNG insertion setting.

    Select a plugin formula to Load Selected, Delete Selected, convert between OLE and PNG, or apply Format Selected / All. Updates and conversions preserve the slide position and manual scale where possible.

    The side pane holds a new formula draft. Load Selected, OLE / PNG conversion, and bulk formatting apply to standalone objects inserted by the plugin. PowerPoint manages equations within text.

    diff --git a/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs b/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs index f8bbdebc..603b26d3 100644 --- a/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs +++ b/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs @@ -10,6 +10,8 @@ public interface IWordApplicationAdapter { string GetCurrentDocumentId(); + bool TrySelectOleFormulaAtScreenPoint(int x, int y); + Task ValidateCurrentInsertionTargetAsync(CancellationToken cancellationToken); Task ActivateForEditingAsync(CancellationToken cancellationToken); @@ -52,6 +54,16 @@ Task InsertManagedEquationAsync( Task LoadSelectedFormulaTargetAsync(CancellationToken cancellationToken); + Task> LoadSelectedFormulaTargetsAsync(CancellationToken cancellationToken); + + Task ReplaceWithMathTypeAsync(WordFormulaEditTarget target, string mathMl, CancellationToken cancellationToken); + + Task> LoadConversionEntriesAsync(bool includeMathType, CancellationToken cancellationToken); + + Task ReadMathTypeMathMlAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken); + + Task ReplaceMathTypeWithOleAsync(MathTypeFormulaTarget target, FormulaMetadata metadata, OlePresentationResult presentation, CancellationToken cancellationToken); + bool IsFormulaEditTargetValid(WordFormulaEditTarget target); Task UpdateOleFormulaObjectAsync(WordFormulaEditTarget target, FormulaMetadata metadata, OlePresentationResult presentation, bool display, CancellationToken cancellationToken); diff --git a/office_plugin/hosts/WordAddIn/LaTeXSnipper.OfficePlugin.WordAddIn.csproj b/office_plugin/hosts/WordAddIn/LaTeXSnipper.OfficePlugin.WordAddIn.csproj index 542c98b0..79630d4e 100644 --- a/office_plugin/hosts/WordAddIn/LaTeXSnipper.OfficePlugin.WordAddIn.csproj +++ b/office_plugin/hosts/WordAddIn/LaTeXSnipper.OfficePlugin.WordAddIn.csproj @@ -27,10 +27,6 @@ icon.ico PreserveNewest - - EditorSharedAssets\%(RecursiveDir)%(Filename)%(Extension) - PreserveNewest - diff --git a/office_plugin/hosts/WordAddIn/Ribbon/WordRibbon.xml b/office_plugin/hosts/WordAddIn/Ribbon/WordRibbon.xml index 7c9bae16..70def4ea 100644 --- a/office_plugin/hosts/WordAddIn/Ribbon/WordRibbon.xml +++ b/office_plugin/hosts/WordAddIn/Ribbon/WordRibbon.xml @@ -50,6 +50,7 @@ onAction="OnDeleteSelected" /> +

    PowerPoint 中的常用操作

    -

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。“转为 MathType”将所选插件 OLE 公式转换为 MathType 原生对象,需要安装 MathType;转换后由 MathType 管理内容与排版,双击进入 MathType,不再保留插件源码和字体快照。支持多选批量转换;失败项保留原公式,继续处理其他项,并显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    +

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。“转为 MathType”将所选插件 OLE 公式转换为 MathType 原生对象。双向转换无需安装 MathType;双击编辑转换后的对象需要 MathType。转换后不再保留插件源码和字体快照。支持多选批量转换;失败项保留原公式,继续处理其他项,并显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    在文本框内把光标放在文字中间并点击“插入公式”,会得到随文字排版的 PowerPoint 原生公式,可在 PowerPoint 中继续编辑。光标不在文字中时,新公式作为居中的独立幻灯片对象插入,并按设置选择 OLE 对象或 PNG 图片。

    选中插件公式后,可“加载所选”修改、“删除所选”、在 OLE 与 PNG 间转换,或用“格式化所选 / 全文”应用当前默认属性。更新与转换会尽量保留对象在幻灯片中的位置和手动缩放。

    侧边栏用于新公式草稿;“加载所选”、OLE / PNG 转换和批量格式化适用于插件插入的独立对象。文本内原生公式由 PowerPoint 管理。

    @@ -164,7 +164,7 @@

    Common tasks in Word

    Insert and number

    Insert Inline places a formula within text. Insert Display gives it a separate line. Insert Numbered positions the number and enclosure according to settings. Default Numbered Formula Layout also controls chapter and section numbering, separators, and boundary visibility.

    Load, convert, and format

    -

    Copied OLE formulas remain independently editable in another document. To MathType converts an unnumbered OLE formula into a native MathType object; desktop MathType must be installed. Double-click the converted object to edit it in MathType. Its content and fonts are then managed by MathType, without a separate plugin source snapshot. You can convert multiple selected formulas; failed items remain unchanged while the rest continue, followed by a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    +

    Copied OLE formulas remain independently editable in another document. To MathType converts an unnumbered OLE formula into a native MathType object. Conversion in either direction works without MathType installed; double-click editing of native MathType objects requires MathType. The plugin source and font snapshot are not retained after conversion. You can convert multiple selected formulas; failed items remain unchanged while the rest continue, followed by a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    Select a plugin formula to Load Selected or Delete Selected. Formula Insertion offers OLE objects or native Word equations. Convert to OLE / Word Equation changes selected formulas. Format Selected / All applies the current default properties.

    Parse LaTeX in document text

    Parse Selected / All recognizes complete $...$, \(...\), $$...$$, and \[...\] expressions. Incomplete or failed conversions leave the original text in place. A valid \tag{...} can provide a custom number for a display formula.

    @@ -174,7 +174,7 @@

    Renumber and reference

    Common tasks in PowerPoint

    -

    Plugin OLE formulas remain independently editable after copying to another presentation. To MathType converts the selected plugin OLE formula into a native MathType object and requires desktop MathType. MathType then manages its content and layout; double-click to edit it in MathType. The plugin source and font snapshot are not retained. Multiple selected formulas are converted individually; failed items remain unchanged while the rest continue, with a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    +

    Plugin OLE formulas remain independently editable after copying to another presentation. To MathType converts the selected plugin OLE formula into a native MathType object. Conversion in either direction works without MathType installed; double-click editing of native MathType objects requires MathType. The plugin source and font snapshot are not retained after conversion. Multiple selected formulas are converted individually; failed items remain unchanged while the rest continue, with a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    Place the text caret inside a text box to insert a native PowerPoint equation that flows with the surrounding text and remains editable in PowerPoint. Outside text, Insert Formula adds a centered slide object using the OLE or PNG insertion setting.

    Select a plugin formula to Load Selected, Delete Selected, convert between OLE and PNG, or apply Format Selected / All. Updates and conversions preserve the slide position and manual scale where possible.

    The side pane holds a new formula draft. Load Selected, OLE / PNG conversion, and bulk formatting apply to standalone objects inserted by the plugin. PowerPoint manages equations within text.

    diff --git a/office_plugin/hosts/PowerPointAddIn/IPowerPointApplicationAdapter.cs b/office_plugin/hosts/PowerPointAddIn/IPowerPointApplicationAdapter.cs index 9001a991..638823db 100644 --- a/office_plugin/hosts/PowerPointAddIn/IPowerPointApplicationAdapter.cs +++ b/office_plugin/hosts/PowerPointAddIn/IPowerPointApplicationAdapter.cs @@ -35,11 +35,11 @@ public interface IPowerPointApplicationAdapter Task> LoadSelectedFormulaTargetsAsync(CancellationToken cancellationToken); - Task ReplaceWithMathTypeAsync(PowerPointFormulaEditTarget target, string mathMl, CancellationToken cancellationToken); + Task ReplaceWithMathTypeAsync(PowerPointFormulaEditTarget target, byte[] compoundFile, OlePresentationResult presentation, CancellationToken cancellationToken); Task> LoadConversionEntriesAsync(bool includeMathType, CancellationToken cancellationToken); - Task ReadMathTypeMathMlAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken); + Task ReadMathTypeAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken); Task ReplaceMathTypeWithOleAsync(MathTypeFormulaTarget target, FormulaMetadata metadata, OlePresentationResult presentation, CancellationToken cancellationToken); diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs index e8ed1b04..eeb39d11 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs @@ -43,16 +43,16 @@ private static string GetEnglish(string key) "SettingsButton" => "Settings", "HelpButton" => "Help", "InsertFormulaTip" => "Open the formula editor.", - "ScreenshotOcrTip" => "Wait for the next LaTeXSnipper recognition result; click again to cancel.", + "ScreenshotOcrTip" => "Wait for screenshot OCR; click again to cancel.", "LoadSelectedTip" => "Load the selected formula into the editor.", - "DeleteSelectedTip" => "Delete the selected managed formulas.", - "ToOleTip" => "Convert selected PNG or MathType equations to plugin OLE objects. Failed items remain unchanged while the rest continue.", + "DeleteSelectedTip" => "Delete the selected plugin formulas.", + "ToOleTip" => "Convert selected PNG or MathType equations to plugin OLE formulas.", "ToPngTip" => "Convert the selected OLE formulas to PNG.", - "FormatSelectedTip" => "Reset selected formulas to the default font, color, and natural size.", - "FormatAllTip" => "Apply the current default font and color to all plugin formulas and restore their natural size.", + "FormatSelectedTip" => "Reset selected formulas to the default style and natural size.", + "FormatAllTip" => "Reset all plugin formulas to the default style and natural size.", "ShowTaskPaneTip" => "Show the status pane.", "SettingsTip" => "Open LaTeXSnipper settings.", - "HelpTip" => "Show Office plugin help.", + "HelpTip" => "View plugin help.", "OfficePluginLabel" => "Office plugin", "EquationLabel" => "Formula", "ConnectButton" => "Connect", @@ -127,16 +127,16 @@ private static string GetChinese(string key) "SettingsButton" => "设置", "HelpButton" => "帮助", "InsertFormulaTip" => "打开公式编辑器。", - "ScreenshotOcrTip" => "等待 LaTeXSnipper 的下一次识别结果;再次单击可取消。", - "LoadSelectedTip" => "将所选公式加载到编辑器中。", - "DeleteSelectedTip" => "删除所选受管理公式。", - "ToOleTip" => "将所选 PNG 或 MathType 公式转换为插件 OLE 对象。支持多选;失败项保留并继续处理其余公式。", + "ScreenshotOcrTip" => "等待截图识别结果,再次点击取消。", + "LoadSelectedTip" => "将所选公式加载到编辑器。", + "DeleteSelectedTip" => "删除所选插件公式。", + "ToOleTip" => "将所选 PNG 或 MathType 公式转为插件 OLE 公式。", "ToPngTip" => "将所选 OLE 公式转换为 PNG。", - "FormatSelectedTip" => "将所选公式恢复为默认字体、颜色和自然大小。", - "FormatAllTip" => "将全文插件公式应用当前默认字体和颜色,并恢复自然大小。", + "FormatSelectedTip" => "恢复所选公式的默认样式和自然大小。", + "FormatAllTip" => "恢复全文插件公式的默认样式和自然大小。", "ShowTaskPaneTip" => "显示状态窗格。", "SettingsTip" => "打开 LaTeXSnipper 设置。", - "HelpTip" => "显示 Office 插件帮助。", + "HelpTip" => "查看插件帮助。", "OfficePluginLabel" => "Office 插件", "EquationLabel" => "公式", "ConnectButton" => "连接", diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointMathMlClipboard.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointClipboard.cs similarity index 94% rename from office_plugin/hosts/PowerPointAddIn/PowerPointMathMlClipboard.cs rename to office_plugin/hosts/PowerPointAddIn/PowerPointClipboard.cs index 48b5a95c..d857bb43 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointMathMlClipboard.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointClipboard.cs @@ -9,18 +9,26 @@ namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; -internal static class PowerPointMathMlClipboard +internal static class PowerPointClipboard { public static (int Start, int Length) PasteAtRange(dynamic range, string mathMl) { if (string.IsNullOrWhiteSpace(mathMl)) throw new ArgumentException("MathML 不能为空。", nameof(mathMl)); - IDataObject? previous = SnapshotClipboard(); - try + return WithSavedClipboard<(int Start, int Length)>(() => { string normalized = CompactMathMl(mathMl); Clipboard.SetText(normalized, TextDataFormat.UnicodeText); dynamic pasted = range.Paste(); return (Convert.ToInt32(pasted.Start), Convert.ToInt32(pasted.Length)); + }); + } + + public static T WithSavedClipboard(Func action) + { + IDataObject? previous = SnapshotClipboard(); + try + { + return action(); } finally { diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs index 2f338663..abca4cbb 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs @@ -36,7 +36,10 @@ public async Task ConvertSelectedToMathTypeAsync(CancellationToken cancellationT } string mathMl = await _mathJaxRenderer.ConvertTypographyToMathMlAsync(target.Metadata.Latex, target.Metadata.DisplayMode, target.Metadata.Typography, cancellationToken); - await _powerPointAdapter.ReplaceWithMathTypeAsync(target, mathMl, cancellationToken); + byte[] native = MathTypeNativeEquation.Create(mathMl, target.Metadata.Typography.FontSizePoints); + byte[] compoundFile = MathTypeCompoundFile.Create(native); + OlePresentationResult presentation = await RenderOlePresentationAsync(target.Metadata, cancellationToken); + await _powerPointAdapter.ReplaceWithMathTypeAsync(target, compoundFile, presentation, cancellationToken); converted++; } catch (OperationCanceledException) { throw; } @@ -97,11 +100,12 @@ private async Task ConvertSelectedAsync(RenderEngineKind target, CancellationTok { if (entry.MathTypeTarget is MathTypeFormulaTarget mathType) { - string mathMl = await _powerPointAdapter.ReadMathTypeMathMlAsync(mathType, cancellationToken); + MathTypeFormulaContent content = await _powerPointAdapter.ReadMathTypeAsync(mathType, cancellationToken); var imported = new FormulaMetadata( new FormulaIdentity(mathType.DocumentId, Guid.NewGuid().ToString("N")), - mathMl, FormulaDisplayMode.Display, NumberingMode.None, string.Empty, - RenderEngineKind.MathJaxSvg, FormulaMetadata.CurrentSchemaVersion, PowerPointPluginSettings.Load().Typography); + content.MathMl, FormulaDisplayMode.Display, NumberingMode.None, string.Empty, + RenderEngineKind.MathJaxSvg, FormulaMetadata.CurrentSchemaVersion, + PowerPointPluginSettings.Load().Typography.WithFontSize(content.FontSizePoints)); OlePresentationResult presentation = await RenderOlePresentationAsync(imported, cancellationToken); await _powerPointAdapter.ReplaceMathTypeWithOleAsync(mathType, imported, presentation, cancellationToken); converted++; diff --git a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.MathType.cs b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.MathType.cs index 6b2ae96e..11de2653 100644 --- a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.MathType.cs +++ b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.MathType.cs @@ -34,14 +34,12 @@ private dynamic FindMathTypeFormula(MathTypeFormulaTarget target) throw new InvalidOperationException(WordAddInText.Get("SelectedFormulaRequired")); } - public Task ReadMathTypeMathMlAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken) + public Task ReadMathTypeAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken) => _officeThread.InvokeAsync(() => { dynamic shape = FindMathTypeFormula(target); - shape.OLEFormat.Activate(); - object server = shape.OLEFormat.Object; - try { return MathTypeOleBridge.ReadAndCloseMathMl(server); } - finally { if (System.Runtime.InteropServices.Marshal.IsComObject(server)) System.Runtime.InteropServices.Marshal.ReleaseComObject(server); } + byte[] native = MathTypeWordPackage.ReadNative(Convert.ToString(shape.Range.WordOpenXML)!); + return new MathTypeFormulaContent(MathTypeNativeEquation.ReadMathMl(native), MathTypeNativeEquation.ReadFontSizePoints(native)); }, cancellationToken); public Task ReplaceMathTypeWithOleAsync(MathTypeFormulaTarget target, FormulaMetadata metadata, @@ -63,6 +61,7 @@ public Task ReplaceMathTypeWithOleAsync(MathTypeFormulaTarget target, FormulaMet converted.LockAspectRatio = -1; converted.Height = height; SaveFormulaMetadata(metadata); + cancellationToken.ThrowIfCancellationRequested(); FindMathTypeFormula(target).Delete(); } catch @@ -88,73 +87,72 @@ public Task ReplaceMathTypeWithOleAsync(MathTypeFormulaTarget target, FormulaMet }, cancellationToken); } - public Task ReplaceWithMathTypeAsync(WordFormulaEditTarget target, string mathMl, CancellationToken cancellationToken) + public Task ReplaceWithMathTypeAsync(WordFormulaEditTarget target, byte[] compoundFile, OlePresentationResult presentation, CancellationToken cancellationToken) => _officeThread.InvokeAsync(() => { if (!target.IsOle || !IsFormulaEditTargetValid(target)) throw new InvalidOperationException(WordAddInText.Get("SelectedFormulaRequired")); if (target.Metadata.NumberingMode != NumberingMode.None) throw new InvalidOperationException(WordAddInText.Get("MathTypeNumberedUnsupported")); - MathTypeOleBridge.EnsureAvailable(); using (UseDocument(target.Document)) using (BeginUndoRecord()) { dynamic original = TryFindOleInlineShapeById(target.Metadata.Identity.EquationId) ?? throw new InvalidOperationException(WordAddInText.Get("SelectedFormulaRequired")); - float width = Convert.ToSingle(original.Width), height = Convert.ToSingle(original.Height); - string objectXml = CreateMathTypeObjectXml(mathMl, width, height); cancellationToken.ThrowIfCancellationRequested(); dynamic insertion = original.Range.Duplicate; insertion.Collapse(0); - int insertionStart = GetRangeStart(insertion); + float width = Convert.ToSingle(original.Width); + float height = Convert.ToSingle(original.Height); + string package = MathTypeWordPackage.Create(compoundFile, presentation, width, height); + string expected = MathTypeNativeEquation.ReadMathMl(MathTypeCompoundFile.ReadNative(compoundFile)); + int originalCount = Convert.ToInt32(CurrentDocument.InlineShapes.Count); + int insertionStart = Convert.ToInt32(insertion.Start); + int documentEnd = Convert.ToInt32(CurrentDocument.Content.End); dynamic? converted = null; + dynamic? inserted = null; try { - // Insert an already saved native object: the SDK never updates - // this document's placeholder or its cached scaling factors. - insertion.InsertXML(objectXml); - converted = FindMathTypeFormula(new MathTypeFormulaTarget(target.Document, - target.Metadata.Identity.DocumentId, insertionStart)); + insertion.InsertXML(package); + int insertedLength = Convert.ToInt32(CurrentDocument.Content.End) - documentEnd; + inserted = CurrentDocument.Range(insertionStart, insertionStart + insertedLength); + if (Convert.ToInt32(inserted.InlineShapes.Count) != 1) + throw new InvalidOperationException("MathType insertion did not produce exactly one equation."); + converted = inserted.InlineShapes.Item(1); + dynamic trailing = CurrentDocument.Range(Convert.ToInt32(converted.Range.End), Convert.ToInt32(inserted.End)); + dynamic leading = CurrentDocument.Range(Convert.ToInt32(inserted.Start), Convert.ToInt32(converted.Range.Start)); + string trailingText = Convert.ToString((object?)trailing.Text) ?? string.Empty; + string leadingText = Convert.ToString((object?)leading.Text) ?? string.Empty; + if (trailingText.Trim('\r').Length != 0 || leadingText.Trim('\r').Length != 0) + throw new InvalidOperationException("MathType insertion unexpectedly introduced surrounding text."); + if (Convert.ToInt32(trailing.End) > Convert.ToInt32(trailing.Start)) trailing.Delete(); + if (Convert.ToInt32(leading.End) > Convert.ToInt32(leading.Start)) leading.Delete(); if (!MathTypeOleBridge.IsEquation((object)converted)) throw new InvalidOperationException(WordAddInText.Get("MathTypeNativeRequired")); converted.AlternativeText = string.Empty; - if (Math.Abs(Convert.ToSingle(converted.Width) - width) > Math.Max(0.5f, width * 0.01f) - || Math.Abs(Convert.ToSingle(converted.Height) - height) > Math.Max(0.5f, height * 0.01f)) - throw new InvalidOperationException("MathType 对象未能恢复转换前的宽度和高度。"); - original = TryFindOleInlineShapeById(target.Metadata.Identity.EquationId) - ?? throw new InvalidOperationException(WordAddInText.Get("SelectedFormulaRequired")); + string actual = MathTypeNativeEquation.ReadMathMl(MathTypeWordPackage.ReadNative(Convert.ToString(converted.Range.WordOpenXML)!)); + if (actual != expected || Convert.ToInt32(CurrentDocument.InlineShapes.Count) != originalCount + 1 + || Math.Abs(Convert.ToSingle(converted.Width) - width) > 0.1f + || Math.Abs(Convert.ToSingle(converted.Height) - height) > 0.1f) + throw new InvalidOperationException("MathType insertion content, count or dimensions failed validation."); + cancellationToken.ThrowIfCancellationRequested(); original.Delete(); } catch { - if (converted != null) converted.Delete(); + if (inserted != null) + { + if (Convert.ToInt32(inserted.End) > Convert.ToInt32(inserted.Start)) inserted.Delete(); + } + else + { + int insertedLength = Convert.ToInt32(CurrentDocument.Content.End) - documentEnd; + if (insertedLength > 0) CurrentDocument.Range(insertionStart, insertionStart + insertedLength).Delete(); + } throw; } TryCom(() => converted.Range.Select()); } return true; }, cancellationToken); - - private string CreateMathTypeObjectXml(string mathMl, float width, float height) - { - dynamic scratch = _wordApplication.Documents.Add(Visible: false); - try - { - dynamic shape = scratch.InlineShapes.AddOLEObject(MathTypeOleBridge.ProgId); - object server = shape.OLEFormat.Object; - try { MathTypeOleBridge.WriteMathMl(server, mathMl); } - finally { if (System.Runtime.InteropServices.Marshal.IsComObject(server)) System.Runtime.InteropServices.Marshal.ReleaseComObject(server); } - string objectXml = Convert.ToString(shape.Range.WordOpenXML); - var xml = System.Xml.Linq.XDocument.Parse(objectXml); - System.Xml.Linq.XNamespace word = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - System.Xml.Linq.XNamespace vml = "urn:schemas-microsoft-com:vml"; - var frame = xml.Descendants(word + "object").Single().Element(vml + "shape") - ?? throw new InvalidOperationException(WordAddInText.Get("MathTypeNativeRequired")); - // Persist each original dimension explicitly in points. Importing the - // complete native storage and frame avoids Word's late OLE resizing. - frame.SetAttributeValue("style", FormattableString.Invariant($"width:{width:R}pt;height:{height:R}pt")); - return xml.ToString(System.Xml.Linq.SaveOptions.DisableFormatting); - } - finally { scratch.Close(0); } - } } diff --git a/office_plugin/hosts/WordAddIn/EditorAssets/help.html b/office_plugin/hosts/WordAddIn/EditorAssets/help.html index 6d2969c4..79605a2f 100644 --- a/office_plugin/hosts/WordAddIn/EditorAssets/help.html +++ b/office_plugin/hosts/WordAddIn/EditorAssets/help.html @@ -90,7 +90,7 @@

    Word 中的常用操作

    插入与编号

    “插入行内公式”放在当前文字中;“插入行间公式”单独占一行;“插入带编号公式”按设置的左右位置与外框显示编号。设置中的“带编号公式默认布局”还可选择章、节编号、层级分隔符,以及是否显示章/节分隔符。

    加载、转换和格式化

    -

    OLE 公式复制到另一份文档后仍可加载并独立编辑。选择普通 OLE 公式并点击“转为 MathType”,可得到由 MathType 管理的原生对象;需要安装 MathType,转换后双击由 MathType 打开,不再保留插件源码与字体快照。Word 带编号公式暂不支持此转换。支持同时选择多个公式逐项转换,失败项保留原公式并继续处理其他项,结束后显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    +

    OLE 公式复制到另一份文档后仍可加载并独立编辑。选择普通 OLE 公式并点击“转为 MathType”,可得到 MathType 原生对象。双向转换无需安装 MathType;双击编辑转换后的对象需要 MathType。转换后不再保留插件源码与字体快照。Word 带编号公式暂不支持此转换。支持同时选择多个公式逐项转换,失败项保留原公式并继续处理其他项,结束后显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    选中插件插入的公式后,用“加载所选”修改或用“删除所选”移除。设置中的“公式插入方式”可选 OLE 对象或 Word 原生公式;“转换为 OLE / Word 原生公式”可处理已选公式。“格式化所选 / 全文”按当前默认属性整理公式。

    解析正文中的 LaTeX

    “解析所选 / 全文”识别完整的 $...$、\(...\)、$$...$$ 和 \[...\]。未闭合或转换失败的内容会保留原文;行间公式中的有效 \tag{...} 可作为自定义编号。

    @@ -100,7 +100,7 @@

    重编号与引用

    PowerPoint 中的常用操作

    -

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。“转为 MathType”将所选插件 OLE 公式转换为 MathType 原生对象,需要安装 MathType;转换后由 MathType 管理内容与排版,双击进入 MathType,不再保留插件源码和字体快照。支持多选批量转换;失败项保留原公式,继续处理其他项,并显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    +

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。“转为 MathType”将所选插件 OLE 公式转换为 MathType 原生对象。双向转换无需安装 MathType;双击编辑转换后的对象需要 MathType。转换后不再保留插件源码和字体快照。支持多选批量转换;失败项保留原公式,继续处理其他项,并显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    在文本框内把光标放在文字中间并点击“插入公式”,会得到随文字排版的 PowerPoint 原生公式,可在 PowerPoint 中继续编辑。光标不在文字中时,新公式作为居中的独立幻灯片对象插入,并按设置选择 OLE 对象或 PNG 图片。

    选中插件公式后,可“加载所选”修改、“删除所选”、在 OLE 与 PNG 间转换,或用“格式化所选 / 全文”应用当前默认属性。更新与转换会尽量保留对象在幻灯片中的位置和手动缩放。

    侧边栏用于新公式草稿;“加载所选”、OLE / PNG 转换和批量格式化适用于插件插入的独立对象。文本内原生公式由 PowerPoint 管理。

    @@ -164,7 +164,7 @@

    Common tasks in Word

    Insert and number

    Insert Inline places a formula within text. Insert Display gives it a separate line. Insert Numbered positions the number and enclosure according to settings. Default Numbered Formula Layout also controls chapter and section numbering, separators, and boundary visibility.

    Load, convert, and format

    -

    Copied OLE formulas remain independently editable in another document. To MathType converts an unnumbered OLE formula into a native MathType object; desktop MathType must be installed. Double-click the converted object to edit it in MathType. Its content and fonts are then managed by MathType, without a separate plugin source snapshot. You can convert multiple selected formulas; failed items remain unchanged while the rest continue, followed by a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    +

    Copied OLE formulas remain independently editable in another document. To MathType converts an unnumbered OLE formula into a native MathType object. Conversion in either direction works without MathType installed; double-click editing of native MathType objects requires MathType. The plugin source and font snapshot are not retained after conversion. You can convert multiple selected formulas; failed items remain unchanged while the rest continue, followed by a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    Select a plugin formula to Load Selected or Delete Selected. Formula Insertion offers OLE objects or native Word equations. Convert to OLE / Word Equation changes selected formulas. Format Selected / All applies the current default properties.

    Parse LaTeX in document text

    Parse Selected / All recognizes complete $...$, \(...\), $$...$$, and \[...\] expressions. Incomplete or failed conversions leave the original text in place. A valid \tag{...} can provide a custom number for a display formula.

    @@ -174,7 +174,7 @@

    Renumber and reference

    Common tasks in PowerPoint

    -

    Plugin OLE formulas remain independently editable after copying to another presentation. To MathType converts the selected plugin OLE formula into a native MathType object and requires desktop MathType. MathType then manages its content and layout; double-click to edit it in MathType. The plugin source and font snapshot are not retained. Multiple selected formulas are converted individually; failed items remain unchanged while the rest continue, with a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    +

    Plugin OLE formulas remain independently editable after copying to another presentation. To MathType converts the selected plugin OLE formula into a native MathType object. Conversion in either direction works without MathType installed; double-click editing of native MathType objects requires MathType. The plugin source and font snapshot are not retained after conversion. Multiple selected formulas are converted individually; failed items remain unchanged while the rest continue, with a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    Place the text caret inside a text box to insert a native PowerPoint equation that flows with the surrounding text and remains editable in PowerPoint. Outside text, Insert Formula adds a centered slide object using the OLE or PNG insertion setting.

    Select a plugin formula to Load Selected, Delete Selected, convert between OLE and PNG, or apply Format Selected / All. Updates and conversions preserve the slide position and manual scale where possible.

    The side pane holds a new formula draft. Load Selected, OLE / PNG conversion, and bulk formatting apply to standalone objects inserted by the plugin. PowerPoint manages equations within text.

    diff --git a/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs b/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs index 603b26d3..42330659 100644 --- a/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs +++ b/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs @@ -56,11 +56,11 @@ Task InsertManagedEquationAsync( Task> LoadSelectedFormulaTargetsAsync(CancellationToken cancellationToken); - Task ReplaceWithMathTypeAsync(WordFormulaEditTarget target, string mathMl, CancellationToken cancellationToken); + Task ReplaceWithMathTypeAsync(WordFormulaEditTarget target, byte[] compoundFile, OlePresentationResult presentation, CancellationToken cancellationToken); Task> LoadConversionEntriesAsync(bool includeMathType, CancellationToken cancellationToken); - Task ReadMathTypeMathMlAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken); + Task ReadMathTypeAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken); Task ReplaceMathTypeWithOleAsync(MathTypeFormulaTarget target, FormulaMetadata metadata, OlePresentationResult presentation, CancellationToken cancellationToken); diff --git a/office_plugin/hosts/WordAddIn/WordAddInText.cs b/office_plugin/hosts/WordAddIn/WordAddInText.cs index d95d6667..f7e523ab 100644 --- a/office_plugin/hosts/WordAddIn/WordAddInText.cs +++ b/office_plugin/hosts/WordAddIn/WordAddInText.cs @@ -48,15 +48,15 @@ private static string GetEn(string key) "ConnectButton" => "Connect", "InsertInlineTip" => "Insert an inline Word equation.", "InsertDisplayTip" => "Insert a centered display equation.", - "InsertNumberedTip" => "Insert a numbered display equation using the status pane setting.", - "ScreenshotOcrTip" => "Wait for the next LaTeXSnipper recognition result; click again to cancel.", + "InsertNumberedTip" => "Insert a numbered display equation.", + "ScreenshotOcrTip" => "Wait for screenshot OCR; click again to cancel.", "AutoNumberTip" => "Add an automatic number to the selected display formula.", "RenumberTip" => "Renumber all automatically numbered formulas in document order.", - "HelpTip" => "Show Office plugin help.", + "HelpTip" => "View plugin help.", "SettingsTip" => "Open LaTeXSnipper settings.", "ShowTaskPaneTip" => "Show the status pane.", - "LoadSelectedTip" => "Load the selected formula.", - "DeleteSelectedTip" => "Delete the selected control.", + "LoadSelectedTip" => "Load the selected formula into the editor.", + "DeleteSelectedTip" => "Delete the selected plugin formulas.", "EditorInsert" => "Insert", "EditorUpdate" => "Update", "Cancel" => "Cancel", @@ -107,22 +107,22 @@ private static string GetEn(string key) "ParsingGroup" => "Formula Parsing", "ToOleButton" => "Convert to OLE", "ToOmmlButton" => "Convert to Word", - "ToOleSelectedTip" => "Convert selected Word or MathType equations to plugin OLE objects. Failed items remain unchanged while the rest continue.", - "ToOmmlSelectedTip" => "Convert the selected managed formula to native Word OMML.", + "ToOleSelectedTip" => "Convert selected Word or MathType equations to plugin OLE formulas.", + "ToOmmlSelectedTip" => "Convert selected formulas to native Word equations.", "InsertReferenceButton" => "Insert Reference", - "InsertReferenceTip" => "Insert a placeholder, then click a numbered formula to create the cross-reference.", + "InsertReferenceTip" => "Insert a placeholder, then click a numbered formula.", "InsertChapterBoundaryButton" => "Chapter Separator", "InsertSectionBoundaryButton" => "Section Boundary", "InsertChapterBoundaryTip" => "Mark the start of a chapter for automatic numbering.", "InsertSectionBoundaryTip" => "Mark the start of a section for automatic numbering.", "FormatSelectedButton" => "Format Selected", "FormatAllButton" => "Format All", - "FormatSelectedTip" => "Reset selected formulas to their default font, color, and natural size.", - "FormatAllTip" => "Apply the current default font and color to all plugin formulas and restore their natural size.", + "FormatSelectedTip" => "Reset selected formulas to the default style and natural size.", + "FormatAllTip" => "Reset all plugin formulas to the default style and natural size.", "ParseSelectedButton" => "Parse Selected", "ParseAllButton" => "Parse All", - "ParseSelectedTip" => "Convert delimited LaTeX in the selection to managed formulas.", - "ParseAllTip" => "Convert delimited LaTeX in the document body and tables to managed formulas.", + "ParseSelectedTip" => "Convert delimited LaTeX in the selection to formulas.", + "ParseAllTip" => "Convert delimited LaTeX in the document body and tables to formulas.", "ReferencePlaceholderText" => "[Select a formula]", "ReferencePlaceholderStatus" => "Select a numbered formula to complete the reference.", "ReferenceInsertedStatus" => "Inserted formula reference.", @@ -179,15 +179,15 @@ private static string GetZh(string key) "ConnectButton" => "连接", "InsertInlineTip" => "插入行内公式。", "InsertDisplayTip" => "插入行间公式。", - "InsertNumberedTip" => "按状态窗格编号设置插入带编号公式。", - "ScreenshotOcrTip" => "等待 LaTeXSnipper 的下一次识别结果;再次点击可取消。", + "InsertNumberedTip" => "插入带编号的行间公式。", + "ScreenshotOcrTip" => "等待截图识别结果,再次点击取消。", "AutoNumberTip" => "为选中的行间公式添加自动编号。", "RenumberTip" => "重排所有自动编号的公式。", - "HelpTip" => "显示插件帮助。", + "HelpTip" => "查看插件帮助。", "SettingsTip" => "打开 LaTeXSnipper 设置。", "ShowTaskPaneTip" => "显示状态窗格。", - "LoadSelectedTip" => "加载选中的公式。", - "DeleteSelectedTip" => "删除所选控件。", + "LoadSelectedTip" => "将所选公式加载到编辑器。", + "DeleteSelectedTip" => "删除所选插件公式。", "EditorInsert" => "插入", "EditorUpdate" => "更新", "Cancel" => "取消", @@ -238,22 +238,22 @@ private static string GetZh(string key) "ParsingGroup" => "公式解析", "ToOleButton" => "转为 OLE", "ToOmmlButton" => "转为 Word", - "ToOleSelectedTip" => "将所选 Word 或 MathType 公式转换为插件 OLE 对象。支持多选;失败项保留并继续处理其余公式。", - "ToOmmlSelectedTip" => "将所选受管公式转换为 Word 原生 OMML。", + "ToOleSelectedTip" => "将所选 Word 或 MathType 公式转为插件 OLE 公式。", + "ToOmmlSelectedTip" => "将所选公式转为 Word 原生公式。", "InsertReferenceButton" => "插入引用", - "InsertReferenceTip" => "插入占位符后,单击带编号公式即可创建交叉引用。", + "InsertReferenceTip" => "插入占位符,再单击带编号公式。", "InsertChapterBoundaryButton" => "章分隔符", "InsertSectionBoundaryButton" => "节分隔符", "InsertChapterBoundaryTip" => "标记自动编号的新章节起点。", "InsertSectionBoundaryTip" => "标记自动编号的新节起点。", "FormatSelectedButton" => "格式化所选", "FormatAllButton" => "格式化全文", - "FormatSelectedTip" => "将所选公式恢复为默认字体、颜色和自然大小。", - "FormatAllTip" => "将全文插件公式应用当前默认字体和颜色,并恢复自然大小。", + "FormatSelectedTip" => "恢复所选公式的默认样式和自然大小。", + "FormatAllTip" => "恢复全文插件公式的默认样式和自然大小。", "ParseSelectedButton" => "解析所选", "ParseAllButton" => "解析全文", - "ParseSelectedTip" => "将选区内带定界符的 LaTeX 转换为受管公式。", - "ParseAllTip" => "将正文及表格中带定界符的 LaTeX 转换为受管公式。", + "ParseSelectedTip" => "将选区内带定界符的 LaTeX 转为公式。", + "ParseAllTip" => "将正文及表格中带定界符的 LaTeX 转为公式。", "ReferencePlaceholderText" => "[请选择公式]", "ReferencePlaceholderStatus" => "请选择一个带编号公式以完成引用。", "ReferenceInsertedStatus" => "已插入公式引用。", diff --git a/office_plugin/hosts/WordAddIn/WordPluginController.DocumentCommands.cs b/office_plugin/hosts/WordAddIn/WordPluginController.DocumentCommands.cs index 20b1fc97..832b08fe 100644 --- a/office_plugin/hosts/WordAddIn/WordPluginController.DocumentCommands.cs +++ b/office_plugin/hosts/WordAddIn/WordPluginController.DocumentCommands.cs @@ -35,7 +35,10 @@ public async Task ConvertSelectedToMathTypeAsync(CancellationToken cancellationT } string mathMl = await _mathJaxRenderer.ConvertTypographyToMathMlAsync(target.Metadata.Latex, target.Metadata.DisplayMode, target.Metadata.Typography, cancellationToken); - await _wordAdapter.ReplaceWithMathTypeAsync(target, mathMl, cancellationToken); + byte[] native = MathTypeNativeEquation.Create(mathMl, target.Metadata.Typography.FontSizePoints); + byte[] compoundFile = MathTypeCompoundFile.Create(native); + OlePresentationResult presentation = await RenderOlePresentationAsync(target.Metadata, target.Metadata.Latex, cancellationToken); + await _wordAdapter.ReplaceWithMathTypeAsync(target, compoundFile, presentation, cancellationToken); converted++; } catch (OperationCanceledException) { throw; } @@ -144,11 +147,11 @@ void RecordFailure(int start, string stage, Exception exception) { if (entry.MathTypeTarget is MathTypeFormulaTarget mathType) { - string mathMl = await _wordAdapter.ReadMathTypeMathMlAsync(mathType, cancellationToken); + MathTypeFormulaContent content = await _wordAdapter.ReadMathTypeAsync(mathType, cancellationToken); var imported = new FormulaMetadata( new FormulaIdentity(mathType.DocumentId, Guid.NewGuid().ToString("N")), - mathMl, FormulaDisplayMode.Inline, NumberingMode.None, string.Empty, - RenderEngineKind.MathJaxSvg, FormulaMetadata.CurrentSchemaVersion, _settingsLoader().Typography); + content.MathMl, FormulaDisplayMode.Inline, NumberingMode.None, string.Empty, + RenderEngineKind.MathJaxSvg, FormulaMetadata.CurrentSchemaVersion, _settingsLoader().Typography.WithFontSize(content.FontSizePoints)); PreparedWordFormula importedPrepared = await PrepareRenderedFormulaAsync(imported, includeEquationOoxml: false, cancellationToken, FormulaInsertionBackend.Ole, reportProgress: false); preparedBatch.Add((entry, importedPrepared)); diff --git a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe index 82144180..ebe2d6e4 100644 --- a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe +++ b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:290609b1f2352c7bba1d9180a6962210ae4f7d37059f927cb8eabe651260afbf -size 7528835 +oid sha256:087ce22279a4f3a357d31dacca3e628f86c4af3e655e38ce7fc617f5559e0416 +size 7570398 diff --git a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 index 68df2bcb..59a3e6d9 100644 --- a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 +++ b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 @@ -1 +1 @@ -290609b1f2352c7bba1d9180a6962210ae4f7d37059f927cb8eabe651260afbf LaTeXSnipperOffice_3.1.0_amd64.exe +087ce22279a4f3a357d31dacca3e628f86c4af3e655e38ce7fc617f5559e0416 LaTeXSnipperOffice_3.1.0_amd64.exe diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/MathTypeFormulaContent.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/MathTypeFormulaContent.cs new file mode 100644 index 00000000..5be19d2b --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/MathTypeFormulaContent.cs @@ -0,0 +1,13 @@ +namespace LaTeXSnipper.OfficePlugin.Abstractions; + +public sealed class MathTypeFormulaContent +{ + public MathTypeFormulaContent(string mathMl, double fontSizePoints) + { + MathMl = mathMl; + FontSizePoints = FormulaFontSize.Validate(fontSizePoints); + } + + public string MathMl { get; } + public double FontSizePoints { get; } +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/MathTypeText.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/MathTypeText.cs index 2481079a..31deb239 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/MathTypeText.cs +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Abstractions/MathTypeText.cs @@ -11,8 +11,8 @@ public static string Get(string key) { "ToMathTypeButton" => zh ? "转为 MathType" : "To MathType", "ToMathTypeTip" => zh - ? "将选区中的普通 OLE 公式逐项转换为 MathType 原生公式,需要安装 MathType。失败项保留原公式并继续处理其余公式。转换后由 MathType 独立排版和编辑,不再保留 LaTeXSnipper 源码与字体快照。" - : "Convert selected unnumbered OLE formulas to native MathType equations. MathType must be installed. Failed items are preserved and remaining items continue. MathType owns layout and editing after conversion; the LaTeXSnipper source and font snapshot are not retained.", + ? "将所选 OLE 公式转为 MathType 公式。" + : "Convert selected OLE formulas to MathType equations.", "MathTypeOleRequired" => zh ? "请先选择一个 LaTeXSnipper OLE 公式。" : "Select a LaTeXSnipper OLE formula first.", "MathTypeNumberedUnsupported" => zh ? "带编号公式暂不支持转为 MathType;请选择普通 OLE 公式。" : "MathType conversion currently requires an unnumbered OLE formula.", "MathTypeNativeRequired" => zh ? "宿主未创建 MathType 原生公式,已保留原公式。" : "The host did not create a native MathType equation. The original formula was preserved.", diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/LaTeXSnipper.OfficePlugin.Rendering.csproj b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/LaTeXSnipper.OfficePlugin.Rendering.csproj index 21ba85ce..64326418 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/LaTeXSnipper.OfficePlugin.Rendering.csproj +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/LaTeXSnipper.OfficePlugin.Rendering.csproj @@ -8,6 +8,7 @@ + diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeCompoundFile.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeCompoundFile.cs new file mode 100644 index 00000000..81b88eed --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeCompoundFile.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +public static class MathTypeCompoundFile +{ + private const int StgmRead = 0; + private const int StgmReadWrite = 2; + private const int StgmShareExclusive = 16; + private const int StgmCreate = 4096; + private const int StatFlagNoName = 1; + private static readonly Guid EquationClsid = new("0002CE03-0000-0000-C000-000000000046"); + + public static byte[] Create(byte[] native) + { + _ = MathTypeNativeEquation.ReadMathMl(native); + string path = Path.Combine(Path.GetTempPath(), "latexsnipper-mathtype-" + Guid.NewGuid().ToString("N") + ".ole"); + IStorageNative? storage = null; + try + { + Marshal.ThrowExceptionForHR(StgCreateDocfile(path, StgmCreate | StgmReadWrite | StgmShareExclusive, 0, out storage)); + var clsid = EquationClsid; + storage.SetClass(ref clsid); + uint format = RegisterClipboardFormatW("MathType EF"); + if (format == 0) throw new IOException("Could not register MathType EF."); + Marshal.ThrowExceptionForHR(WriteFmtUserTypeStg(storage, checked((ushort)format), "MathType 7.0 Equation")); + CreateAndWriteStream(storage, "\u0001Ole", new byte[] { 1,0,0,2,8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 }); + CreateAndWriteStream(storage, "\u0003ObjInfo", new byte[] { 0,0,3,0,4,0 }); + CreateAndWriteStream(storage, "Equation Native", native); + storage.Commit(0); + Release(storage); + storage = null; + byte[] result = File.ReadAllBytes(path); + _ = MathTypeNativeEquation.ReadMathMl(ReadNative(result)); + return result; + } + finally { Release(storage); File.Delete(path); } + } + + public static byte[] ReadNative(byte[] compoundFile) + { + if (compoundFile == null || compoundFile.Length < 512 || compoundFile.Length > 64 * 1024 * 1024 + || BitConverter.ToUInt64(compoundFile, 0) != 0xE11AB1A1E011CFD0UL) + throw new InvalidDataException("Invalid MathType compound file."); + string path = Path.Combine(Path.GetTempPath(), "latexsnipper-mathtype-" + Guid.NewGuid().ToString("N") + ".ole"); + IStorageNative? storage = null; + try + { + File.WriteAllBytes(path, compoundFile); + Marshal.ThrowExceptionForHR(StgOpenStorage(path, IntPtr.Zero, StgmRead | StgmShareExclusive, IntPtr.Zero, 0, out storage)); + storage.Stat(out var stat, StatFlagNoName); + if (stat.clsid != EquationClsid) throw new InvalidDataException("The compound file is not a MathType equation."); + return ReadStream(storage, "Equation Native"); + } + finally { Release(storage); File.Delete(path); } + } + + private static void Release(object? value) + { + if (value != null && Marshal.IsComObject(value)) Marshal.ReleaseComObject(value); + } + + private static byte[] ReadStream(IStorageNative storage, string name) + { + storage.OpenStream( + name, + IntPtr.Zero, + StgmRead | StgmShareExclusive, + 0, + out var stream); + try + { + stream.Stat(out var stat, StatFlagNoName); + if (stat.cbSize < 0 || stat.cbSize > 64L * 1024 * 1024) + throw new InvalidDataException( + $"Unexpected MathType storage stream '{name}' length: {stat.cbSize}."); + var bytes = new byte[(int)stat.cbSize]; + stream.Seek(0, 0, IntPtr.Zero); + var readPointer = Marshal.AllocHGlobal(sizeof(int)); + try + { + stream.Read(bytes, bytes.Length, readPointer); + var read = Marshal.ReadInt32(readPointer); + if (read != bytes.Length) + throw new EndOfStreamException( + $"MathType stream '{name}' expected {bytes.Length} bytes, read {read}."); + } + finally { Marshal.FreeHGlobal(readPointer); } + return bytes; + } + finally { Release(stream); } + } + + + private static void CreateAndWriteStream( + IStorageNative storage, + string name, + byte[] data) + { + storage.CreateStream( + name, + StgmCreate | StgmReadWrite | StgmShareExclusive, + 0, + 0, + out var stream); + try + { + stream.SetSize(data.LongLength); + stream.Seek(0, 0, IntPtr.Zero); + var writtenPointer = Marshal.AllocHGlobal(sizeof(int)); + try + { + stream.Write(data, data.Length, writtenPointer); + var written = Marshal.ReadInt32(writtenPointer); + if (written != data.Length) + throw new IOException( + $"MathType stream '{name}' expected to write {data.Length} bytes, wrote {written}."); + } + finally { Marshal.FreeHGlobal(writtenPointer); } + stream.Commit(0); + } + finally { Release(stream); } + } + + + [ComImport] + [Guid("0000000B-0000-0000-C000-000000000046")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IStorageNative + { + void CreateStream(string name, int mode, int reserved1, int reserved2, out IStream stream); + void OpenStream(string name, IntPtr reserved1, int mode, int reserved2, out IStream stream); + void CreateStorage(string name, int mode, int reserved1, int reserved2, out IStorageNative storage); + void OpenStorage(string name, IntPtr priority, int mode, IntPtr exclude, int reserved, out IStorageNative storage); + void CopyTo(int ciidExclude, IntPtr rgiidExclude, IntPtr snbExclude, IStorageNative destination); + void MoveElementTo(string name, IStorageNative destination, string newName, int flags); + void Commit(int flags); + void Revert(); + void EnumElements(int reserved1, IntPtr reserved2, int reserved3, out object enumerator); + void DestroyElement(string name); + void RenameElement(string oldName, string newName); + void SetElementTimes(string name, IntPtr creation, IntPtr access, IntPtr modification); + void SetClass(ref Guid clsid); + void SetStateBits(int stateBits, int mask); + void Stat(out System.Runtime.InteropServices.ComTypes.STATSTG stat, int flags); + } + + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern uint RegisterClipboardFormatW(string format); + + + [DllImport("ole32.dll", CharSet = CharSet.Unicode)] + private static extern int WriteFmtUserTypeStg( + IStorageNative storage, + ushort clipboardFormat, + string userType); + + + [DllImport("ole32.dll", CharSet = CharSet.Unicode)] + private static extern int StgCreateDocfile( + string name, + int mode, + int reserved, + out IStorageNative storage); + + + [DllImport("ole32.dll", CharSet = CharSet.Unicode)] + private static extern int StgOpenStorage( + string name, + IntPtr priority, + int mode, + IntPtr exclude, + int reserved, + out IStorageNative storage); + +} \ No newline at end of file diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMathMl.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMathMl.cs deleted file mode 100644 index 316b93d2..00000000 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMathMl.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Linq; -using System.Xml.Linq; - -namespace LaTeXSnipper.OfficePlugin.Rendering; - -public static class MathTypeMathMl -{ - public static string PrepareForImport(string mathMl) - { - XNamespace ns = "http://www.w3.org/1998/Math/MathML"; - var root = XDocument.Parse(mathMl).Root; - if (root?.Name != ns + "math") - throw new ArgumentException("MathType 转换需要完整的 MathML 公式。", nameof(mathMl)); - // MathJax emits an empty closing operator for one-sided fences (cases, - // \left...\right.). MathType rejects that operator with SDK -9999. - root.Descendants(ns + "mo").Where(node => !node.HasElements && string.IsNullOrWhiteSpace(node.Value)).Remove(); - return root.ToString(SaveOptions.DisableFormatting); - } -} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Color.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Color.cs new file mode 100644 index 00000000..1ae4833f --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Color.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Xml.Linq; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +internal static partial class MathTypeMtefCodec +{ + private sealed class ColorState { public int Current; } + private static readonly ConditionalWeakTable, ColorState> ColorStates = new(); + + private static void PrepareColors(XElement math, List definitions) + { + var palette = new List { "#000000" }; + foreach (var element in math.DescendantsAndSelf()) + { + string? color = element.AncestorsAndSelf().Select(e => (string?)e.Attribute("mathcolor")).FirstOrDefault(c => c != null); + if (color == null) continue; + color = NormalizeColor(color); + int index = palette.IndexOf(color); + if (index < 0) + { + index = palette.Count; + palette.Add(color); + definitions.Add(RecordColorDef); + definitions.Add(0); + for (int component = 0; component < 3; component++) + { + int rgb = int.Parse(color.Substring(1 + component * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + ushort value = (ushort)Math.Round(rgb * 1000d / 255); + definitions.Add((byte)value); + definitions.Add((byte)(value >> 8)); + } + } + element.SetAttributeValue("data-mtef-color", index + 1); + } + } + + private static string NormalizeColor(string color) + { + var named = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["black"] = "#000000", ["white"] = "#ffffff", ["red"] = "#ff0000", ["green"] = "#008000", + ["blue"] = "#0000ff", ["yellow"] = "#ffff00", ["cyan"] = "#00ffff", ["magenta"] = "#ff00ff" + }; + color = color.Trim(); + if (named.TryGetValue(color, out string? hex)) return hex; + if (color.Length == 4 && color[0] == '#') color = "#" + string.Concat(color.Skip(1).Select(c => new string(c, 2))); + if (color.Length != 7 || color[0] != '#' || !uint.TryParse(color.Substring(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _)) + throw new InvalidDataException("Unsupported MathML color: " + color); + return color.ToLowerInvariant(); + } + + private static void EmitNode(XNode node, List output) + { + var state = ColorStates.GetOrCreateValue(output); + int previous = state.Current; + if (node is XElement element && int.TryParse((string?)element.Attribute("data-mtef-color"), out int color)) state.Current = color; + output.Add(RecordColor); + WriteUnsigned(output, state.Current); + try { EmitNodeCore(node, output); } + finally + { + state.Current = previous; + output.Add(RecordColor); + WriteUnsigned(output, previous); + } + } + + private sealed partial class MtefStructureReader + { + private readonly List _colors = new() { "#000000" }; + private int _currentColor; + + private void ReadColorDefinition(int start) + { + int options = _data[start + 1]; + int count = (options & 1) == 0 ? 3 : 4; + Require(_data, start + 2, count * 2); + var values = Enumerable.Range(0, count).Select(i => BitConverter.ToUInt16(_data, start + 2 + i * 2) / 1000d).ToArray(); + if (values.Any(v => v > 1)) throw new InvalidDataException("Invalid MTEF color component at " + start); + var rgb = count == 3 ? values : values.Take(3).Select(v => (1 - v) * (1 - values[3])).ToArray(); + _colors.Add("#" + string.Concat(rgb.Select(v => ((byte)Math.Round(v * 255)).ToString("x2", CultureInfo.InvariantCulture)))); + _position = SkipColorDefinition(_data, start); + } + + private void ApplyCurrentColor(XElement node) => ApplyColor(node, _currentColor); + + private void ApplyColor(XElement node, int index) + { + if (index < 0 || index >= _colors.Count) throw new InvalidDataException("Undefined MTEF color index " + index); + if (index != 0) node.SetAttributeValue("mathcolor", _colors[index]); + } + } +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.FontSize.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.FontSize.cs new file mode 100644 index 00000000..901a73b7 --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.FontSize.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Globalization; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +internal static partial class MathTypeMtefCodec +{ + // MathType MTEF v5 EQN_PREFS stores logical sizes as packed decimal + // dimensions. Full is element zero; the other sizes, spacing, fonts and + // explicit SIZE records remain native MathType preferences. + // https://docs.wiris.com/en_US/mathtype-mtef-v5-mathtype-40-and-later + internal static byte[] CreateEquationNativeAtFontSize(string mathMl, double fontSizePt) + => SetFullFontSize(CreateEquationNative(mathMl), fontSizePt); + + internal static double ReadEquationNativeFullFontSize(byte[] equationNative) + { + var mtef = ExtractNativeMtef(equationNative); + var preferences = ReadPrefixLayout(mtef).Preferences; + if (preferences < 0) throw new InvalidDataException("MathType equation preferences are missing."); + var sizes = ReadSizeDimensions(mtef, preferences + 2, out _); + if (sizes.Count == 0) throw new InvalidDataException("MathType Full size is not recorded."); + var full = sizes[0]; + if (!double.TryParse(full.Value, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, out var value)) + throw new InvalidDataException("MathType Full size is not a valid decimal dimension."); + var points = full.Units switch + { + 0 => value * 72d, + 1 => value * 72d / 2.54d, + 2 => value, + 3 => value * 12d, + _ => throw new InvalidDataException("MathType Full size needs an absolute dimension."), + }; + if (points <= 0 || double.IsNaN(points) || double.IsInfinity(points)) + throw new InvalidDataException("MathType Full size must be a positive finite dimension."); + return points; + } + + private static byte[] SetFullFontSize(byte[] source, double fontSizePt) + { + if (fontSizePt <= 0 || fontSizePt > 200 || double.IsNaN(fontSizePt) || double.IsInfinity(fontSizePt)) + throw new ArgumentOutOfRangeException(nameof(fontSizePt)); + if (Math.Abs(ReadEquationNativeFullFontSize(source) - fontSizePt) < 0.000001d) + return source; + var sourceMtef = ExtractNativeMtef(source); + var preferences = ReadPrefixLayout(sourceMtef).Preferences; + var arrayStart = preferences + 2; + var sizes = ReadSizeDimensions(sourceMtef, arrayStart, out var arrayEnd); + if (sizes.Count == 0) throw new InvalidDataException("MathType Full size is not recorded."); + sizes[0] = (2, fontSizePt.ToString("0.########", CultureInfo.InvariantCulture)); + var packed = WriteSizeDimensions(sizes); + var mtef = new byte[sourceMtef.Length - (arrayEnd - arrayStart) + packed.Length]; + Buffer.BlockCopy(sourceMtef, 0, mtef, 0, arrayStart); + Buffer.BlockCopy(packed, 0, mtef, arrayStart, packed.Length); + Buffer.BlockCopy(sourceMtef, arrayEnd, mtef, arrayStart + packed.Length, sourceMtef.Length - arrayEnd); + var native = new byte[28 + mtef.Length]; + Buffer.BlockCopy(source, 0, native, 0, 28); + Buffer.BlockCopy(BitConverter.GetBytes((uint)mtef.Length), 0, native, 8, sizeof(uint)); + Buffer.BlockCopy(mtef, 0, native, 28, mtef.Length); + return native; + } + + private static List<(byte Units, string Value)> ReadSizeDimensions(byte[] data, int start, out int end) + { + Require(data, start, 1); + var count = data[start]; + var nibble = (start + 1) * 2; + byte Next() + { + Require(data, nibble / 2, 1); + var value = (byte)((nibble % 2 == 0 ? data[nibble / 2] >> 4 : data[nibble / 2]) & 15); + nibble++; + return value; + } + var result = new List<(byte Units, string Value)>(count); + for (var index = 0; index < count; index++) + { + var units = Next(); + if (units > 4) throw new InvalidDataException("Unsupported MathType size dimension unit."); + var text = new System.Text.StringBuilder(); + for (var digit = Next(); digit != 15; digit = Next()) + { + if (digit > 11) throw new InvalidDataException("Invalid MathType size dimension digit."); + text.Append(digit <= 9 ? (char)('0' + digit) : digit == 10 ? '.' : '-'); + } + result.Add((units, text.ToString())); + } + end = (nibble + 1) / 2; + return result; + } + + private static byte[] WriteSizeDimensions(IReadOnlyList<(byte Units, string Value)> sizes) + { + var nibbles = new List(); + foreach (var size in sizes) + { + nibbles.Add(size.Units); + foreach (var character in size.Value) + nibbles.Add(character is >= '0' and <= '9' ? (byte)(character - '0') + : character == '.' ? (byte)10 : character == '-' ? (byte)11 + : throw new InvalidDataException("Invalid MathType size dimension character.")); + nibbles.Add(15); + } + var bytes = new byte[1 + (nibbles.Count + 1) / 2]; + bytes[0] = checked((byte)sizes.Count); + for (var index = 0; index < nibbles.Count; index++) + bytes[1 + index / 2] |= (byte)(index % 2 == 0 ? nibbles[index] << 4 : nibbles[index]); + return bytes; + } +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Glyphs.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Glyphs.cs new file mode 100644 index 00000000..eb174794 --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Glyphs.cs @@ -0,0 +1,939 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Globalization; +using System.Text; +using System.Xml.Linq; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +internal static partial class MathTypeMtefCodec +{ + + private static void EmitIdentifier(XElement element, List output) + { + var value = element.Value; + if (string.IsNullOrEmpty(value)) return; + var scalars = EnumerateBmpScalars(value).ToArray(); + var variant = ((string?)element.Attribute("mathvariant") ?? string.Empty) + .Trim() + .ToLowerInvariant(); + var isFunction = scalars.Length > 1 && scalars.All(IsLetterScalar); + var explicitTypeface = int.TryParse( + (string?)element.Attribute("data-mtef-explicit-typeface"), + out var parsedExplicitTypeface) + ? parsedExplicitTypeface + : (int?)null; + foreach (var originalScalar in scalars) + { + // Word's OMML reverse transform represents a single prime in a + // superscript as ASCII apostrophe, while MathJax/MathType use the + // mathematical prime U+2032. Normalize before typeface/glyph lookup + // so OMML→MathType writes MathType's native Symbol prime rather than + // an italic variable apostrophe. + var scalar = originalScalar == '\'' ? 0x2032 : originalScalar; + var effectiveVariant = variant; + if (string.IsNullOrWhiteSpace(effectiveVariant) + && TryNormalizeLetterlikeForWrite( + originalScalar, + out var normalizedScalar, + out var normalizedVariant)) + { + scalar = normalizedScalar; + effectiveVariant = normalizedVariant; + } + + if (scalar == 0x210F) + { + // Genuine MathType 7 persists \hbar / U+210F as an MT Extra + // character, with the legacy 8-bit position 'h'. Writing the + // same MTCode using Variable/Text typeface leaves MathType with + // no glyph in that font and it renders an unknown-symbol box. + // Keep this before mathvariant handling: MathJax commonly marks + // the hbar as normal/upright, but MathType's native MTEF + // representation is still fnMTEXTRA + encoded8 0x68. + EmitScalar( + 0x210F, + TypefaceMtExtra, + output, + includeEncoded8: true, + encoded8Override: 0x68); + continue; + } + + if (effectiveVariant.Contains("double-struck") + && TryMtExtraDoubleStruck(scalar, out var mtExtraCode, out var mtExtraPosition)) + { + // MathType 7 stores the five standard blackboard-bold set letters + // in its built-in Extra Math style (fnMTEXTRA), not as an explicit + // Euclid Math Two font. This exactly matches native MathType MTEF. + EmitScalar( + mtExtraCode, + TypefaceMtExtra, + output, + includeEncoded8: true, + encoded8Override: mtExtraPosition); + continue; + } + + if (explicitTypeface is null + && !effectiveVariant.Contains("bold") + && !effectiveVariant.Contains("double-struck") + && !effectiveVariant.Contains("script") + && !effectiveVariant.Contains("fraktur") + && TryResolveMathTypeGlyph( + scalar, + out var nativeMtCode, + out var nativeTypeface, + out var nativeEncoded8)) + { + EmitScalar( + nativeMtCode, + nativeTypeface, + output, + includeEncoded8: true, + encoded8Override: nativeEncoded8); + continue; + } + + if (explicitTypeface is null + && IsMathematicalSymbolScalar(scalar)) + { + // MathType's built-in Symbol style is an 8-bit Adobe Symbol + // encoding, not a generic Unicode font. If a mathematical + // symbol has no legacy Symbol position, keep its Unicode MTCode + // in the text font instead of emitting a non-existent Symbol + // glyph (which MathType renders as '?'/unknown-symbol). + EmitScalar( + NormalizeMathTypeMtCode(scalar), + TypefaceText, + output); + continue; + } + + var typeface = explicitTypeface + ?? ResolveTokenTypeface(element, scalar, effectiveVariant, isFunction); + var dotlessJMtCode = 0; + byte dotlessJEncoded8 = 0; + var hasDotlessJ = explicitTypeface is < 0 + && TryEuclidMathOneDotlessJ( + scalar, + out dotlessJMtCode, + out dotlessJEncoded8); + var mtCode = hasDotlessJ + ? dotlessJMtCode + : explicitTypeface is < 0 + ? ExplicitVariantMtCode(effectiveVariant, scalar) + : effectiveVariant.Contains("double-struck") + && TryStandardDoubleStruckMtCode(scalar, out var standardDoubleStruck) + ? standardDoubleStruck + : scalar; + var explicitEncoded8 = hasDotlessJ + ? (byte?)dotlessJEncoded8 + : explicitTypeface is < 0 + && TryEuclidMathOneGreekVariantEncoded8(scalar, out var greekVariantEncoded8) + ? (byte?)greekVariantEncoded8 + : explicitTypeface is < 0 && scalar <= 0xFF + ? (byte?)scalar + : null; + EmitScalar( + mtCode, + typeface, + output, + includeEncoded8: explicitEncoded8 is not null, + encoded8Override: explicitEncoded8); + } + } + + + private static bool TryNormalizeLetterlikeForWrite( + int scalar, + out int normalizedScalar, + out string mathVariant) + { + switch (scalar) + { + case 0x2102: normalizedScalar = 'C'; mathVariant = "double-struck"; return true; + case 0x210B: normalizedScalar = 'H'; mathVariant = "script"; return true; + case 0x210C: normalizedScalar = 'H'; mathVariant = "fraktur"; return true; + case 0x210D: normalizedScalar = 'H'; mathVariant = "double-struck"; return true; + case 0x2110: normalizedScalar = 'I'; mathVariant = "script"; return true; + case 0x2112: normalizedScalar = 'L'; mathVariant = "script"; return true; + case 0x2115: normalizedScalar = 'N'; mathVariant = "double-struck"; return true; + case 0x2119: normalizedScalar = 'P'; mathVariant = "double-struck"; return true; + case 0x211A: normalizedScalar = 'Q'; mathVariant = "double-struck"; return true; + case 0x211B: normalizedScalar = 'R'; mathVariant = "script"; return true; + case 0x211D: normalizedScalar = 'R'; mathVariant = "double-struck"; return true; + case 0x2124: normalizedScalar = 'Z'; mathVariant = "double-struck"; return true; + case 0x212C: normalizedScalar = 'B'; mathVariant = "script"; return true; + case 0x212D: normalizedScalar = 'C'; mathVariant = "fraktur"; return true; + case 0x212F: normalizedScalar = 'e'; mathVariant = "script"; return true; + case 0x2130: normalizedScalar = 'E'; mathVariant = "script"; return true; + case 0x2131: normalizedScalar = 'F'; mathVariant = "script"; return true; + case 0x2133: normalizedScalar = 'M'; mathVariant = "script"; return true; + case 0x2134: normalizedScalar = 'o'; mathVariant = "script"; return true; + default: + normalizedScalar = scalar; + mathVariant = string.Empty; + return false; + } + } + + + private static bool TryEuclidMathOneGreekVariantEncoded8( + int scalar, + out byte encoded8) + { + switch (scalar) + { + case 0x03F1: encoded8 = 0xF1; return true; // \varrho + case 0x03F5: encoded8 = 0xF2; return true; // \epsilon + case 0x03F0: encoded8 = 0xF9; return true; // \varkappa + default: + encoded8 = 0; + return false; + } + } + + + private static bool TryEuclidMathOneDotlessJ( + int scalar, + out int mtCode, + out byte encoded8) + { + if (scalar == 0x0237) // \\jmath + { + mtCode = 0xED02; + encoded8 = 0xF8; + return true; + } + mtCode = 0; + encoded8 = 0; + return false; + } + + + private static bool TryEuclidMathOneOperatorEncoded8( + int scalar, + out byte encoded8) + { + switch (scalar) + { + case 0x2216: encoded8 = 0x82; return true; // \\setminus + case 0x224D: encoded8 = 0xA9; return true; // \\asymp + case 0x22A2: encoded8 = 0x90; return true; // \\vdash + case 0x22A3: encoded8 = 0x94; return true; // \\dashv + case 0x22A8: encoded8 = 0x91; return true; // \\models + case 0x21BC: encoded8 = 0xB4; return true; // \\leftharpoonup + case 0x21C1: encoded8 = 0xB7; return true; // \\rightharpoondown + case 0x2296: encoded8 = 0x21; return true; // \\ominus + case 0x2298: encoded8 = 0x25; return true; // \\oslash + case 0x22A4: encoded8 = 0x95; return true; // \\top + case 0x22C6: encoded8 = 0xE5; return true; // \\star + case 0x2240: encoded8 = 0xAA; return true; // \\wr + default: + encoded8 = 0; + return false; + } + } + + + private static bool TryEuclidMathTwoOperatorEncoded8( + int scalar, + out byte encoded8) + { + switch (scalar) + { + // MathType's own TeX translator and Euclid Math Two font agree on + // the private MTCode pair E938/E939 at legacy positions B0/B1 for + // \preceq/\succeq. MathJax exposes the public Unicode pair + // U+2AAF/U+2AB0; accept both at the codec boundary. + case 0x2AAF: + case 0xE938: encoded8 = 0xB0; return true; // \preceq + case 0x2AB0: + case 0xE939: encoded8 = 0xB1; return true; // \succeq + case 0x227C: encoded8 = 0xB0; return true; // \preccurlyeq + case 0x227D: encoded8 = 0xB1; return true; // \succcurlyeq + case 0x228F: encoded8 = 0xF0; return true; // \\sqsubset + case 0x2291: encoded8 = 0xF4; return true; // \\sqsubseteq + case 0x2290: encoded8 = 0xF1; return true; // \\sqsupset + case 0x2292: encoded8 = 0xF5; return true; // \\sqsupseteq + case 0x228E: encoded8 = 0xE2; return true; // \\uplus + case 0x2293: encoded8 = 0xF3; return true; // \\sqcap + case 0x2294: encoded8 = 0xF2; return true; // \\sqcup + } + encoded8 = 0; + return false; + } + + + private static bool TryMtExtraDoubleStruck( + int scalar, + out int mtCode, + out byte fontPosition) + { + switch (scalar) + { + case 'R': mtCode = 0x211D; fontPosition = 0xA1; return true; + case 'Z': mtCode = 0x2124; fontPosition = 0xA2; return true; + case 'C': mtCode = 0x2102; fontPosition = 0xA3; return true; + case 'Q': mtCode = 0x211A; fontPosition = 0xA4; return true; + case 'N': mtCode = 0x2115; fontPosition = 0xA5; return true; + default: + mtCode = 0; + fontPosition = 0; + return false; + } + } + + + private static bool TryStandardDoubleStruckMtCode(int scalar, out int mtCode) + { + mtCode = scalar switch + { + 'C' => 0x2102, + 'H' => 0x210D, + 'N' => 0x2115, + 'P' => 0x2119, + 'Q' => 0x211A, + 'R' => 0x211D, + 'Z' => 0x2124, + _ => 0, + }; + return mtCode != 0; + } + + + private static int ExplicitVariantMtCode(string variant, int scalar) + { + if (variant.Contains("double-struck")) + { + // MathType's EuclidMath2 encoding uses its own BMP PUA MTCode + // range, even for the familiar Unicode letterlike symbols such as + // ℝ. These values are the mappings shipped in MathType 7's own + // AMS/Desire2Learn translators. + if (scalar is >= 'A' and <= 'Z') return 0xF080 + scalar - 'A'; + if (scalar is >= 'a' and <= 'z') return 0xF09A + scalar - 'a'; + if (scalar is >= '0' and <= '9') return 0xF0C0 + scalar - '0'; + return scalar; + } + if (variant.Contains("script")) + { + return scalar switch + { + 'B' => 0x212C, + 'E' => 0x2130, + 'F' => 0x2131, + 'H' => 0x210B, + 'I' => 0x2110, + 'L' => 0x2112, + 'M' => 0x2133, + 'R' => 0x211B, + 'e' => 0x212F, + 'g' => 0x210A, + 'o' => 0x2134, + _ => scalar, + }; + } + if (variant.Contains("fraktur")) + { + return scalar switch + { + 'C' => 0x212D, + 'H' => 0x210C, + 'I' => 0x2111, + 'R' => 0x211C, + 'Z' => 0x2128, + _ => scalar, + }; + } + return scalar; + } + + + private static int ResolveTokenTypeface( + XElement element, + int scalar, + string? normalizedVariant = null, + bool? functionHint = null) + { + var variant = normalizedVariant + ?? (((string?)element.Attribute("mathvariant") ?? string.Empty) + .Trim() + .ToLowerInvariant()); + var tokenKind = element.Name.LocalName; + + // MTEF typeface is semantic, not merely visual. In particular MathJax + // can attach mathvariant="normal" to operator tokens such as infinity, + // relations, arrows and set symbols. Treating that visual hint as Text + // changes the token to mtext on read-back (for example \infty becomes + // \text{∞}) and makes otherwise-valid MathType MTEF fail semantic + // round-trip validation. Preserve the MathML token class first; apply + // mathvariant only inside identifier-like tokens. + if (tokenKind == "mtext") return TypefaceText; + if (tokenKind == "mn") return TypefaceNumber; + if (tokenKind == "mo") + return functionHint == true ? TypefaceFunction : TypefaceSymbol; + if (IsMathematicalSymbolScalar(scalar)) return TypefaceSymbol; + + if (variant.Contains("normal") || variant.Contains("upright")) + return functionHint == true ? TypefaceFunction : TypefaceText; + if (variant.Contains("bold")) return TypefaceVector; + if (variant.Contains("italic")) + return IsLowerGreek(scalar) + ? TypefaceLowerGreek + : IsUpperGreek(scalar) + ? TypefaceUpperGreek + : TypefaceVariable; + if (functionHint == true) return TypefaceFunction; + if (IsLowerGreek(scalar)) return TypefaceLowerGreek; + if (IsUpperGreek(scalar)) return TypefaceUpperGreek; + return TypefaceVariable; + } + + + private static bool IsMathematicalSymbolScalar(int scalar) + { + if (scalar < 0 || scalar > char.MaxValue) return false; + if (scalar is 0x2020 or 0x2021 + or 0x2032 or 0x2033 or 0x2034 + // Unicode classifies floor/ceiling as opening/closing punctuation, + // not MathSymbol. MathJax emits the common shorthand + // \lfloor x\rfloor / \lceil x\rceil as standalone tokens, so + // an MTEF Text-font fallback must still read them back as operators. + // Otherwise they become mtext and semantic round-trip validation + // rejects an otherwise valid MathType equation. + or 0x2308 or 0x2309 or 0x230A or 0x230B + or 0x2329 or 0x232A + or 0x27E8 or 0x27E9) + return true; + var character = (char)scalar; + var category = char.GetUnicodeCategory(character); + return category == System.Globalization.UnicodeCategory.MathSymbol + || category == System.Globalization.UnicodeCategory.CurrencySymbol + || category == System.Globalization.UnicodeCategory.ModifierSymbol + || category == System.Globalization.UnicodeCategory.OtherSymbol; + } + + + private static string NormalizeOperatorToken(string value) => value switch + { + "〈" or "〈" or "⟨" => "⟨", + "〉" or "〉" or "⟩" => "⟩", + // MathJax uses U+25C3/U+25B9 for TeX triangleleft/triangleright, + // while MathType persists the same glyphs as U+22B2/U+22B3. + "◃" or "⊲" => "⊲", + "▹" or "⊳" => "⊳", + // MathJax emits the mathematical minus U+2212 while Word OMML commonly + // serializes the same binary subtraction operator as ASCII hyphen-minus. + // They are semantically identical in an token and must compare equal + // across LaTeXSnipper/OMML/MathType round trips. + "−" => "-", + // MathType 7 stores TeX \\sim as ASCII '~' in its Function typeface, + // while MathJax represents the same operator as U+223C. This is an + // operator-token equivalence only; accent \\tilde is represented by + // mover/template structure and never reaches this normalization. + "~" => "∼", + _ => value, + }; + + + private static int NormalizeMathTypeMtCode(int scalar) => scalar switch + { + // MathType 7/Adobe Symbol use the historical angle-bracket MTCode pair. + 0x27E8 => 0x2329, + 0x27E9 => 0x232A, + _ => scalar, + }; + + + private static bool TryResolveMathTypeGlyph( + int scalar, + out int mtCode, + out int typeface, + out byte encoded8) + { + mtCode = NormalizeMathTypeMtCode(scalar); + typeface = TypefaceSymbol; + encoded8 = 0; + + if (mtCode == 0x2223) + { + // \mid uses the ordinary vertical-bar position in Symbol while its + // MTCode remains U+2223 so semantic readback is not degraded to '|'. + encoded8 = 0x7C; + return true; + } + if (mtCode == 0x2213) + { + // Genuine MathType 7 persists \mp / MINUS-OR-PLUS in MT Extra, + // not Adobe Symbol: fnMTEXTRA + MTCode U+2213 + encoded8 0x6D. + // Falling back to Unicode text changes the glyph metrics/baseline. + typeface = TypefaceMtExtra; + encoded8 = 0x6D; + return true; + } + if (mtCode == 0x21A6) + { + // Genuine MathType 7 persists \mapsto in MT Extra: + // fnMTEXTRA + MTCode U+21A6 + encoded8 0x61. + typeface = TypefaceMtExtra; + encoded8 = 0x61; + return true; + } + if (mtCode == 0x2225) + { + // Genuine MathType 7 persists \parallel in MT Extra: + // fnMTEXTRA + MTCode U+2225 + encoded8 0x50. + typeface = TypefaceMtExtra; + encoded8 = 0x50; + return true; + } + + if (mtCode == 0x2113) + { + // Genuine MathType 7 persists \\ell as MT Extra U+2113 / 0x6C. + typeface = TypefaceMtExtra; + encoded8 = 0x6C; + return true; + } + + if (!TryGetAdobeSymbolEncoded8(mtCode, out encoded8)) return false; + if (IsLowerGreek(mtCode)) typeface = TypefaceLowerGreek; + else if (IsUpperGreek(mtCode)) typeface = TypefaceUpperGreek; + return true; + } + + + private static bool TryGetAdobeSymbolEncoded8(int scalar, out byte encoded8) + { + // Static Adobe Symbol encoding used by MathType's built-in Lower Greek, + // Upper Greek and Symbol styles. Keeping this table in LaTeXSnipper makes + // standalone MTEF generation independent of MathType/MathPage at runtime. + switch (scalar) + { + case 0x00AC: encoded8 = 0xD8; return true; + case 0x00B0: encoded8 = 0xB0; return true; + case 0x00B1: encoded8 = 0xB1; return true; + case 0x00B5: encoded8 = 0x6D; return true; + case 0x00D7: encoded8 = 0xB4; return true; + case 0x00F7: encoded8 = 0xB8; return true; + case 0x0192: encoded8 = 0xA6; return true; + case 0x0391: encoded8 = 0x41; return true; + case 0x0392: encoded8 = 0x42; return true; + case 0x0393: encoded8 = 0x47; return true; + case 0x0394: encoded8 = 0x44; return true; + case 0x0395: encoded8 = 0x45; return true; + case 0x0396: encoded8 = 0x5A; return true; + case 0x0397: encoded8 = 0x48; return true; + case 0x0398: encoded8 = 0x51; return true; + case 0x0399: encoded8 = 0x49; return true; + case 0x039A: encoded8 = 0x4B; return true; + case 0x039B: encoded8 = 0x4C; return true; + case 0x039C: encoded8 = 0x4D; return true; + case 0x039D: encoded8 = 0x4E; return true; + case 0x039E: encoded8 = 0x58; return true; + case 0x039F: encoded8 = 0x4F; return true; + case 0x03A0: encoded8 = 0x50; return true; + case 0x03A1: encoded8 = 0x52; return true; + case 0x03A3: encoded8 = 0x53; return true; + case 0x03A4: encoded8 = 0x54; return true; + case 0x03A5: encoded8 = 0x55; return true; + case 0x03A6: encoded8 = 0x46; return true; + case 0x03A7: encoded8 = 0x43; return true; + case 0x03A8: encoded8 = 0x59; return true; + case 0x03A9: encoded8 = 0x57; return true; + case 0x03B1: encoded8 = 0x61; return true; + case 0x03B2: encoded8 = 0x62; return true; + case 0x03B3: encoded8 = 0x67; return true; + case 0x03B4: encoded8 = 0x64; return true; + case 0x03B5: encoded8 = 0x65; return true; + case 0x03B6: encoded8 = 0x7A; return true; + case 0x03B7: encoded8 = 0x68; return true; + case 0x03B8: encoded8 = 0x71; return true; + case 0x03B9: encoded8 = 0x69; return true; + case 0x03BA: encoded8 = 0x6B; return true; + case 0x03BB: encoded8 = 0x6C; return true; + case 0x03BC: encoded8 = 0x6D; return true; + case 0x03BD: encoded8 = 0x6E; return true; + case 0x03BE: encoded8 = 0x78; return true; + case 0x03BF: encoded8 = 0x6F; return true; + case 0x03C0: encoded8 = 0x70; return true; + case 0x03C1: encoded8 = 0x72; return true; + case 0x03C2: encoded8 = 0x56; return true; + case 0x03C3: encoded8 = 0x73; return true; + case 0x03C4: encoded8 = 0x74; return true; + case 0x03C5: encoded8 = 0x75; return true; + case 0x03C6: encoded8 = 0x6A; return true; + case 0x03C7: encoded8 = 0x63; return true; + case 0x03C8: encoded8 = 0x79; return true; + case 0x03C9: encoded8 = 0x77; return true; + case 0x03D1: encoded8 = 0x4A; return true; + case 0x03D2: encoded8 = 0xA1; return true; + case 0x03D5: encoded8 = 0x66; return true; + case 0x03D6: encoded8 = 0x76; return true; + case 0x2022: encoded8 = 0xB7; return true; + case 0x2026: encoded8 = 0xBC; return true; + case 0x2032: encoded8 = 0xA2; return true; + case 0x2033: encoded8 = 0xB2; return true; + case 0x2044: encoded8 = 0xA4; return true; + case 0x2111: encoded8 = 0xC1; return true; + case 0x2118: encoded8 = 0xC3; return true; + case 0x211C: encoded8 = 0xC2; return true; + case 0x2126: encoded8 = 0x57; return true; + case 0x2135: encoded8 = 0xC0; return true; + case 0x2190: encoded8 = 0xAC; return true; + case 0x2191: encoded8 = 0xAD; return true; + case 0x2192: encoded8 = 0xAE; return true; + case 0x2193: encoded8 = 0xAF; return true; + case 0x2194: encoded8 = 0xAB; return true; + case 0x21B5: encoded8 = 0xBF; return true; + case 0x21D0: encoded8 = 0xDC; return true; + case 0x21D1: encoded8 = 0xDD; return true; + case 0x21D2: encoded8 = 0xDE; return true; + case 0x21D3: encoded8 = 0xDF; return true; + case 0x21D4: encoded8 = 0xDB; return true; + case 0x2200: encoded8 = 0x22; return true; + case 0x2202: encoded8 = 0xB6; return true; + case 0x2203: encoded8 = 0x24; return true; + case 0x2205: encoded8 = 0xC6; return true; + case 0x2206: encoded8 = 0x44; return true; + case 0x2207: encoded8 = 0xD1; return true; + case 0x2208: encoded8 = 0xCE; return true; + case 0x2209: encoded8 = 0xCF; return true; + case 0x220B: encoded8 = 0x27; return true; + case 0x220F: encoded8 = 0xD5; return true; + case 0x2211: encoded8 = 0xE5; return true; + case 0x2212: encoded8 = 0x2D; return true; + case 0x2217: encoded8 = 0x2A; return true; + case 0x221A: encoded8 = 0xD6; return true; + case 0x221D: encoded8 = 0xB5; return true; + case 0x221E: encoded8 = 0xA5; return true; + case 0x2220: encoded8 = 0xD0; return true; + case 0x2227: encoded8 = 0xD9; return true; + case 0x2228: encoded8 = 0xDA; return true; + case 0x2229: encoded8 = 0xC7; return true; + case 0x222A: encoded8 = 0xC8; return true; + case 0x222B: encoded8 = 0xF2; return true; + case 0x2234: encoded8 = 0x5C; return true; + case 0x223C: encoded8 = 0x7E; return true; + case 0x2245: encoded8 = 0x40; return true; + case 0x2248: encoded8 = 0xBB; return true; + case 0x2260: encoded8 = 0xB9; return true; + case 0x2261: encoded8 = 0xBA; return true; + case 0x2264: encoded8 = 0xA3; return true; + case 0x2265: encoded8 = 0xB3; return true; + case 0x2282: encoded8 = 0xCC; return true; + case 0x2283: encoded8 = 0xC9; return true; + case 0x2284: encoded8 = 0xCB; return true; + case 0x2286: encoded8 = 0xCD; return true; + case 0x2287: encoded8 = 0xCA; return true; + case 0x2295: encoded8 = 0xC5; return true; + case 0x2297: encoded8 = 0xC4; return true; + case 0x22A5: encoded8 = 0x5E; return true; + case 0x22C5: encoded8 = 0xD7; return true; + case 0x2320: encoded8 = 0xF3; return true; + case 0x2321: encoded8 = 0xF5; return true; + case 0x2329: encoded8 = 0xE1; return true; + case 0x232A: encoded8 = 0xF1; return true; + case 0x25CA: encoded8 = 0xE0; return true; + case 0x2660: encoded8 = 0xAA; return true; + case 0x2663: encoded8 = 0xA7; return true; + case 0x2665: encoded8 = 0xA9; return true; + case 0x2666: encoded8 = 0xA8; return true; + default: + encoded8 = 0; + return false; + } + } + + + private static void EmitOperator(XElement element, List output) + { + var explicitTypeface = int.TryParse( + (string?)element.Attribute("data-mtef-explicit-typeface"), + out var parsedExplicitTypeface) + ? parsedExplicitTypeface + : (int?)null; + if (explicitTypeface is null) + { + EmitOperator(element.Value, output); + return; + } + + foreach (var scalar in EnumerateBmpScalars(element.Value)) + { + if (TryEuclidMathOneOperatorEncoded8(scalar, out var encoded8) + || TryEuclidMathTwoOperatorEncoded8(scalar, out encoded8)) + { + var nativeScalar = scalar switch + { + 0x2AAF => 0xE938, // MathJax \preceq -> MathType private MTCode + 0x2AB0 => 0xE939, // MathJax \succeq -> MathType private MTCode + _ => scalar, + }; + EmitScalar( + nativeScalar, + explicitTypeface.Value, + output, + includeEncoded8: true, + encoded8Override: encoded8); + continue; + } + + EmitScalar( + scalar, + explicitTypeface.Value, + output, + includeEncoded8: scalar <= 0xFF, + encoded8Override: scalar <= 0xFF ? (byte?)scalar : null); + } + } + + + private static bool TryEmitMathTypeSpecialOperator(int scalar, List output) + { + switch (scalar) + { + case 0x2020: // \\dagger: genuine MathType uses Function typeface. + EmitScalar(0x2020, TypefaceFunction, output, includeEncoded8: false); + return true; + case 0x2021: // \\ddagger: genuine MathType uses Function typeface. + EmitScalar(0x2021, TypefaceFunction, output, includeEncoded8: false); + return true; + case 0x2218: // \\circ: MathType uses the degree glyph in Symbol. + EmitScalar(0x00B0, TypefaceSymbol, output, includeEncoded8: true, encoded8Override: 0xB0); + return true; + case 0x2022: // \\bullet: genuine MathType uses Function, no encoded8. + EmitScalar(0x2022, TypefaceFunction, output, includeEncoded8: false); + return true; + case 0x22C4: // \\diamond: Symbol position 0xE0. + EmitScalar(0x22C4, TypefaceSymbol, output, includeEncoded8: true, encoded8Override: 0xE0); + return true; + case 0x22B2: // MathType-side \\triangleleft scalar. + case 0x25C3: // MathJax-side \\triangleleft scalar. + EmitScalar(0x22B2, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x3C); + return true; + case 0x22B3: // MathType-side \\triangleright scalar. + case 0x25B9: // MathJax-side \\triangleright scalar. + EmitScalar(0x22B3, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x3E); + return true; + case 0x2661: // \\heartsuit: built-in MathType typeface 12. + EmitScalar(0x2661, TypefaceMathTypeSpecial12, output, includeEncoded8: false); + return true; + case 0x2662: // \\diamondsuit: MT Extra glyph position 0x6E. + EmitScalar(0xFFFD, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x6E); + return true; + case 0x2299: // \\odot: MT Extra position 0x65. + EmitScalar(0x2299, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x65); + return true; + case 0x25EF: // \\bigcirc: built-in typeface 12. + EmitScalar(0x25EF, TypefaceMathTypeSpecial12, output, includeEncoded8: false); + return true; + case 0x226A: // \\ll: MT Extra position 0x3D. + EmitScalar(0x226A, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x3D); + return true; + case 0x226B: // \\gg: MT Extra position 0x3F. + EmitScalar(0x226B, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x3F); + return true; + case 0x2243: // \\simeq + EmitScalar(0x2243, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x3B); + return true; + case 0x2250: // \\doteq + EmitScalar(0x2250, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x42); + return true; + case 0x227A: // \\prec + EmitScalar(0x227A, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x70); + return true; + case 0x227B: // \\succ + EmitScalar(0x227B, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x66); + return true; + case 0x2195: // \\updownarrow + EmitScalar(0x2195, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x62); + return true; + case 0x21D5: // \\Updownarrow + EmitScalar(0x21D5, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x63); + return true; + case 0x21BD: // \\leftharpoondown + EmitScalar(0x21BD, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x87); + return true; + case 0x21C0: // \\rightharpoonup + EmitScalar(0x21C0, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x86); + return true; + case 0x2197: // \\nearrow + EmitScalar(0x2197, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x5A); + return true; + case 0x2198: // \\searrow + EmitScalar(0x2198, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x5D); + return true; + case 0x2199: // \\swarrow + EmitScalar(0x2199, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x5B); + return true; + case 0x2196: // \\nwarrow + EmitScalar(0x2196, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x5E); + return true; + case 0x2A3F: // \\amalg: MathType stores ordinary amalg as MT Extra U+2210 / 0x43. + EmitScalar(0x2210, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x43); + return true; + case 0x220B: // \\ni: built-in typeface 12, no encoded8. + EmitScalar(0x220B, TypefaceMathTypeSpecial12, output, includeEncoded8: false); + return true; + case 0x22EF: // \\cdots + EmitScalar(0x22EF, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x4C); + return true; + case 0x22EE: // \\vdots + EmitScalar(0x22EE, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x4D); + return true; + case 0x22F1: // \\ddots + EmitScalar(0x22F1, TypefaceMtExtra, output, includeEncoded8: true, encoded8Override: 0x4F); + return true; + default: + return false; + } + } + + + private static void EmitOperator(string value, List output) + { + if (value == "⁡") return; + foreach (var scalar in EnumerateBmpScalars(value)) + { + if (scalar is 0x223C or '~') + { + // Genuine MathType 7 represents TeX \sim as ASCII '~' in the + // Function typeface, not U+223C in Adobe Symbol. + EmitScalar('~', TypefaceFunction, output, includeEncoded8: false); + continue; + } + if (TryEmitMathTypeSpecialOperator(scalar, output)) + continue; + if (IsWhiteSpaceScalar(scalar)) + { + EmitScalar(scalar, TypefaceSpace, output); + continue; + } + + if (TryResolveMathTypeGlyph( + scalar, + out var mtCode, + out var typeface, + out var encoded8)) + { + EmitScalar( + mtCode, + typeface, + output, + includeEncoded8: true, + encoded8Override: encoded8); + continue; + } + + if (scalar <= 0xFF) + { + EmitScalar( + scalar, + TypefaceSymbol, + output, + includeEncoded8: true, + encoded8Override: (byte)scalar); + continue; + } + + // Do not pretend every Unicode operator lives in Adobe Symbol. + // Times New Roman/MathType Text is Unicode-capable and is the safe + // fallback for symbols not present in the legacy Symbol encoding. + EmitScalar(NormalizeMathTypeMtCode(scalar), TypefaceText, output); + } + } + + + private static void EmitText(string value, int typeface, List output) + { + foreach (var scalar in EnumerateBmpScalars(value)) + { + if (IsWhiteSpaceScalar(scalar)) EmitScalar(scalar, TypefaceSpace, output); + else EmitScalar(scalar, typeface, output); + } + } + + + private static IEnumerable EnumerateBmpScalars(string value) + { + foreach (var character in value) + { + if (char.IsSurrogate(character)) + throw new InvalidDataException( + "MathType MTEF v5 writer does not yet support non-BMP Unicode characters."); + yield return character; + } + } + + + private static bool IsLetterScalar(int scalar) => + scalar <= char.MaxValue && char.IsLetter((char)scalar); + + + private static bool IsWhiteSpaceScalar(int scalar) => + scalar <= char.MaxValue && char.IsWhiteSpace((char)scalar); + + + private static void EmitCharacter(char character, int typeface, List output) => + EmitScalar(character, typeface, output); + + + private static void EmitScalar( + int scalar, + int typeface, + List output, + bool includeEncoded8 = false, + IReadOnlyList? embellishments = null, + byte? encoded8Override = null, + bool functionStart = false) + { + if (scalar < 0 || scalar > 0xFFFF) + throw new InvalidDataException( + $"MathType MTEF v5 MTCode writer does not yet support non-BMP scalar U+{scalar:X}."); + var hasEmbellishments = embellishments is { Count: > 0 }; + if (typeface == TypefaceText && scalar is >= 0x2E80 and <= 0x9FFF) + typeface = TypefaceMathTypeSpecial12; + var options = (byte)(includeEncoded8 ? CharEncoded8 : 0); + if (hasEmbellishments) options |= CharHasEmbellishment; + if (functionStart) options |= CharFunctionStart; + output.Add(RecordChar); + output.Add(options); + WriteSigned(output, typeface); + output.Add((byte)(scalar & 0xFF)); + output.Add((byte)((scalar >> 8) & 0xFF)); + if (includeEncoded8) output.Add(encoded8Override ?? (byte)scalar); + if (!hasEmbellishments) return; + foreach (var embellishment in embellishments!) + output.AddRange(new byte[] { RecordEmbellishment, 0, embellishment }); + output.Add(RecordEnd); + } + + + private static void WriteSigned(List output, int value) + { + if (value is >= -128 and < 127) + { + output.Add(unchecked((byte)(value + 128))); + return; + } + if (value < short.MinValue || value > short.MaxValue - 1) + throw new ArgumentOutOfRangeException(nameof(value)); + var raw = value + 32768; + output.Add(0xFF); + output.Add((byte)(raw & 0xFF)); + output.Add((byte)((raw >> 8) & 0xFF)); + } + + + private static bool IsLowerGreek(int scalar) => + scalar is >= 0x03B1 and <= 0x03C9 + || scalar is 0x03D1 or 0x03D5 or 0x03D6 or 0x03F1 or 0x03F5; + + + private static bool IsUpperGreek(int scalar) => scalar is >= 0x0391 and <= 0x03A9; + +} \ No newline at end of file diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.MathMl.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.MathMl.cs new file mode 100644 index 00000000..74b1ccc6 --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.MathMl.cs @@ -0,0 +1,762 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Globalization; +using System.Text; +using System.Xml.Linq; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +internal static partial class MathTypeMtefCodec +{ + + internal static string SemanticSignature(string mathMl) + { + if (string.IsNullOrWhiteSpace(mathMl)) return string.Empty; + var document = XDocument.Parse(mathMl, LoadOptions.PreserveWhitespace); + var math = document.Root?.DescendantsAndSelf() + .FirstOrDefault(element => element.Name.LocalName == "math") + ?? document.Root + ?? throw new InvalidDataException("MathML has no root element."); + var normalized = new XElement(math); + NormalizeMathTypeExportedTabPiles(normalized); + return CanonicalizeMathMl(normalized, inheritedMathVariant: null); + } + + + private static void NormalizeMathTypeExportedTabPiles(XElement root) + { + foreach (var table in root.DescendantsAndSelf() + .Where(element => element.Name.LocalName == "mtable") + .ToArray()) + { + var sourceRows = table.Elements() + .Where(row => row.Name.LocalName is "mtr" or "mlabeledtr") + .ToArray(); + if (sourceRows.Length == 0) continue; + + var allowsEmptyComTabMarkers = + IsMathTypeComExportedTabPile(table, sourceRows); + var rebuiltRows = new List(); + var sawTab = false; + var supportedShape = true; + foreach (var sourceRow in sourceRows) + { + var sourceCells = sourceRow.Elements() + .Where(cell => cell.Name.LocalName == "mtd") + .ToArray(); + if (sourceCells.Length != 1) + { + supportedShape = false; + break; + } + + if (sourceCells[0].Elements().Any(element => + element.Name.LocalName is "maligngroup" or "malignmark")) + { + var markedRow = RebuildMathTypeAlignmentMarkedRow( + sourceCells[0], + out var markedSawTab); + sawTab |= markedSawTab; + rebuiltRows.Add(markedRow); + continue; + } + + var rebuiltRow = new XElement("mtr"); + var rebuiltCell = new XElement("mtd"); + foreach (var node in sourceCells[0].Nodes()) + { + if (node is XElement marker + && IsMathTypeExportedTabMarker( + marker, + allowsEmptyComTabMarkers)) + { + sawTab = true; + NormalizeMathTypeExportedTabCell(rebuiltCell); + rebuiltRow.Add(rebuiltCell); + rebuiltCell = new XElement("mtd"); + continue; + } + if (node is XText whitespace && string.IsNullOrWhiteSpace(whitespace.Value)) + continue; + rebuiltCell.Add(CloneNode(node)); + } + NormalizeMathTypeExportedTabCell(rebuiltCell); + rebuiltRow.Add(rebuiltCell); + rebuiltRows.Add(rebuiltRow); + } + if (!supportedShape || !sawTab) continue; + + while (rebuiltRows.Count > 1 + && !MathTypeTabRowHasMeaningfulContent(rebuiltRows[rebuiltRows.Count - 1])) + rebuiltRows.RemoveAt(rebuiltRows.Count - 1); + + table.RemoveNodes(); + foreach (var row in rebuiltRows) table.Add(row); + var columnCount = rebuiltRows + .Select(row => row.Elements().Count(cell => cell.Name.LocalName == "mtd")) + .DefaultIfEmpty(0) + .Max(); + if (columnCount > 0) + { + table.SetAttributeValue( + "columnalign", + string.Join(" ", Enumerable.Range(0, columnCount) + .Select(column => (column & 1) == 0 ? "right" : "left"))); + } + } + } + + + private static XElement RebuildMathTypeAlignmentMarkedRow( + XElement sourceCell, + out bool sawTab) + { + sawTab = false; + var row = new XElement("mtr"); + var cell = new XElement("mtd"); + var emittedAnyCell = false; + + bool CellHasContent() => cell.Nodes().Any(node => + node is XElement + || node is XText text && !string.IsNullOrWhiteSpace(text.Value)); + + void EmitCell() + { + NormalizeMathTypeExportedTabCell(cell); + row.Add(cell); + cell = new XElement("mtd"); + emittedAnyCell = true; + } + + foreach (var node in sourceCell.Nodes()) + { + if (node is XText whitespace && string.IsNullOrWhiteSpace(whitespace.Value)) + continue; + if (node is XElement element) + { + if (element.Name.LocalName == "maligngroup") + continue; + if (element.Name.LocalName == "malignmark") + { + EmitCell(); + continue; + } + if (IsMathTypeExportedTabMarker(element, allowEmptyComMarker: false)) + { + sawTab = true; + // MathType emits one leading U+0009 after to + // enter the first ruler group. It is positioning metadata, + // not an empty TeX column. Later tabs terminate the current + // right-hand cell and start the next right/left pair. + if (!emittedAnyCell && !CellHasContent()) + continue; + EmitCell(); + continue; + } + } + cell.Add(CloneNode(node)); + } + EmitCell(); + return row; + } + + + private static bool IsMathTypeComExportedTabPile( + XElement table, + XElement[] rows) + { + var columnAlign = ((string?)table.Attribute("columnalign") ?? string.Empty) + .Trim(); + if (!string.Equals(columnAlign, "left", StringComparison.OrdinalIgnoreCase) + || rows.Length < 2 + || MathTypeTabRowHasMeaningfulContent(rows[rows.Length - 1])) + return false; + + var nonTrailingRows = rows.Take(rows.Length - 1).ToArray(); + if (nonTrailingRows.Any(row => row.Elements() + .Count(cell => cell.Name.LocalName == "mtd") != 1)) + return false; + return nonTrailingRows.Any(row => row.Descendants() + .Any(element => element.Name.LocalName == "mtext" + && !element.HasElements + && element.Value.Length == 0)); + } + + + private static bool IsMathTypeExportedTabMarker( + XElement element, + bool allowEmptyComMarker) + { + if (element.Name.LocalName is not ("mtext" or "mi")) return false; + var value = element.Value; + if (value.Length > 0 && value.All(character => character == '\t')) + return true; + return allowEmptyComMarker + && element.Name.LocalName == "mtext" + && !element.HasElements + && value.Length == 0; + } + + + private static bool MathTypeTabRowHasMeaningfulContent(XElement row) + { + foreach (var element in row.Descendants()) + { + if (element.Name.LocalName is "mrow" or "mstyle" or "mpadded" or "mtd") + continue; + if (!string.IsNullOrWhiteSpace(element.Value.Replace("\t", string.Empty))) + return true; + if (element.HasElements) return true; + } + return false; + } + + + private static void NormalizeMathTypeExportedTabCell(XElement cell) + { + var elements = cell.Elements().ToList(); + for (var index = 0; index < elements.Count; index++) + { + var first = elements[index]; + if (first.Name.LocalName != "mtext" || string.IsNullOrWhiteSpace(first.Value)) + continue; + var mergedText = first.Value; + var cursor = index + 1; + var consumed = new List(); + while (cursor + 1 < elements.Count + && IsMathTypeExportedTextSpace(elements[cursor]) + && elements[cursor + 1].Name.LocalName == "mtext" + && !string.IsNullOrWhiteSpace(elements[cursor + 1].Value)) + { + mergedText += " " + elements[cursor + 1].Value; + consumed.Add(elements[cursor]); + consumed.Add(elements[cursor + 1]); + cursor += 2; + } + if (consumed.Count == 0) continue; + first.Value = mergedText; + foreach (var item in consumed) item.Remove(); + elements = cell.Elements().ToList(); + } + + // MathType can terminate an ordinary text run before punctuation even + // though the source was one \text{...} node (for example it exports + // "High word." as mtext("High word") + mtext(".")). Rejoin only an + // immediately adjacent punctuation-only mtext fragment inside this + // already-confirmed tab-aligned export shape. + elements = cell.Elements().ToList(); + for (var index = 0; index + 1 < elements.Count; index++) + { + var current = elements[index]; + var next = elements[index + 1]; + if (current.Name.LocalName != "mtext" + || next.Name.LocalName != "mtext" + || current.HasElements + || next.HasElements + || next.Value.Length == 0 + || !next.Value.All(char.IsPunctuation)) + continue; + current.Value += next.Value; + next.Remove(); + elements = cell.Elements().ToList(); + index--; + } + + elements = cell.Elements().ToList(); + for (var index = 0; index < elements.Count; index++) + { + if (!IsSingleUnstyledLatinIdentifier(elements[index])) continue; + var end = index; + var name = new StringBuilder(); + while (end < elements.Count && IsSingleUnstyledLatinIdentifier(elements[end])) + { + name.Append(elements[end].Value); + end++; + } + if (name.Length < 2 + || end >= elements.Count + || elements[end].Name.LocalName != "mo" + || elements[end].Value.Trim() != "(") + { + index = Math.Max(index, end - 1); + continue; + } + + var function = new XElement( + "mi", + new XAttribute("mathvariant", "normal"), + name.ToString()); + elements[index].ReplaceWith(function); + for (var remove = index + 1; remove < end; remove++) + elements[remove].Remove(); + elements = cell.Elements().ToList(); + } + } + + + private static bool IsMathTypeExportedTextSpace(XElement element) + { + if (element.HasElements) return false; + if (element.Name.LocalName == "mi" && element.Value.Length == 0) + { + // MathType's IDataObject MathML exporter represents a word-space + // inside text in a native tab-pile as an empty rather than + // mspace/whitespace text. This helper is used only after the table + // has already been proven to be a MathType tab-pile export. + return true; + } + return element.Name.LocalName is "mi" or "mtext" + && element.Value.Length > 0 + && element.Value.All(char.IsWhiteSpace); + } + + + private static bool IsSingleUnstyledLatinIdentifier(XElement element) + { + if (element.Name.LocalName != "mi" + || element.Attribute("mathvariant") is not null) + return false; + var value = element.Value; + return value.Length == 1 && value[0] <= 0x7F && char.IsLetter(value[0]); + } + + + private static string CanonicalizeMathMl(XElement element, string? inheritedMathVariant) + { + var local = element.Name.LocalName; + var ownVariant = ((string?)element.Attribute("mathvariant"))?.Trim(); + var variant = string.IsNullOrWhiteSpace(ownVariant) ? inheritedMathVariant : ownVariant; + string Children(string? childVariant = null) => + CanonicalizeElementSequence( + element.Elements() + .Where(child => child.Name.LocalName is not ("annotation" or "annotation-xml")) + .ToArray(), + childVariant ?? variant); + var children = element.Elements() + .Where(child => child.Name.LocalName is not ("annotation" or "annotation-xml")) + .ToArray(); + switch (local) + { + case "math": + case "mpadded": + return Children(); + case "mrow": + { + if (TryCanonicalizeMathJaxFencedSuperscriptRow(element, variant, out var fencedSuperscript)) + return fencedSuperscript; + if (TryGetMathJaxFenceRow(element, out var open, out var close, out var fenceChildren)) + { + if (TryCanonicalizeMathJaxBinomialFence( + open, + close, + fenceChildren, + variant, + out var binomial)) + return binomial; + var normalizedOpen = NormalizeFence(open); + var normalizedClose = NormalizeFence(close); + var fencedChildrenSignature = + string.Concat(fenceChildren.Select(child => CanonicalizeMathMl(child, variant))); + return CanonicalFenceSignature( + normalizedOpen, + normalizedClose, + fencedChildrenSignature); + } + return Children(); + } + case "mstyle": + return Children(variant); + case "semantics": + case "maction": + return children.Length == 0 + ? string.Empty + : CanonicalizeMathMl(children[0], variant); + case "annotation": + case "annotation-xml": + case "none": + case "mspace": + return string.Empty; + case "mi": + { + var value = element.Value.Trim(); + if (value.Length == 0) return string.Empty; + var primeSignature = CanonicalPrimeSignature(value); + if (primeSignature is not null) return primeSignature; + if (value == "ℓ") + return CanonicalToken("mi", "l", "script"); + if ((value is "ℵ" or "ℜ" or "ℑ") + && string.Equals(variant, "normal", StringComparison.OrdinalIgnoreCase)) + { + // Production MathJax marks these fixed letterlike symbols as + // upright, while MathType's direct MTEF readback omits an + // explicit mathvariant. The glyph identity already fixes their + // presentation, so the attribute is semantically redundant. + return CanonicalToken("mi", value, string.Empty); + } + // MathType 7 sometimes exports standalone mathematical glyphs + // such as infinity as even though MathJax/LaTeXSnipper uses + // . That token-tag difference is not a semantic change. + // Keep mtext distinct so an actual Symbol -> Text regression is + // still rejected by the round-trip validator. + if (IsMathematicalSymbolToken(value)) + return "o(" + NormalizeOperatorToken(value) + ")"; + return CanonicalToken("mi", value, variant); + } + case "mn": + return "n(" + element.Value.Trim() + ")"; + case "mo": + { + var value = element.Value.Trim(); + // MathJax inserts U+2061 FUNCTION APPLICATION between a named + // function and its argument. It has no visible glyph and should + // never become a literal MathType character or a fake '?'. + if (value == "⁡") return string.Empty; + var primeSignature = CanonicalPrimeSignature(value); + if (primeSignature is not null) return primeSignature; + if (value.Length > 1 && value.All(char.IsLetter)) + return CanonicalToken("mi", value, "normal"); + // MathJax keeps the TeX definition operator := in one , + // while MathType 7 serializes the same visible operator as two + // adjacent operator records ':' and '='. Treat only this known + // split as equivalent; other multi-character operators remain + // strict so the MTEF round-trip gate still catches real changes. + if (value == ":=") return "o(:)o(=)"; + // MathJax may coalesce adjacent non-ASCII mathematical symbols + // into one (for example \mapsto\parallel\sim), while MTEF + // persists one CHAR record per glyph. Normalize only a pure run + // of Unicode math symbols into the same per-glyph signature. + // ASCII composites such as := and alphabetic operators keep the + // stricter handling above. + if (value.Length > 1 + && value.All(character => character > 0x7F + && IsMathematicalSymbolScalar(character))) + { + return string.Concat(value.Select(character => + "o(" + NormalizeOperatorToken(character.ToString()) + ")")); + } + return "o(" + NormalizeOperatorToken(value) + ")"; + } + case "mtext": + { + var value = element.Value.Trim(); + // MathJax may serialize TeX spacing commands as whitespace-only + // (commonly NBSP), while MathType normalizes the same + // presentation-only gap to on MTEF read-back. Neither + // carries mathematical semantics, so treating empty/whitespace + // mtext as text() makes an otherwise exact equation fail the + // standalone-MTEF round-trip gate. Keep real textual content + // strict, but canonicalize pure spacing exactly like mspace. + if (value.Length == 0) return string.Empty; + if (value.All(char.IsLetter)) + return CanonicalToken("mi", value, "normal"); + return "text(" + value + ")"; + } + case "mfrac": + return CanonicalBinary("frac", children, variant); + case "msqrt": + return "sqrt(" + Children() + ")"; + case "mroot": + return CanonicalBinary("root", children, variant); + case "msub": + return CanonicalBinary("sub", children, variant); + case "msup": + return CanonicalBinary("sup", children, variant); + case "msubsup": + { + if (children.Length < 3) + return string.Concat(children.Select(child => CanonicalizeMathMl(child, variant))); + var baseSignature = CanonicalizeScriptBase(children[0], variant); + var subSignature = CanonicalizeMathMl(children[1], variant); + var supSignature = CanonicalizeMathMl(children[2], variant); + if (subSignature.Length == 0 && supSignature.Length > 0) + return "sup(" + baseSignature + "," + supSignature + ")"; + if (supSignature.Length == 0 && subSignature.Length > 0) + return "sub(" + baseSignature + "," + subSignature + ")"; + if (subSignature.Length == 0 && supSignature.Length == 0) + return baseSignature; + return "subsup(" + baseSignature + "," + subSignature + "," + supSignature + ")"; + } + case "munder": + { + if (children.Length == 1) + { + if (TryCanonicalizeWrappedHorizontalBrace( + children[0], variant, top: false, out var wrappedBrace)) + return wrappedBrace; + return CanonicalizeMathMl(children[0], variant); + } + if (children.Length >= 2) + { + var under = children[1].Value.Trim(); + if (under is "_" or "\u00AF" or "\u203E" or "\u2015" or "\u02C9") + return "underbar(" + CanonicalizeMathMl(children[0], variant) + ")"; + if (IsHorizontalBraceMarker(under, top: false)) + return "hbrace-bottom(" + CanonicalizeMathMl(children[0], variant) + ")"; + } + return CanonicalBinary("sub", children, variant); + } + case "mover": + { + if (children.Length == 1) + { + if (TryCanonicalizeWrappedHorizontalBrace( + children[0], variant, top: true, out var wrappedBrace)) + return wrappedBrace; + return CanonicalizeMathMl(children[0], variant); + } + if (children.Length < 2) return Children(); + var over = NormalizeAccentMark(children[1].Value.Trim()); + if (IsHorizontalBraceMarker(over, top: true)) + return "hbrace-top(" + CanonicalizeMathMl(children[0], variant) + ")"; + if (over == "¯") + return "accent(overbar," + CanonicalizeMathMl(children[0], variant) + ")"; + var accent = over is "→" or "←" or "↔" + or "^" or "~" or "." or "˙" or "¨" + or "⌢" or "⏜" or "ˇ" or "˘" or "´" or "`" or "˚"; + return accent + ? "accent(" + over + "," + CanonicalizeMathMl(children[0], variant) + ")" + : CanonicalBinary("sup", children, variant); + } + case "munderover": + return CanonicalTernary("subsup", children, variant); + case "mfenced": + { + if (TryCanonicalizeMathTypeBinomialFence(element, variant, out var binomial)) + return binomial; + var open = NormalizeFence((string?)element.Attribute("open") ?? "("); + var close = NormalizeFence((string?)element.Attribute("close") ?? ")"); + var fencedChildren = Children(); + // Word OMML materializes a one-sided system/cases delimiter as + // a real delimiter object (m:d / MathML mfenced with an empty + // opposite fence), while MathJax can represent the same visible + // mathematics as a loose delimiter followed by the table. + // Canonicalize only one-sided fences to that explicit token form; + // paired fences remain structurally strict. + return CanonicalFenceSignature( + open, + close, + fencedChildren); + } + case "mtable": + return "table(" + string.Join(";", children.Select(row => + CanonicalizeMathMl(row, variant))) + ")"; + case "mtr": + case "mlabeledtr": + return "row(" + string.Join(",", children.Select(cell => + CanonicalizeMathMl(cell, variant))) + ")"; + case "mtd": + return "cell(" + Children() + ")"; + case "menclose": + { + var notation = ((string?)element.Attribute("notation") ?? string.Empty) + .Trim() + .ToLowerInvariant(); + if (notation.Contains("radical")) return "sqrt(" + Children() + ")"; + if (notation.Contains("box")) return "box(" + Children() + ")"; + var strikeParts = new List(); + if (notation.Contains("horizontalstrike")) strikeParts.Add("h"); + if (notation.Contains("updiagonalstrike")) strikeParts.Add("up"); + if (notation.Contains("downdiagonalstrike")) strikeParts.Add("down"); + if (strikeParts.Count > 0) + return "strike[" + string.Join("+", strikeParts) + "](" + Children() + ")"; + return Children(); + } + case "mphantom": + return "phantom(" + Children() + ")"; + case "mmultiscripts": + { + if (children.Length == 0) return string.Empty; + var result = CanonicalizeScriptBase(children[0], variant); + var precedes = false; + for (var index = 1; index < children.Length;) + { + if (children[index].Name.LocalName == "mprescripts") + { + if (precedes) throw new InvalidDataException("Duplicate MathML prescript separator."); + precedes = true; index++; continue; + } + if (index + 1 >= children.Length || children[index + 1].Name.LocalName == "mprescripts") + throw new InvalidDataException("MathML multiscripts require complete sub/sup pairs."); + var subSignature = children[index].Name.LocalName == "none" ? string.Empty : CanonicalizeMathMl(children[index], variant); + var supSignature = children[index + 1].Name.LocalName == "none" ? string.Empty : CanonicalizeMathMl(children[index + 1], variant); + if (subSignature.Length > 0 || supSignature.Length > 0) + result = precedes ? "pre(" + result + "," + subSignature + "," + supSignature + ")" + : subSignature.Length > 0 && supSignature.Length > 0 ? "subsup(" + result + "," + subSignature + "," + supSignature + ")" + : subSignature.Length > 0 ? "sub(" + result + "," + subSignature + ")" + : "sup(" + result + "," + supSignature + ")"; + index += 2; + } + return result; + } + default: + return children.Length > 0 + ? Children() + : element.Value.Trim(); + } + } + + + private static string? CanonicalPrimeSignature(string value) => value switch + { + "'" => "o(′)", + "′" => "o(′)", + "″" => "o(′)o(′)", + "‴" => "o(′)o(′)o(′)", + "⁗" => "o(′)o(′)o(′)o(′)", + _ => null, + }; + + + private static bool IsReplacementGlyphOnly(string value) => + value.Length > 0 && value.All(character => character == '\uFFFD'); + + + private static bool IsMathematicalSymbolToken(string value) => + value.Length > 0 && value.All(character => + { + var category = char.GetUnicodeCategory(character); + return category == System.Globalization.UnicodeCategory.MathSymbol + || category == System.Globalization.UnicodeCategory.CurrencySymbol + || category == System.Globalization.UnicodeCategory.ModifierSymbol + || category == System.Globalization.UnicodeCategory.OtherSymbol; + }); + + + private static bool IsHorizontalBraceMarker(string value, bool top) => + value == (top ? "⏞" : "⏟") + || value == (top ? "\uFE37" : "\uFE38") + || IsReplacementGlyphOnly(value); + + + private static bool TryCanonicalizeWrappedHorizontalBrace( + XElement candidate, + string? variant, + bool top, + out string signature) + { + signature = string.Empty; + if (candidate.Name.LocalName != (top ? "mover" : "munder")) return false; + var inner = candidate.Elements().ToArray(); + if (inner.Length < 2 || inner[1].Name.LocalName != "mo") return false; + var marker = inner[1].Value.Trim(); + var stretchy = string.Equals( + (string?)inner[1].Attribute("stretchy"), + "true", + StringComparison.OrdinalIgnoreCase); + // MathType 7 can export its private horizontal-brace glyph as replacement + // characters. The extra one-child mover/munder wrapper plus a stretchy + // operator is the stable structural signal; ordinary overset/underset + // expressions do not have this wrapper. + if (!stretchy && !IsHorizontalBraceMarker(marker, top)) + return false; + signature = (top ? "hbrace-top(" : "hbrace-bottom(") + + CanonicalizeMathMl(inner[0], variant) + ")"; + return true; + } + + + private static string CanonicalBinary( + string name, + XElement[] children, + string? variant) + { + if (children.Length < 2) + return string.Concat(children.Select(child => CanonicalizeMathMl(child, variant))); + return name + "(" + + (name is "sub" or "sup" + ? CanonicalizeScriptBase(children[0], variant) + : CanonicalizeMathMl(children[0], variant)) + "," + + CanonicalizeMathMl(children[1], variant) + ")"; + } + + + private static string CanonicalTernary( + string name, + XElement[] children, + string? variant) + { + if (children.Length < 3) + return string.Concat(children.Select(child => CanonicalizeMathMl(child, variant))); + return name + "(" + + (name == "subsup" + ? CanonicalizeScriptBase(children[0], variant) + : CanonicalizeMathMl(children[0], variant)) + "," + + CanonicalizeMathMl(children[1], variant) + "," + + CanonicalizeMathMl(children[2], variant) + ")"; + } + + + private static string CanonicalizeScriptBase(XElement element, string? variant) + { + if (element.Name.LocalName == "mo") + { + // MathType's BigOp templates export the ordinary set-union/ + // intersection code points while MathJax represents \bigcup/\bigcap + // with U+22C3/U+22C2. They are the same semantic operator when used + // as the base of a limit/script structure. + var value = element.Value.Trim(); + if (value == "∪") return "o(⋃)"; + if (value == "∩") return "o(⋂)"; + } + return CanonicalizeMathMl(element, variant); + } + + + private static string CanonicalToken(string kind, string value, string? mathVariant) + { + var token = value.Trim(); + var variant = (mathVariant ?? string.Empty).Trim().ToLowerInvariant(); + if (variant.Contains("sans-serif-bold-italic")) variant = "sans-serif-bold-italic"; + else if (variant.Contains("sans-serif-italic")) variant = "sans-serif-italic"; + else if (variant.Contains("bold-sans-serif")) variant = "bold-sans-serif"; + else if (variant.Contains("bold-script")) variant = "bold-script"; + else if (variant.Contains("bold-fraktur")) variant = "bold-fraktur"; + else if (variant.Contains("bold-italic")) variant = "bold-italic"; + else if (variant.Contains("double-struck")) variant = "double-struck"; + else if (variant.Contains("fraktur")) variant = "fraktur"; + else if (variant.Contains("script")) variant = "script"; + else if (variant.Contains("monospace")) variant = "monospace"; + else if (variant.Contains("sans-serif")) variant = "sans-serif"; + else if (variant.Contains("normal") || variant.Contains("upright")) variant = "normal"; + else if (variant.Contains("bold")) variant = "bold"; + else if (variant.Contains("italic")) variant = string.Empty; + else variant = string.Empty; + if (kind == "mi" && variant.Length == 0 && token.Length == 1) + { + switch (token[0]) + { + case 'ℂ': token = "C"; variant = "double-struck"; break; + case 'ℍ': token = "H"; variant = "double-struck"; break; + case 'ℕ': token = "N"; variant = "double-struck"; break; + case 'ℙ': token = "P"; variant = "double-struck"; break; + case 'ℚ': token = "Q"; variant = "double-struck"; break; + case 'ℝ': token = "R"; variant = "double-struck"; break; + case 'ℤ': token = "Z"; variant = "double-struck"; break; + case 'ℱ': token = "F"; variant = "script"; break; + case 'ℒ': token = "L"; variant = "script"; break; + case 'ℛ': token = "R"; variant = "script"; break; + } + } + if (kind == "mi" && variant.Length == 0 + && token.Length == 1 + && IsUpperGreek(token[0])) + variant = "normal"; + var explicitlyItalic = string.Equals(mathVariant?.Trim(), "italic", StringComparison.OrdinalIgnoreCase); + if (kind == "mi" && variant.Length == 0 && !explicitlyItalic + && token.Length > 1 && token.All(char.IsLetter)) + variant = "normal"; + // MTEF stores styled identifiers as individual CHAR records; exporters + // may group or split an uninterrupted same-style alphabetic run. Compare + // its ordered characters with the exact style retained on EVERY token. + // This covers monospace/script/bold as well as upright runs without + // equating fonts, symbols, digit tokens or different character contents. + if (kind == "mi" && (variant.Length > 0 || explicitlyItalic) + && token.Length > 1 && token.All(char.IsLetter)) + return string.Concat(token.Select(character => + "mi[" + variant + "](" + character + ")")); + return kind + "[" + variant + "](" + token + ")"; + } + +} \ No newline at end of file diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Reader.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Reader.cs new file mode 100644 index 00000000..d5217e2d --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Reader.cs @@ -0,0 +1,1322 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Globalization; +using System.Text; +using System.Xml.Linq; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +internal static partial class MathTypeMtefCodec +{ + + private sealed partial class MtefStructureReader + { + private sealed class FontDefinition + { + public int EncodingIndex { get; set; } + public string Name { get; set; } = string.Empty; + } + + private sealed class FontStyleDefinition + { + public int FontDefinitionIndex { get; set; } + public byte CharacterStyle { get; set; } + } + + private readonly byte[] _data; + private readonly List _encodingDefinitions = new() + { + "MTCode", + "Unknown", + "Symbol", + "MTExtra", + }; + private readonly List _fontDefinitions = new(); + private readonly List _fontStyleDefinitions = new(); + private int _position; + + internal MtefStructureReader(byte[] data, int position) + { + _data = data; + ScanPrefixDefinitions(position); + _position = position; + } + + private void ScanPrefixDefinitions(int rootPosition) + { + var prefix = ReadPrefixLayout(_data); + if (rootPosition != prefix.Root) + throw new InvalidDataException("MTEF structural reader received a non-root offset."); + foreach (var record in prefix.Records) + { + if (record.Type == RecordColorDef) { ReadColorDefinition(record.Start); continue; } + if (record.Type is not RecordEncodingDef and not RecordFontDef and not RecordFontStyleDef) + continue; + _position = record.Start; + ReadDefinitionRecord(); + if (_position != record.End) + throw new InvalidDataException($"MTEF definition reader diverged at offset {record.Start}."); + } + } + + private static string ReadNullTerminatedString(byte[] data, ref int position) + { + var start = position; + while (position < data.Length && data[position] != 0) position++; + if (position >= data.Length) + throw new EndOfStreamException("MTEF string is not null terminated."); + var value = System.Text.Encoding.Default.GetString(data, start, position - start); + position++; + return value; + } + + private void ReadDefinitionRecord() + { + var record = Peek(); + switch (record) + { + case RecordEncodingDef: + _position++; + _encodingDefinitions.Add(ReadNullTerminatedString(_data, ref _position)); + return; + case RecordFontDef: + _position++; + _fontDefinitions.Add(new FontDefinition + { + EncodingIndex = ReadUnsigned(_data, ref _position), + Name = ReadNullTerminatedString(_data, ref _position), + }); + return; + case RecordFontStyleDef: + _position++; + _fontStyleDefinitions.Add(new FontStyleDefinition + { + FontDefinitionIndex = ReadUnsigned(_data, ref _position), + CharacterStyle = ReadByte(), + }); + return; + default: + throw new InvalidDataException( + $"MTEF record {record} is not a font/encoding definition."); + } + } + + internal IEnumerable ReadRoot() + { + SkipFormattingRecords(); + if (Peek() == RecordLine) + return ReadLine().Nodes().ToArray(); + if (Peek() == RecordPile) + return new XNode[] { ReadPile() }; + if (Peek() == RecordMatrix) + return new XNode[] { ReadMatrix() }; + throw new InvalidDataException( + $"Unsupported MathType root record {Peek()} at offset {_position}."); + } + + internal bool HasOnlyEquationEndRemaining() + { + SkipFormattingRecords(); + return _position == _data.Length - 1 + && Peek() == RecordEnd; + } + + private XElement ReadLine() + { + Expect(RecordLine); + var options = ReadByte(); + SkipNudge(options); + if ((options & 0x04) != 0) Skip(2); + if ((options & 0x02) != 0) SkipRuler(); + var row = new XElement("mrow"); + if ((options & LineNull) != 0) return row; + ReadObjectList(row); + return row; + } + + private XElement ReadPile() + { + Expect(RecordPile); + var options = ReadByte(); + SkipNudge(options); + Skip(2); // horizontal and vertical alignment + var rulerStops = (options & 0x02) != 0 + ? ReadRulerOffsets() + : Array.Empty(); + var table = new XElement("mtable"); + var hasAlignmentTabs = false; + while (true) + { + SkipFormattingRecords(); + if (Peek() == RecordEnd) + { + _position++; + break; + } + var line = ReadLine(); + var lineNodes = line.Nodes().ToArray(); + if (lineNodes.OfType().Any(IsMtefAlignmentPoint) + && TryBuildAlignmentSymbolRow(lineNodes, out var alignedRow)) + { + hasAlignmentTabs = true; + table.Add(alignedRow); + continue; + } + + var row = new XElement("mtr"); + var cell = new XElement("mtd"); + foreach (var node in lineNodes) + { + if (node is XElement marker && IsMtefAlignmentTab(marker)) + { + hasAlignmentTabs = true; + marker.Remove(); + row.Add(cell); + cell = new XElement("mtd"); + continue; + } + node.Remove(); + cell.Add(node); + } + row.Add(cell); + table.Add(row); + } + + if (hasAlignmentTabs) + { + // MathType's native multi-point aligned equations are PILE rows + // separated by fnMARKER/U+0009 tab characters. Reconstruct the + // alternating right/left MathJax table shape so strict MTEF + // round-trip validation and MathML->LaTeX recovery preserve every + // `&`, including empty cells introduced by `&&`. + table.SetAttributeValue("data-mtef-tabs", "true"); + var columnCount = table.Elements() + .Select(row => row.Elements().Count(cellNode => cellNode.Name.LocalName == "mtd")) + .DefaultIfEmpty(0) + .Max(); + if (columnCount > 0) + { + table.SetAttributeValue( + "columnalign", + string.Join(" ", Enumerable.Range(0, columnCount) + .Select(column => (column & 1) == 0 ? "right" : "left"))); + if (rulerStops.Length == columnCount / 2) + { + table.SetAttributeValue( + MtefRulerStopsAttribute, + string.Join(",", rulerStops.Select(stop => + stop.ToString(CultureInfo.InvariantCulture)))); + } + } + } + else + { + // Preserve the MTEF container kind. A two-row/one-column PILE + // inside ordinary parentheses is MathType's native binomial + // representation, while a MATRIX of the same shape is not. + table.SetAttributeValue("data-mtef-pile", "true"); + } + return table; + } + + private static bool IsMtefAlignmentTab(XElement element) => + element.Name.LocalName == "mspace" + && string.Equals( + (string?)element.Attribute("data-mtef-tab"), + "true", + StringComparison.OrdinalIgnoreCase); + + private static bool IsMtefAlignmentPoint(XElement element) => + element.Name.LocalName == "mspace" + && string.Equals( + (string?)element.Attribute("data-mtef-align"), + "true", + StringComparison.OrdinalIgnoreCase); + + private static bool TryBuildAlignmentSymbolRow( + XNode[] lineNodes, + out XElement row) + { + row = new XElement("mtr"); + var groups = new List>(); + var current = new List(); + foreach (var node in lineNodes) + { + if (node is XElement marker && IsMtefAlignmentTab(marker)) + { + // A LaTeXSnipper/MathType aligned PILE starts each tab group with + // a TAB. Do not materialize the leading empty group as a cell. + if (current.Count > 0) groups.Add(current); + current = new List(); + continue; + } + current.Add(node); + } + if (current.Count > 0) groups.Add(current); + if (groups.Count == 0) return false; + + foreach (var group in groups) + { + var alignmentIndexes = group + .Select((node, index) => new { Node = node, Index = index }) + .Where(item => item.Node is XElement element && IsMtefAlignmentPoint(element)) + .Select(item => item.Index) + .ToArray(); + if (alignmentIndexes.Length != 1) + { + row = new XElement("mtr"); + return false; + } + + var alignmentIndex = alignmentIndexes[0]; + row.Add(new XElement( + "mtd", + group.Take(alignmentIndex).Select(CloneNode))); + row.Add(new XElement( + "mtd", + group.Skip(alignmentIndex + 1).Select(CloneNode))); + } + return row.Elements().Any(); + } + + private XElement ReadMatrix() + { + Expect(RecordMatrix); + var options = ReadByte(); + SkipNudge(options); + Skip(3); // vertical alignment, horizontal and vertical cell justification + var rowCount = ReadByte(); + var columnCount = ReadByte(); + if (rowCount == 0 || columnCount == 0) + throw new InvalidDataException("MathType MATRIX has zero rows or columns."); + Skip(PartitionByteCount(rowCount + 1)); + Skip(PartitionByteCount(columnCount + 1)); + var table = new XElement("mtable"); + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) + { + var row = new XElement("mtr"); + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + SkipFormattingRecords(); + if (Peek() != RecordLine) + throw new InvalidDataException( + $"MathType MATRIX cell {rowIndex},{columnIndex} is not a LINE record."); + var cell = ReadLine(); + row.Add(new XElement("mtd", cell.Nodes())); + } + table.Add(row); + } + SkipFormattingRecords(); + if (Peek() != RecordEnd) + throw new InvalidDataException( + $"MathType MATRIX has no END record at offset {_position}."); + _position++; + return table; + } + + private void ReadObjectList(XElement row) + { + while (true) + { + SkipFormattingRecords(); + var record = Peek(); + if (record == RecordEnd) + { + _position++; + return; + } + if (record == RecordChar) + { + var character = ReadCharacter(); + ApplyCurrentColor(character); + ApplyCurrentSize(character); + AppendNode(row, character); + continue; + } + if (record == RecordTemplate) + { + int templateColor = _currentColor; + var template = ReadTemplate(); + if (template.Node != null) ApplyColor(template.Node, templateColor); + if (template.ScriptKind != 0) + { + var baseNode = row.Nodes().LastOrDefault(); + if (baseNode is null) + throw new InvalidDataException( + $"MathType script template at offset {_position} has no preceding base object."); + baseNode.Remove(); + row.Add(BuildScriptNode(baseNode, template)); + } + else if (template.Node is not null) + { + row.Add(template.Node); + } + continue; + } + if (record == RecordLine) + { + row.Add(ReadLine()); + continue; + } + if (record == RecordPile) + { + row.Add(ReadPile()); + continue; + } + if (record == RecordMatrix) + { + row.Add(ReadMatrix()); + continue; + } + if (record >= 100) + { + SkipFutureRecord(); + continue; + } + throw new InvalidDataException( + $"Unsupported MathType MTEF record {record} at offset {_position}."); + } + } + + private XElement ReadCharacter() + { + Expect(RecordChar); + var options = ReadByte(); + SkipNudge(options); + var typeface = ReadSigned(); + int scalar = -1; + if ((options & 0x20) == 0) + { + Require(_data, _position, 2); + scalar = _data[_position] | (_data[_position + 1] << 8); + _position += 2; + } + int encoded = -1; + if ((options & CharEncoded8) != 0) encoded = ReadByte(); + if ((options & 0x10) != 0) + { + Require(_data, _position, 2); + encoded = _data[_position] | (_data[_position + 1] << 8); + _position += 2; + } + if (scalar < 0) scalar = encoded; + if (scalar < 0 || scalar > char.MaxValue) + scalar = '?'; + + var embellishments = (options & CharHasEmbellishment) != 0 + ? ReadEmbellishmentList() + : Array.Empty(); + + var text = ((char)scalar).ToString(); + string? explicitMathVariant = null; + if (typeface < 0) + { + explicitMathVariant = ResolveExplicitMathVariant( + -typeface, + scalar, + encoded, + out var explicitText); + if (!string.IsNullOrEmpty(explicitText)) text = explicitText; + if (TryEuclidMathOneOperatorEncoded8(scalar, out _) + || TryEuclidMathTwoOperatorEncoded8(scalar, out _)) + { + // For explicit Euclid operator glyphs the encoded8 byte is + // only a font position. Normally MTCode remains the semantic + // Unicode character; MathType's preceq/succeq pair is the + // exception, using private MTCode E938/E939 internally. + text = scalar switch + { + 0xE938 => "⪯", + 0xE939 => "⪰", + _ => ((char)scalar).ToString(), + }; + explicitMathVariant = null; + } + else if (scalar == 0xED02 && encoded == 0xF8) + { + // Genuine MathType 7 dotless-j: Euclid Math One PUA MTCode + // 0xED02 / position 0xF8. Recover Unicode U+0237 so the + // public MathML/LaTeX layer sees \\jmath, not script PUA text. + text = "ȷ"; + explicitMathVariant = null; + } + } + + XElement token; + if (typeface == TypefaceMathTypeSpecial12 && scalar is >= 0x2E80 and <= 0x9FFF) + token = new XElement("mtext", text); + else if (typeface < 0) + { + if (TryEuclidMathOneOperatorEncoded8(scalar, out _) + || TryEuclidMathTwoOperatorEncoded8(scalar, out _)) + { + // Several MathType relation/harpoon glyphs live in explicit + // Euclid Math One/Two styles. They remain mathematical + // operators even though their MTEF typeface is negative. + token = new XElement("mo", text); + } + else + { + token = new XElement("mi", text); + if (!string.IsNullOrWhiteSpace(explicitMathVariant)) + token.SetAttributeValue("mathvariant", explicitMathVariant); + } + } + else if (typeface != TypefaceSymbol + && TryNormalizeLetterlikeScalar( + scalar, + out var normalizedLetterlikeText, + out var normalizedLetterlikeVariant)) + { + token = new XElement( + "mi", + new XAttribute("mathvariant", normalizedLetterlikeVariant), + normalizedLetterlikeText); + } + else if (typeface == TypefaceNumber) + token = new XElement("mn", text); + else if (typeface == TypefaceMarker && scalar == '\t') + token = new XElement("mspace", new XAttribute("data-mtef-tab", "true")); + else if (typeface == TypefaceMarker && scalar == MathTypeAlignmentMarkerMtCode) + token = new XElement("mspace", new XAttribute("data-mtef-align", "true")); + else if (typeface == TypefaceMathTypeSpecial12 && scalar == 0x220B) + token = new XElement("mo", "∋"); + else if (typeface == TypefaceMathTypeSpecial12 && scalar == 0x25EF) + token = new XElement("mo", "◯"); + else if (typeface == TypefaceMathTypeSpecial12 && scalar == 0x2661) + token = new XElement("mo", "♡"); + else if (typeface == TypefaceMtExtra && scalar == 0xFFFD && encoded == 0x6E) + { + // MathType 7 uses this MT Extra position for the diamondsuit glyph. + // Some unsupported TeX imports may also collapse to the same + // persisted glyph; once that happens the original command is no + // longer recoverable, so preserve the actual stored glyph. + token = new XElement("mo", "♢"); + } + else if (typeface == TypefaceMtExtra && scalar == 0x2210 && encoded == 0x43) + { + // MathType reuses U+2210 internally for ordinary TeX \\amalg. + // The dedicated COPRODUCT template is what represents \\coprod; + // a plain MT Extra CHAR at position 0x43 is amalg instead. + token = new XElement("mo", "⨿"); + } + else if (typeface == TypefaceSpace) + token = new XElement("mspace", new XAttribute("width", "0.2em")); + else if (typeface == TypefaceSymbol + && scalar is 0x2135 or 0x2118 or 0x211C or 0x2111) + { + // MathType stores \aleph, \wp, \Re and \Im in Symbol typeface, + // but exports them as identifier tokens rather than operators. + // Preserve that token class without misclassifying ℜ/ℑ as + // Fraktur solely from their Unicode letterlike code points. + token = new XElement("mi", text); + } + else if (typeface == TypefaceSymbol) + token = new XElement("mo", text); + else if (typeface == TypefaceText) + token = IsMathematicalSymbolScalar(scalar) + ? new XElement("mo", text) + : new XElement("mtext", text); + else if (typeface == TypefaceFunction) + { + if (text.All(char.IsLetter)) + { + token = new XElement( + "mi", + new XAttribute("mathvariant", "normal"), + new XAttribute("data-mtef-run", "function"), + text); + } + else if (text.All(char.IsWhiteSpace)) + token = new XElement("mspace", new XAttribute("width", "0.2em")); + else + token = new XElement("mo", text); + } + else if (typeface == TypefaceVector) + token = new XElement("mi", new XAttribute("mathvariant", "bold"), text); + else + token = new XElement("mi", text); + + // A CHAR contains a fixed glyph; actual scalable delimiters are + // decoded by the TMPL path as mfenced. Preserve that distinction for + // nested bra/ket characters inside a scalable absolute-value pair. + if (token.Name.LocalName == "mo" + && NormalizeFence(text) is "(" or ")" or "[" or "]" or "{" or "}" + or "⟨" or "⟩" or "⌈" or "⌉" or "⌊" or "⌋" or "|" or "‖") + { + token.SetAttributeValue("fence", "false"); + token.SetAttributeValue("stretchy", "false"); + } + + // EMBELL is not restricted to over-accents: native MathType stores + // primes here, including on characters nested inside another script. + // Apply accents to the character, then attach all right/left primes + // at that character's script level. Never silently discard a record. + var rightPrimes = new StringBuilder(); + var leftPrimes = new StringBuilder(); + foreach (var embellishment in embellishments) + { + switch (embellishment) + { + case EmbellPrime: rightPrimes.Append('′'); continue; + case EmbellDoublePrime: rightPrimes.Append('″'); continue; + case EmbellTriplePrime: rightPrimes.Append('‴'); continue; + case EmbellBackPrime: leftPrimes.Append('‵'); continue; + } + var mark = EmbellishmentMark(embellishment); + if (mark.Length == 0) + throw new InvalidDataException( + $"Unsupported MathType character embellishment {embellishment}; conversion stopped to avoid losing a symbol."); + token = new XElement( + "mover", + new XAttribute("accent", "true"), + token, + new XElement("mo", mark)); + } + if (leftPrimes.Length > 0) + { + token = new XElement("mmultiscripts", token, + new XElement("none"), + rightPrimes.Length > 0 ? new XElement("mo", rightPrimes.ToString()) : new XElement("none"), + new XElement("mprescripts"), new XElement("none"), + new XElement("mo", leftPrimes.ToString())); + } + else if (rightPrimes.Length > 0) + token = new XElement("msup", token, new XElement("mo", rightPrimes.ToString())); + return token; + } + + private string? ResolveExplicitMathVariant( + int styleIndex, + int scalar, + int encoded, + out string text) + { + text = encoded is >= 0x21 and <= 0x7E + ? ((char)encoded).ToString() + : ((char)scalar).ToString(); + string? fontName = null; + byte characterStyle = 0; + if (styleIndex > 0 && styleIndex <= _fontStyleDefinitions.Count) + { + var style = _fontStyleDefinitions[styleIndex - 1]; + characterStyle = style.CharacterStyle; + if (style.FontDefinitionIndex > 0 + && style.FontDefinitionIndex <= _fontDefinitions.Count) + fontName = _fontDefinitions[style.FontDefinitionIndex - 1].Name; + } + + var font = fontName ?? string.Empty; + if (font.IndexOf("Euclid Math One", StringComparison.OrdinalIgnoreCase) >= 0 + && TryEuclidMathOneGreekVariantEncoded8(scalar, out var expectedGreekEncoded8) + && encoded == expectedGreekEncoded8) + { + // MathType uses Euclid Math One for several Greek variants + // (\epsilon, \varrho, \varkappa). They remain ordinary Greek + // identifiers, not \mathcal/script characters. + text = ((char)scalar).ToString(); + return null; + } + if (font.IndexOf("Euclid Math One", StringComparison.OrdinalIgnoreCase) >= 0) + return characterStyle is 1 or 3 ? "bold-script" : "script"; + if (font.IndexOf("Euclid Math Two", StringComparison.OrdinalIgnoreCase) >= 0) + return "double-struck"; + if (font.IndexOf("Fraktur", StringComparison.OrdinalIgnoreCase) >= 0) + return characterStyle is 1 or 3 ? "bold-fraktur" : "fraktur"; + if (string.Equals(font, "Courier New", StringComparison.OrdinalIgnoreCase)) + return "monospace"; + if (string.Equals(font, "Arial", StringComparison.OrdinalIgnoreCase)) + return characterStyle switch + { + 1 => "bold-sans-serif", + 2 => "sans-serif-italic", + 3 => "sans-serif-bold-italic", + _ => "sans-serif", + }; + + if (TryNormalizeLetterlikeScalar(scalar, out var letterlikeText, out var letterlikeVariant)) + { + text = letterlikeText; + return letterlikeVariant; + } + + return characterStyle switch + { + 1 => "bold", + 2 => "italic", + 3 => "bold-italic", + _ => "normal", + }; + } + + private static bool TryNormalizeLetterlikeScalar( + int scalar, + out string text, + out string mathVariant) + { + text = string.Empty; + mathVariant = string.Empty; + switch (scalar) + { + case 0x2102: text = "C"; mathVariant = "double-struck"; return true; + case 0x210B: text = "H"; mathVariant = "script"; return true; + case 0x210C: text = "H"; mathVariant = "fraktur"; return true; + case 0x210D: text = "H"; mathVariant = "double-struck"; return true; + case 0x2110: text = "I"; mathVariant = "script"; return true; + case 0x2111: text = "I"; mathVariant = "fraktur"; return true; + case 0x2112: text = "L"; mathVariant = "script"; return true; + case 0x2115: text = "N"; mathVariant = "double-struck"; return true; + case 0x2119: text = "P"; mathVariant = "double-struck"; return true; + case 0x211A: text = "Q"; mathVariant = "double-struck"; return true; + case 0x211B: text = "R"; mathVariant = "script"; return true; + case 0x211C: text = "R"; mathVariant = "fraktur"; return true; + case 0x211D: text = "R"; mathVariant = "double-struck"; return true; + case 0x2124: text = "Z"; mathVariant = "double-struck"; return true; + case 0x2128: text = "Z"; mathVariant = "fraktur"; return true; + case 0x212C: text = "B"; mathVariant = "script"; return true; + case 0x212D: text = "C"; mathVariant = "fraktur"; return true; + case 0x212F: text = "e"; mathVariant = "script"; return true; + case 0x2130: text = "E"; mathVariant = "script"; return true; + case 0x2131: text = "F"; mathVariant = "script"; return true; + case 0x2133: text = "M"; mathVariant = "script"; return true; + case 0x2134: text = "o"; mathVariant = "script"; return true; + default: return false; + } + } + + private static void AppendNode(XElement row, XElement node) + { + var previous = row.Elements().LastOrDefault(); + if (node.Name.LocalName == "mtext" && !node.HasElements) + { + // MathType stores spaces inside an ordinary text run as its + // Space typeface, so ReadCharacter() necessarily materializes + // them first as . Rejoin only a run of + // those exact spaces when it is bounded by mtext on both sides. + // This preserves `\text{Double word product}` as textual content + // without treating general mathematical spacing as text. + var trailingTextSpaces = row.Elements() + .Reverse() + .TakeWhile(IsMtefTextSpace) + .ToArray(); + if (trailingTextSpaces.Length > 0) + { + var precedingText = trailingTextSpaces[trailingTextSpaces.Length - 1].PreviousNode as XElement; + if (precedingText is not null + && precedingText.Name.LocalName == "mtext" + && !precedingText.HasElements) + { + precedingText.Value += new string(' ', trailingTextSpaces.Length) + node.Value; + foreach (var space in trailingTextSpaces) space.Remove(); + return; + } + } + } + + var mergeableRun = node.Name.LocalName is "mn" or "mtext" + || node.Name.LocalName == "mi" + && string.Equals( + (string?)node.Attribute("data-mtef-run"), + "function", + StringComparison.Ordinal) + && string.Equals( + (string?)previous?.Attribute("data-mtef-run"), + "function", + StringComparison.Ordinal); + if (previous is not null + && mergeableRun + && previous.Name.LocalName == node.Name.LocalName + && (string?)previous.Attribute("mathcolor") == (string?)node.Attribute("mathcolor") + && (string?)previous.Attribute("mathsize") == (string?)node.Attribute("mathsize") + && string.Equals( + (string?)previous.Attribute("mathvariant") ?? string.Empty, + (string?)node.Attribute("mathvariant") ?? string.Empty, + StringComparison.OrdinalIgnoreCase) + && !previous.HasElements + && !node.HasElements) + { + previous.Value += node.Value; + return; + } + row.Add(node); + } + + private static bool IsMtefTextSpace(XElement element) => + element.Name.LocalName == "mspace" + && string.Equals( + (string?)element.Attribute("width"), + "0.2em", + StringComparison.OrdinalIgnoreCase) + && !element.HasElements; + + private static string EmbellishmentMark(byte embellishment) => embellishment switch + { + EmbellDot => ".", + EmbellDoubleDot => "¨", + EmbellTilde => "~", + EmbellHat => "^", + EmbellRightArrow => "→", + EmbellLeftArrow => "←", + EmbellBothArrow => "↔", + EmbellOverbar => "¯", + _ => string.Empty, + }; + + private sealed class ParsedTemplate + { + internal byte ScriptKind { get; set; } + internal bool ScriptPrecedes { get; set; } + internal XElement? Node { get; set; } + internal XElement? Subscript { get; set; } + internal XElement? Superscript { get; set; } + } + + private ParsedTemplate ReadTemplate() + { + Expect(RecordTemplate); + var options = ReadByte(); + SkipNudge(options); + var selector = ReadByte(); + var variationFirst = ReadByte(); + var variation = (variationFirst & 0x80) == 0 + ? variationFirst + : (variationFirst & 0x7F) | (ReadByte() << 8); + _ = ReadByte(); // template-specific options + + switch (selector) + { + case TemplateFraction: + { + var numerator = ReadNextLineSlot(); + var denominator = ReadNextLineSlot(); + ConsumeTemplateEnd(); + return new ParsedTemplate + { + Node = new XElement( + "mfrac", + CollapseRow(numerator), + CollapseRow(denominator)), + }; + } + case TemplateRoot: + { + var radicand = ReadNextLineSlot(); + var index = ReadNextLineSlot(); + ConsumeTemplateEnd(); + return new ParsedTemplate + { + Node = variation == 1 && index.HasElements + ? new XElement( + "mroot", + CollapseRow(radicand), + CollapseRow(index)) + : new XElement("msqrt", radicand.Nodes()), + }; + } + case TemplateSub: + case TemplateSup: + case TemplateSubSup: + { + var subscript = ReadNextLineSlot(); + var superscript = ReadNextLineSlot(); + ConsumeTemplateEnd(); + return new ParsedTemplate + { + ScriptKind = selector, + ScriptPrecedes = (variation & 1) != 0, + Subscript = subscript, + Superscript = superscript, + }; + } + case TemplateAngle: + case TemplateParen: + case TemplateBrace: + case TemplateBracket: + case TemplateBar: + case TemplateDoubleBar: + case TemplateFloor: + case TemplateCeiling: + { + var main = ReadNextLineSlot(); + // MathType may include explicit left/right fence CHAR records. + // They are presentation metadata; consume all remaining + // subobjects until this template's END and reconstruct from + // selector/variation instead. + while (true) + { + SkipFormattingRecords(); + if (Peek() == RecordEnd) + { + _position++; + break; + } + SkipOneObject(); + } + var (open, close) = FenceCharacters(selector, variation); + return new ParsedTemplate + { + Node = new XElement( + "mfenced", + new XAttribute("open", open), + new XAttribute("close", close), + CollapseRow(main)), + }; + } + case TemplateUnderbar: + case TemplateOverbar: + case TemplateArrow: + case TemplateVector: + case TemplateTilde: + case TemplateHat: + case TemplateArc: + case TemplateStrike: + case TemplateBox: + { + var main = ReadNextLineSlot(); + ConsumeRemainingTemplateObjects(); + var body = CollapseRow(main); + XElement node; + if (selector == TemplateUnderbar) + node = new XElement("munder", body, new XElement("mo", "_")); + else if (selector == TemplateOverbar) + node = new XElement("mover", body, new XElement("mo", "¯")); + else if (selector is TemplateArrow or TemplateVector) + { + var arrow = (variation & 0x03) switch + { + 1 => "←", + 3 => "↔", + _ => "→", + }; + node = new XElement("mover", body, new XElement("mo", arrow)); + } + else if (selector == TemplateTilde) + node = new XElement("mover", body, new XElement("mo", "~")); + else if (selector == TemplateHat) + node = new XElement("mover", body, new XElement("mo", "^")); + else if (selector == TemplateArc) + node = new XElement("mover", body, new XElement("mo", "⌢")); + else if (selector == TemplateBox) + node = new XElement("menclose", new XAttribute("notation", "box"), body); + else + { + var strikeNotations = new List(); + if ((variation & 0x01) != 0) strikeNotations.Add("horizontalstrike"); + if ((variation & 0x02) != 0) strikeNotations.Add("updiagonalstrike"); + if ((variation & 0x04) != 0) strikeNotations.Add("downdiagonalstrike"); + if (strikeNotations.Count == 0) strikeNotations.Add("horizontalstrike"); + node = new XElement( + "menclose", + new XAttribute("notation", string.Join(" ", strikeNotations)), + body); + } + return new ParsedTemplate { Node = node }; + } + case TemplateHorizontalBrace: + case TemplateHorizontalBracket: + { + var slots = ReadTemplateLineSlotsUntilEnd(); + var main = slots.Count > 0 + ? CollapseRow(slots[0]) + : new XElement("mrow"); + var annotation = slots.Count > 1 && slots[1].HasElements + ? CollapseRow(slots[1]) + : null; + var top = (variation & 0x01) != 0; + var mark = selector == TemplateHorizontalBrace + ? (top ? "⏞" : "⏟") + : (top ? "⎴" : "⎵"); + XNode decorated = top + ? new XElement("mover", main, new XElement("mo", mark)) + : new XElement("munder", main, new XElement("mo", mark)); + if (annotation is not null) + decorated = top + ? new XElement("mover", decorated, annotation) + : new XElement("munder", decorated, annotation); + return new ParsedTemplate { Node = (XElement)decorated }; + } + case TemplateIntegral: + case TemplateSum: + case TemplateProduct: + case TemplateCoproduct: + case TemplateUnion: + case TemplateIntersection: + case 21: // integral-family variants in MathType's BigOp group + case 22: + return ReadBigOperatorTemplate(selector, variation); + case TemplateLimit: + return ReadLimitTemplate(); + default: + throw new InvalidDataException( + $"Unsupported MathType template selector {selector} at offset {_position}."); + } + } + + private ParsedTemplate ReadBigOperatorTemplate(byte selector, int variation) + { + var main = ReadNextLineSlot(); + // MathType 7's persisted BigOp objects are observed in Word as + // main, lower, upper, operator. Keep the empirically verified order: + // swapping these two slots makes i=1/n reopen with inverted limits. + var lower = ReadNextLineSlot(); + var upper = ReadNextLineSlot(); + SkipFormattingRecords(); + XElement? serializedOperator = null; + if (Peek() == RecordChar) + { + serializedOperator = ReadCharacter(); + while (serializedOperator.Name.LocalName == "mover") + serializedOperator = serializedOperator.Elements().FirstOrDefault() + ?? new XElement("mo", DefaultBigOperatorCharacter(selector)); + } + ConsumeRemainingTemplateObjects(); + // MathType stores the large glyph in a font/MTCode-specific CHAR. For + // the dedicated BigOp selectors the selector itself is the durable + // semantic identity; using the decoded CHAR can leak a private glyph + // or U+FFFD into LaTeXSnipper. Generic BigOp selectors still need the + // serialized character because their selector does not name the op. + var operatorNode = selector == TemplateIntegral + ? new XElement("mo", IntegralOperatorCharacter(variation)) + : selector is >= TemplateSum and <= TemplateIntersection + ? new XElement("mo", DefaultBigOperatorCharacter(selector)) + : serializedOperator ?? new XElement("mo", DefaultBigOperatorCharacter(selector)); + XNode decorated = DecorateWithLimits(operatorNode, lower, upper); + var row = new XElement("mrow", decorated); + foreach (var node in main.Nodes().ToArray()) + { + node.Remove(); + row.Add(node); + } + return new ParsedTemplate { Node = row }; + } + + private ParsedTemplate ReadLimitTemplate() + { + var main = ReadNextLineSlot(); + var lower = ReadNextLineSlot(); + var upper = ReadNextLineSlot(); + ConsumeRemainingTemplateObjects(); + XNode baseNode = CollapseRow(main); + return new ParsedTemplate + { + Node = (XElement)DecorateWithLimits(baseNode, lower, upper), + }; + } + + private static XNode DecorateWithLimits( + XNode baseNode, + XElement lower, + XElement upper) + { + var hasLower = lower.HasElements; + var hasUpper = upper.HasElements; + if (hasLower && hasUpper) + return new XElement( + "msubsup", + baseNode, + CollapseRow(lower), + CollapseRow(upper)); + if (hasLower) + return new XElement("msub", baseNode, CollapseRow(lower)); + if (hasUpper) + return new XElement("msup", baseNode, CollapseRow(upper)); + return baseNode; + } + + private static string IntegralOperatorCharacter(int variation) => (variation & 0x0F) switch + { + 0x02 => "∬", + 0x03 => "∭", + // MathType 7 persists the contour-integral BigOp as kind 5: + // an MT Extra contour adornment followed by the ordinary integral. + 0x05 => "∮", + 0x08 => "∲", + 0x0C => "∳", + _ => "∫", + }; + + private static string DefaultBigOperatorCharacter(byte selector) => selector switch + { + TemplateIntegral => "∫", + TemplateSum => "∑", + TemplateProduct => "∏", + TemplateCoproduct => "∐", + TemplateUnion => "⋃", + TemplateIntersection => "⋂", + 21 => "∫", + 22 => "∫", + _ => "∑", + }; + + private List ReadTemplateLineSlotsUntilEnd() + { + var slots = new List(); + while (true) + { + SkipFormattingRecords(); + if (Peek() == RecordEnd) + { + _position++; + return slots; + } + if (Peek() == RecordLine) + { + slots.Add(ReadLine()); + continue; + } + SkipOneObject(); + } + } + + private void ConsumeRemainingTemplateObjects() + { + while (true) + { + SkipFormattingRecords(); + if (Peek() == RecordEnd) + { + _position++; + return; + } + SkipOneObject(); + } + } + + private static XNode BuildScriptNode(XNode baseNode, ParsedTemplate template) + { + var sub = template.Subscript is null + ? new XElement("mrow") + : CollapseRow(template.Subscript); + var sup = template.Superscript is null + ? new XElement("mrow") + : CollapseRow(template.Superscript); + if (template.ScriptPrecedes) + return new XElement("mmultiscripts", baseNode, new XElement("mprescripts"), + template.ScriptKind == TemplateSup ? new XElement("none") : sub, + template.ScriptKind == TemplateSub ? new XElement("none") : sup); + if (template.ScriptKind == TemplateSub) + return new XElement("msub", baseNode, sub); + if (template.ScriptKind == TemplateSup) + return new XElement("msup", baseNode, sup); + return new XElement("msubsup", baseNode, sub, sup); + } + + private static XNode CollapseRow(XElement row) + { + var nodes = row.Nodes().ToArray(); + if (nodes.Length == 1) + { + nodes[0].Remove(); + return nodes[0]; + } + return new XElement("mrow", nodes); + } + + private XElement ReadNextLineSlot() + { + SkipFormattingRecords(); + if (Peek() != RecordLine) + throw new InvalidDataException( + $"Expected MathType template LINE slot at offset {_position}, actual={Peek()}."); + return ReadLine(); + } + + private void ConsumeTemplateEnd() + { + SkipFormattingRecords(); + if (Peek() != RecordEnd) + throw new InvalidDataException( + $"Expected MathType template END at offset {_position}, actual={Peek()}."); + _position++; + } + + private void SkipOneObject() + { + var record = Peek(); + switch (record) + { + case RecordChar: + _ = ReadCharacter(); + return; + case RecordLine: + _ = ReadLine(); + return; + case RecordTemplate: + _ = ReadTemplate(); + return; + case RecordPile: + _ = ReadPile(); + return; + case RecordMatrix: + _ = ReadMatrix(); + return; + default: + SkipFormattingRecords(); + if (Peek() == record) + throw new InvalidDataException( + $"Cannot skip MathType MTEF record {record} at offset {_position}."); + return; + } + } + + private void SkipFormattingRecords() + { + while (_position < _data.Length) + { + var record = _data[_position]; + if (record >= RecordFull && record <= RecordSubSym) + { + _position++; + _explicitSizePoints = null; + continue; + } + if (record == RecordSize) + { + ReadSizeRecord(); + continue; + } + if (record == 15) + { + _position++; + _currentColor = ReadUnsigned(_data, ref _position); + continue; + } + if (record == RecordColorDef) + { + ReadColorDefinition(_position); + continue; + } + if (record is RecordEncodingDef or RecordFontDef or RecordFontStyleDef) + { + ReadDefinitionRecord(); + continue; + } + if (record >= 100) + { + SkipFutureRecord(); + continue; + } + break; + } + } + + private void SkipFutureRecord() + { + _position++; // record type + var length = ReadUnsigned(_data, ref _position); + Skip(length); + } + + private byte[] ReadEmbellishmentList() + { + var embellishments = new List(); + while (true) + { + var record = Peek(); + if (record == RecordEnd) + { + _position++; + return embellishments.ToArray(); + } + if (record != RecordEmbellishment) + throw new InvalidDataException( + $"Unexpected MTEF embellishment record {record} at offset {_position}."); + _position++; + var options = ReadByte(); + SkipNudge(options); + embellishments.Add(ReadByte()); + } + } + + private int[] ReadRulerOffsets() + { + // LP_RULER identifies the inline payload, which starts with its count. + var count = ReadByte(); + var offsets = new int[count]; + for (var index = 0; index < count; index++) + { + _ = ReadByte(); // tab type: alignment symbol can override it + var low = ReadByte(); + var high = ReadByte(); + offsets[index] = low | (high << 8); + } + return offsets; + } + + private void SkipRuler() => _ = ReadRulerOffsets(); + + private void SkipNudge(byte options) + { + if ((options & 0x08) == 0) return; + var dx = ReadByte(); + var dy = ReadByte(); + if (dx == 128 && dy == 128) Skip(4); + } + + private int ReadSigned() + { + var first = ReadByte(); + if (first != 0xFF) return first - 128; + Require(_data, _position, 2); + var raw = _data[_position] | (_data[_position + 1] << 8); + _position += 2; + return raw - 32768; + } + + private byte ReadByte() + { + Require(_data, _position, 1); + return _data[_position++]; + } + + private byte Peek() + { + Require(_data, _position, 1); + return _data[_position]; + } + + private void Expect(byte expected) + { + var actual = ReadByte(); + if (actual != expected) + throw new InvalidDataException( + $"Expected MTEF record {expected}, actual={actual} at offset {_position - 1}."); + } + + private void Skip(int count) + { + Require(_data, _position, count); + _position += count; + } + + private static (string Open, string Close) FenceCharacters(byte selector, int variation) + { + var presentLeft = (variation & 0x01) != 0; + var presentRight = (variation & 0x02) != 0; + var pair = selector switch + { + TemplateAngle => ("⟨", "⟩"), + TemplateParen => ("(", ")"), + TemplateBrace => ("{", "}"), + TemplateBracket => ("[", "]"), + TemplateBar => ("|", "|"), + TemplateDoubleBar => ("‖", "‖"), + TemplateFloor => ("⌊", "⌋"), + TemplateCeiling => ("⌈", "⌉"), + _ => (string.Empty, string.Empty), + }; + return (presentLeft ? pair.Item1 : string.Empty, presentRight ? pair.Item2 : string.Empty); + } + } + + + private static void Require(byte[] data, int offset, int count) + { + if (offset < 0 || count < 0 || offset + count > data.Length) + throw new EndOfStreamException( + $"MTEF stream is truncated at offset {offset}, need {count} bytes."); + } + +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Size.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Size.cs new file mode 100644 index 00000000..65c2c8ad --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Size.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using System.IO; +using System.Xml.Linq; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +internal static partial class MathTypeMtefCodec +{ + private sealed partial class MtefStructureReader + { + private double? _explicitSizePoints; + + private void ReadSizeRecord() + { + int start = _position; + _position = SkipInitialSizeRecord(_data, start); + byte form = _data[start + 1]; + if (form == 101) + { + double points = -BitConverter.ToInt16(_data, start + 2) / 32d; + if (!(points > 0)) throw new InvalidDataException("Invalid explicit MTEF point size at " + start); + _explicitSizePoints = points; + return; + } + int logical = form == 100 ? _data[start + 2] : form; + int delta = form == 100 ? BitConverter.ToInt16(_data, start + 3) : _data[start + 2] - 128; + if (delta != 0 || logical > 4) + throw new InvalidDataException("Unsupported MTEF logical size adjustment at " + start); + _explicitSizePoints = null; + } + + private void ApplyCurrentSize(XElement node) + { + if (_explicitSizePoints.HasValue) + node.SetAttributeValue("mathsize", _explicitSizePoints.Value.ToString("0.#####", CultureInfo.InvariantCulture) + "pt"); + } + } +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Writer.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Writer.cs new file mode 100644 index 00000000..e14ffd8b --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.Writer.cs @@ -0,0 +1,2621 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Globalization; +using System.Text; +using System.Xml.Linq; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +internal static partial class MathTypeMtefCodec +{ + + private static XElement PrepareMathForMathType(XElement math) + { + var preparedMath = new XElement(math); + MaterializeInheritedMathVariants(preparedMath, inheritedMathVariant: null); + NormalizeMathJaxFenceRows(preparedMath); + return preparedMath; + } + + + private static byte[] BuildRootStructure( + XElement math, + byte[] sourceMtef, + out byte[] prefixDefinitions) + { + // The shared template writer emits COLOR 1 for matrix/pile slots. MTEF + // definition indices start at 1; the standalone seed has no color table. + // An undefined COLOR 1 lets MathPage omit the root polygon's fill brush, + // leaving only thin fragments in its WMF despite intact root semantics. + var requiredDefinitions = new List { RecordColorDef, 0, 0, 0, 0, 0, 0, 0 }; + var preparedMath = PrepareMathForMathType(math); + PrepareColors(preparedMath, requiredDefinitions); + prefixDefinitions = requiredDefinitions.ToArray(); + var topLevelElements = SignificantChildren(preparedMath) + .OfType() + .Where(element => element.Name.LocalName is not ("annotation" or "annotation-xml")) + .ToArray(); + if (topLevelElements.Length == 1 + && topLevelElements[0].Name.LocalName == "mtable") + { + // Font/encoding definitions are global MTEF records. MathType accepts + // them inside an ordinary root LINE, but putting them inside a root + // PILE makes its native editor preserve the glyphs while degrading + // fnMARKER tabs into empty text. Build the aligned table on a clone, + // emit any missing definitions into the global prefix instead, then + // keep the PILE object list identical to MathType's native layout. + var alignedTable = new XElement(topLevelElements[0]); + var alignedPrefixDefinitions = new List(requiredDefinitions); + EmitExplicitFontDefinitions( + alignedTable, + sourceMtef, + alignedPrefixDefinitions); + var alignedRoot = new List(); + if (TryEmitAlignedPile(alignedTable, alignedRoot)) + { + prefixDefinitions = alignedPrefixDefinitions.ToArray(); + // MathType 7's own multi-point aligned equations use PILE as the + // top-level equation structure. Keeping a synthetic root LINE + // around that PILE causes MathType to export fnMARKER tabs as + // literal text instead of alignment boundaries. + alignedRoot.Add(RecordEnd); // equation + return alignedRoot.ToArray(); + } + } + + var output = new List { RecordLine, 0 }; + EmitExplicitFontDefinitions(preparedMath, sourceMtef, output); + EmitContainerChildren(preparedMath, output, inheritedMathVariant: null); + output.Add(RecordEnd); // root line + output.Add(RecordEnd); // equation + return output.ToArray(); + } + + + private static byte[] AssembleRewrittenMtef( + byte[] sourceMtef, + int sourceStructureOffset, + byte[] prefixDefinitions, + byte[] rootStructure) + { + // MathType's global ENCODING_DEF/FONT_DEF/FONT_STYLE_DEF records belong + // before the initial SIZE record. Putting them after SIZE or inside a root + // PILE makes MathType preserve the styled glyphs but stop interpreting + // fnMARKER/U+0009 as alignment tabs. Insert only the newly required + // definitions at this prefix boundary and keep every original prefix byte. + var insertionOffset = FindInitialSizeRecordOffset( + sourceMtef, + sourceStructureOffset); + var rewritten = new byte[ + sourceStructureOffset + prefixDefinitions.Length + rootStructure.Length]; + Buffer.BlockCopy(sourceMtef, 0, rewritten, 0, insertionOffset); + Buffer.BlockCopy( + prefixDefinitions, + 0, + rewritten, + insertionOffset, + prefixDefinitions.Length); + Buffer.BlockCopy( + sourceMtef, + insertionOffset, + rewritten, + insertionOffset + prefixDefinitions.Length, + sourceStructureOffset - insertionOffset); + Buffer.BlockCopy( + rootStructure, + 0, + rewritten, + sourceStructureOffset + prefixDefinitions.Length, + rootStructure.Length); + return rewritten; + } + + + private static int FindInitialSizeRecordOffset(byte[] mtef, int rootOffset) + { + var prefix = ReadPrefixLayout(mtef); + if (prefix.Root != rootOffset) + throw new InvalidDataException("MTEF rewrite root does not match its validated prefix."); + foreach (var record in prefix.Records) + if (record.Type == RecordSize || record.Type >= RecordFull && record.Type <= RecordSubSym) + return record.Start; + return prefix.Root; + } + + + private static void MaterializeInheritedMathVariants( + XElement element, + string? inheritedMathVariant) + { + var ownVariant = ((string?)element.Attribute("mathvariant"))?.Trim(); + var variant = string.IsNullOrWhiteSpace(ownVariant) + ? inheritedMathVariant + : ownVariant; + if (element.Name.LocalName is "mi" or "mn" or "mo" or "mtext") + { + if (element.Attribute("mathvariant") is null + && !string.IsNullOrWhiteSpace(variant)) + element.SetAttributeValue("mathvariant", variant); + return; + } + foreach (var child in element.Elements()) + MaterializeInheritedMathVariants(child, variant); + } + + + private static void NormalizeMathJaxFenceRows(XElement root) + { + var rows = root.DescendantsAndSelf() + .Where(element => element.Name.LocalName == "mrow") + .Reverse() + .ToArray(); + foreach (var row in rows) + { + if (!TryGetMathJaxFenceRow(row, out var open, out var close, out var children)) + continue; + var fenced = new XElement( + "mfenced", + new XAttribute("open", NormalizeFence(open)), + new XAttribute("close", NormalizeFence(close)), + children.Select(child => new XElement(child))); + row.ReplaceNodes(fenced); + } + } + + + private static string CanonicalizeElementSequence( + XElement[] elements, + string? variant) + { + if (elements.Length == 0) + return string.Empty; + if (TryCanonicalizeSplitFencedScriptSequence( + elements, + variant, + out var splitFencedScript)) + return splitFencedScript; + if (TryCanonicalizeLooseBinomialSequence( + elements, + variant, + out var looseBinomialSequence)) + return looseBinomialSequence; + if (TryCanonicalizeLooseFenceSequence( + elements, + variant, + out var looseFenceSequence)) + return looseFenceSequence; + return string.Concat( + elements.Select( + child => CanonicalizeMathMl( + child, + variant))); + } + + + private static bool TryCanonicalizeLooseBinomialSequence( + XElement[] elements, + string? variant, + out string signature) + { + signature = string.Empty; + if (elements.Length < 3) return false; + var builder = new StringBuilder(); + var replaced = false; + for (var index = 0; index < elements.Length;) + { + if (index + 2 < elements.Length + && TryGetLooseFenceToken(elements[index], out var open) + && TryGetLooseFenceToken(elements[index + 2], out var close) + && NormalizeFence(open) == "(" + && NormalizeFence(close) == ")") + { + if (TryCanonicalizeLooseBinomialBody( + elements[index + 1], + variant, + out var binomialBody)) + { + builder.Append(binomialBody); + index += 3; + replaced = true; + continue; + } + } + builder.Append(CanonicalizeMathMl(elements[index], variant)); + index++; + } + if (!replaced) return false; + signature = builder.ToString(); + return true; + } + + + private static bool TryCanonicalizeLooseFenceSequence( + XElement[] elements, + string? variant, + out string signature) + { + signature = string.Empty; + if (elements.Length < 2) return false; + + var builder = new StringBuilder(); + var replaced = false; + for (var index = 0; index < elements.Length;) + { + if (TryGetLooseOpeningFenceToken( + elements[index], + out var open) + && TryFindMatchingLooseFence( + elements, + index, + open, + out var closeIndex, + out var close)) + { + var innerElements = + elements + .Skip(index + 1) + .Take(closeIndex - index - 1) + .ToArray(); + innerElements = + StripRedundantNestedLooseFenceLayers( + innerElements, + open, + close); + var innerSignature = + CanonicalizeElementSequence( + innerElements, + variant); + + builder.Append( + CanonicalFenceSignature( + open, + close, + innerSignature)); + index = closeIndex + 1; + replaced = true; + continue; + } + + builder.Append( + CanonicalizeMathMl( + elements[index], + variant)); + index++; + } + + if (!replaced) return false; + signature = builder.ToString(); + return true; + } + + + private static bool TryFindMatchingLooseFence( + XElement[] elements, + int openIndex, + string open, + out int closeIndex, + out string close) + { + closeIndex = -1; + close = string.Empty; + var normalizedOpen = NormalizeFence(open); + var expectedClose = + MatchingClosingFence( + normalizedOpen); + if (expectedClose.Length == 0) + return false; + + var depth = 0; + for (var index = openIndex + 1; + index < elements.Length; + index++) + { + if (TryGetLooseOpeningFenceToken( + elements[index], + out var nestedOpen) + && string.Equals( + NormalizeFence(nestedOpen), + normalizedOpen, + StringComparison.Ordinal)) + { + depth++; + continue; + } + + if (!TryGetLooseClosingFenceToken( + elements[index], + out var candidateClose) + || !string.Equals( + NormalizeFence(candidateClose), + expectedClose, + StringComparison.Ordinal)) + continue; + + if (depth > 0) + { + depth--; + continue; + } + + closeIndex = index; + close = candidateClose; + return true; + } + + return false; + } + + + private static bool TryGetLooseOpeningFenceToken( + XElement candidate, + out string fence) + { + if (!TryGetLooseFenceToken( + candidate, + out fence)) + return false; + + var normalized = + NormalizeFence( + fence); + if (normalized is "(" or "[" or "{" or "⟨" or "⌈" or "⌊") + return true; + + if (normalized is "|" or "‖") + return HasExplicitFenceRole( + candidate, + "OPEN"); + + return false; + } + + + private static bool TryGetLooseClosingFenceToken( + XElement candidate, + out string fence) + { + if (!TryGetLooseFenceToken( + candidate, + out fence)) + return false; + + var normalized = + NormalizeFence( + fence); + if (normalized is ")" or "]" or "}" or "⟩" or "⌉" or "⌋") + return true; + + if (normalized is "|" or "‖") + return HasExplicitFenceRole( + candidate, + "CLOSE"); + + return false; + } + + + private static bool HasExplicitFenceRole( + XElement candidate, + string expectedRole) + { + XElement token = candidate; + if (candidate.Name.LocalName == "mrow") + { + var children = candidate.Elements().ToArray(); + if (children.Length != 1 + || children[0].Name.LocalName != "mo") + return false; + token = children[0]; + } + + var candidateClass = + ((string?)candidate.Attribute( + "data-mjx-texclass") + ?? string.Empty).Trim(); + var tokenClass = + ((string?)token.Attribute( + "data-mjx-texclass") + ?? string.Empty).Trim(); + if (string.Equals( + candidateClass, + expectedRole, + StringComparison.OrdinalIgnoreCase) + || string.Equals( + tokenClass, + expectedRole, + StringComparison.OrdinalIgnoreCase)) + return true; + + return string.Equals( + (string?)token.Attribute( + "fence"), + "true", + StringComparison.OrdinalIgnoreCase) + && string.Equals( + (string?)token.Attribute( + "stretchy"), + "true", + StringComparison.OrdinalIgnoreCase); + } + + + private static string MatchingClosingFence( + string open) => + NormalizeFence( + open) switch + { + "(" => ")", + "[" => "]", + "{" => "}", + "⟨" => "⟩", + "⌈" => "⌉", + "⌊" => "⌋", + "|" => "|", + "‖" => "‖", + _ => string.Empty, + }; + + + private static XElement[] StripRedundantNestedLooseFenceLayers( + XElement[] elements, + string outerOpen, + string outerClose) + { + var normalizedOuterOpen = + NormalizeFence( + outerOpen); + var normalizedOuterClose = + NormalizeFence( + outerClose); + var current = + elements; + while (current.Length >= 2 + && TryGetLooseOpeningFenceToken( + current[0], + out var nestedOpen) + && TryFindMatchingLooseFence( + current, + 0, + nestedOpen, + out var nestedCloseIndex, + out var nestedClose) + && nestedCloseIndex == + current.Length - 1 + && string.Equals( + NormalizeFence( + nestedOpen), + normalizedOuterOpen, + StringComparison.Ordinal) + && string.Equals( + NormalizeFence( + nestedClose), + normalizedOuterClose, + StringComparison.Ordinal)) + { + current = + current + .Skip(1) + .Take( + current.Length - 2) + .ToArray(); + } + + return current; + } + + + private static string CanonicalFenceSignature( + string open, + string close, + string innerSignature) + { + var normalizedOpen = + NormalizeFence( + open); + var normalizedClose = + NormalizeFence( + close); + + if (normalizedOpen.Length > 0 + && normalizedClose.Length == 0) + { + return "o(" + + normalizedOpen + + ")" + + innerSignature; + } + + if (normalizedOpen.Length == 0 + && normalizedClose.Length > 0) + { + return innerSignature + + "o(" + + normalizedClose + + ")"; + } + + return "fence(" + + normalizedOpen + + "," + + normalizedClose + + "," + + innerSignature + + ")"; + } + + + private static bool TryCanonicalizeLooseBinomialBody( + XElement body, + string? variant, + out string signature) + { + signature = string.Empty; + if (body.Name.LocalName == "mfrac") + { + var thickness = ((string?)body.Attribute("linethickness") ?? string.Empty).Trim(); + var fractionChildren = body.Elements().ToArray(); + if (!thickness.StartsWith("0", StringComparison.Ordinal) + || fractionChildren.Length != 2) + return false; + signature = "binom(" + + CanonicalizeMathMl(fractionChildren[0], variant) + + "," + + CanonicalizeMathMl(fractionChildren[1], variant) + + ")"; + return true; + } + + body = UnwrapSingleTransparentContainer(body); + if (body.Name.LocalName != "mtable" + || !string.Equals( + (string?)body.Attribute("data-mtef-pile"), + "true", + StringComparison.OrdinalIgnoreCase)) + return false; + var rows = body.Elements() + .Where(row => row.Name.LocalName is "mtr" or "mlabeledtr") + .ToArray(); + if (rows.Length != 2) return false; + var cells = rows.Select(row => row.Elements() + .Where(cell => cell.Name.LocalName == "mtd") + .ToArray()) + .ToArray(); + if (cells.Any(row => row.Length != 1)) return false; + var top = string.Concat(cells[0][0].Elements() + .Select(child => CanonicalizeMathMl(child, variant))); + var bottom = string.Concat(cells[1][0].Elements() + .Select(child => CanonicalizeMathMl(child, variant))); + signature = "binom(" + top + "," + bottom + ")"; + return true; + } + + + private static XElement UnwrapSingleTransparentContainer(XElement element) + { + while (element.Name.LocalName is "mrow" or "mstyle" or "mpadded") + { + var children = element.Elements().ToArray(); + if (children.Length != 1) break; + element = children[0]; + } + return element; + } + + + private static bool TryCanonicalizeMathJaxBinomialFence( + string open, + string close, + XElement[] children, + string? variant, + out string signature) + { + signature = string.Empty; + if (NormalizeFence(open) != "(" || NormalizeFence(close) != ")") return false; + if (children.Length != 1 || children[0].Name.LocalName != "mfrac") return false; + var lineThickness = (children[0].Attribute("linethickness")?.Value ?? string.Empty).Trim(); + if (lineThickness.Length == 0 || !lineThickness.StartsWith("0", StringComparison.Ordinal)) return false; + var fractionChildren = children[0].Elements().ToArray(); + if (fractionChildren.Length != 2) return false; + var top = CanonicalizeMathMl(fractionChildren[0], variant); + var bottom = CanonicalizeMathMl(fractionChildren[1], variant); + signature = "binom(" + top + "," + bottom + ")"; + return true; + } + + + private static bool TryCanonicalizeMathTypeBinomialFence( + XElement fenced, + string? variant, + out string signature) + { + signature = string.Empty; + var open = NormalizeFence((string?)fenced.Attribute("open") ?? "("); + var close = NormalizeFence((string?)fenced.Attribute("close") ?? ")"); + if (open != "(" || close != ")") return false; + var table = fenced.Elements().SingleOrDefault(); + // Word's OMML round-trip commonly materializes a native no-bar + // fraction as mfenced -> mrow -> mfrac(linethickness=0). Treat that + // as the same binomial semantics before checking MathType's PILE form. + if (table is not null + && TryCanonicalizeLooseBinomialBody(table, variant, out signature)) + return true; + if (table is not null) + table = UnwrapSingleTransparentContainer(table); + if (table?.Name.LocalName == "mfrac") + return TryCanonicalizeMathJaxBinomialFence( + open, + close, + new[] { table }, + variant, + out signature); + if (table is null || table.Name.LocalName != "mtable") return false; + // A genuine MathType binomial reaches us through a PILE record. An + // explicit 2x1 matrix inside parentheses is visually similar, but is a + // MATRIX record and must remain a matrix. The old shape-only heuristic + // collapsed both into binom(...), which made strict LaTeXSnipper→MathType + // round-trip validation reject explicit column matrices such as the + // binomial-theorem formula from 文档1. + if (!string.Equals( + (string?)table.Attribute("data-mtef-pile"), + "true", + StringComparison.OrdinalIgnoreCase)) + return false; + var rows = table.Elements() + .Where(row => row.Name.LocalName is "mtr" or "mlabeledtr") + .ToArray(); + if (rows.Length != 2) return false; + var cells = rows.Select(row => row.Elements() + .Where(cell => cell.Name.LocalName == "mtd") + .ToArray()) + .ToArray(); + if (cells.Any(row => row.Length != 1)) return false; + var top = string.Concat(cells[0][0].Elements() + .Select(child => CanonicalizeMathMl(child, variant))); + var bottom = string.Concat(cells[1][0].Elements() + .Select(child => CanonicalizeMathMl(child, variant))); + signature = "binom(" + top + "," + bottom + ")"; + return true; + } + + + private static bool TryCanonicalizeSplitFencedScriptSequence( + XElement[] elements, + string? variant, + out string signature) + { + signature = string.Empty; + if (elements.Length < 3) return false; + if (!TryGetLooseOpeningFenceToken( + elements[0], + out var open)) + return false; + + for (var scriptIndex = 2; + scriptIndex < elements.Length; + scriptIndex++) + { + var script = elements[scriptIndex]; + var scriptKind = script.Name.LocalName; + if (scriptKind is not ("msub" or "msup" or "msubsup")) + continue; + + var scriptChildren = + script.Elements().ToArray(); + var minimumChildren = + scriptKind == "msubsup" + ? 3 + : 2; + if (scriptChildren.Length < minimumChildren) + continue; + if (!TryGetLooseClosingFenceToken( + scriptChildren[0], + out var close)) + continue; + if (!AreMatchingFences( + open, + close)) + continue; + + var innerElements = + elements + .Skip(1) + .Take(scriptIndex - 1) + .ToArray(); + innerElements = + StripRedundantNestedLooseFenceLayers( + innerElements, + open, + close); + var innerSignature = + CanonicalizeElementSequence( + innerElements, + variant); + if (innerSignature.Length == 0) + continue; + + var baseSignature = + CanonicalFenceSignature( + open, + close, + innerSignature); + string scriptedSignature; + if (scriptKind == "msub") + { + var subSignature = + CanonicalizeMathMl( + scriptChildren[1], + variant); + if (subSignature.Length == 0) + continue; + scriptedSignature = + "sub(" + + baseSignature + + "," + + subSignature + + ")"; + } + else if (scriptKind == "msup") + { + var supSignature = + CanonicalizeMathMl( + scriptChildren[1], + variant); + if (supSignature.Length == 0) + continue; + scriptedSignature = + "sup(" + + baseSignature + + "," + + supSignature + + ")"; + } + else + { + var subSignature = + CanonicalizeMathMl( + scriptChildren[1], + variant); + var supSignature = + CanonicalizeMathMl( + scriptChildren[2], + variant); + if (subSignature.Length == 0 + && supSignature.Length == 0) + continue; + if (subSignature.Length == 0) + { + scriptedSignature = + "sup(" + + baseSignature + + "," + + supSignature + + ")"; + } + else if (supSignature.Length == 0) + { + scriptedSignature = + "sub(" + + baseSignature + + "," + + subSignature + + ")"; + } + else + { + scriptedSignature = + "subsup(" + + baseSignature + + "," + + subSignature + + "," + + supSignature + + ")"; + } + } + + var tail = + CanonicalizeElementSequence( + elements + .Skip(scriptIndex + 1) + .ToArray(), + variant); + signature = + scriptedSignature + + tail; + return true; + } + + return false; + } + + + private static bool TryGetLooseFenceToken(XElement candidate, out string fence) + { + fence = string.Empty; + XElement? token = candidate.Name.LocalName == "mo" ? candidate : null; + if (token is null && candidate.Name.LocalName == "mrow") + { + var nested = candidate.Elements().ToArray(); + if (nested.Length == 1 && nested[0].Name.LocalName == "mo") + token = nested[0]; + } + if (token is null) return false; + fence = NormalizeFence(token.Value.Trim()); + return fence is "(" or ")" or "[" or "]" or "{" or "}" + or "⟨" or "⟩" or "⌈" or "⌉" or "⌊" or "⌋" or "|" or "‖"; + } + + + private static bool AreMatchingFences(string open, string close) => + (NormalizeFence(open), NormalizeFence(close)) switch + { + ("(", ")") => true, + ("[", "]") => true, + ("{", "}") => true, + ("⟨", "⟩") => true, + ("⌈", "⌉") => true, + ("⌊", "⌋") => true, + ("|", "|") => true, + ("‖", "‖") => true, + _ => false, + }; + + + private static bool TryCanonicalizeMathJaxFencedSuperscriptRow( + XElement row, + string? variant, + out string signature) + { + signature = string.Empty; + if (row.Name.LocalName != "mrow") return false; + var elements = row.Elements().ToArray(); + if (elements.Length < 3) return false; + if (!TryGetMathJaxFenceToken(elements[0], "OPEN", out var open)) return false; + + var script = elements[elements.Length - 1]; + if (script.Name.LocalName != "msup") return false; + var scriptChildren = script.Elements().ToArray(); + if (scriptChildren.Length < 2) return false; + if (!TryGetMathJaxFenceToken(scriptChildren[0], "CLOSE", out var close)) return false; + if (!AreMatchingFences(open, close)) return false; + + var inner = string.Concat(elements + .Skip(1) + .Take(elements.Length - 2) + .Select(child => CanonicalizeMathMl(child, variant))); + var baseSignature = "o(" + NormalizeFence(open) + ")" + + inner + + "o(" + NormalizeFence(close) + ")"; + var exponentSignature = CanonicalizeMathMl(scriptChildren[1], variant); + signature = exponentSignature.Length == 0 + ? baseSignature + : "sup(" + baseSignature + "," + exponentSignature + ")"; + return true; + } + + + private static bool TryGetMathJaxFenceRow( + XElement row, + out string open, + out string close, + out XElement[] children) + { + open = string.Empty; + close = string.Empty; + children = Array.Empty(); + if (row.Name.LocalName != "mrow") return false; + var elements = row.Elements().ToArray(); + if (elements.Length < 3) return false; + if (!TryGetMathJaxFenceToken(elements[0], "OPEN", out open)) return false; + if (!TryGetMathJaxFenceToken(elements[elements.Length - 1], "CLOSE", out close)) return false; + // MathJax represents \left\{ ... \right. with an explicit empty + // closing fence. It is a one-sided delimiter template, not an empty + // operator after an ordinary brace glyph. Use the same recognition for + // MTEF geometry and semantic comparison. + var oneSided = (open.Length == 0) != (close.Length == 0); + if (!AreMatchingFences(open, close) + && !(oneSided && SelectFenceTemplate(open, close).HasValue)) return false; + children = elements.Skip(1).Take(elements.Length - 2).ToArray(); + return children.Length > 0; + } + + + private static bool TryGetMathJaxFenceToken( + XElement candidate, + string expectedClass, + out string fence) + { + fence = string.Empty; + XElement? token = null; + if (candidate.Name.LocalName == "mo") + token = candidate; + else if (candidate.Name.LocalName == "mrow") + { + var nested = candidate.Elements().ToArray(); + if (nested.Length == 1 && nested[0].Name.LocalName == "mo") + token = nested[0]; + } + if (token is null) return false; + + var candidateClass = ((string?)candidate.Attribute("data-mjx-texclass") ?? string.Empty).Trim(); + var tokenClass = ((string?)token.Attribute("data-mjx-texclass") ?? string.Empty).Trim(); + var markerMatches = string.Equals(candidateClass, expectedClass, StringComparison.OrdinalIgnoreCase) + || string.Equals(tokenClass, expectedClass, StringComparison.OrdinalIgnoreCase); + fence = NormalizeFence(token.Value.Trim()); + if (markerMatches) return true; + // Explicitly fixed CHAR delimiters must not become stretchable TMPL + // fences merely because a read-back mrow groups their siblings. + if (string.Equals((string?)token.Attribute("fence"), "false", StringComparison.OrdinalIgnoreCase) + || string.Equals((string?)token.Attribute("stretchy"), "false", StringComparison.OrdinalIgnoreCase)) + return false; + if (fence.Length == 0 + && string.Equals((string?)token.Attribute("fence"), "true", StringComparison.OrdinalIgnoreCase)) + return true; + + // Word's OMML->MathML transform emits structurally complete delimiter + // rows without MathJax's data-mjx-texclass markers. MathType 7 otherwise + // imports only the first opening delimiter into a big-operator main slot + // and leaves the remaining operand as root siblings. Accept an unmarked + // token only when its glyph unambiguously matches the requested side; + // the caller also verifies that the two delimiters form a valid pair. + return string.Equals(expectedClass, "OPEN", StringComparison.OrdinalIgnoreCase) + ? fence is "(" or "[" or "{" or "⟨" or "⌈" or "⌊" or "|" or "‖" + : string.Equals(expectedClass, "CLOSE", StringComparison.OrdinalIgnoreCase) + && fence is ")" or "]" or "}" or "⟩" or "⌉" or "⌋" or "|" or "‖"; + } + + + private static void EmitExplicitFontDefinitions( + XElement math, + byte[] sourceMtef, + List output) + { + var specs = new[] + { + (Variant: "script", Encoding: "EuclidMath1", Font: "Euclid Math One", CharacterStyle: (byte)0), + (Variant: "bold-script", Encoding: "EuclidMath1", Font: "Euclid Math One", CharacterStyle: (byte)1), + (Variant: "double-struck", Encoding: "EuclidMath2", Font: "Euclid Math Two", CharacterStyle: (byte)0), + (Variant: "fraktur", Encoding: "EuclidFraktur", Font: "Euclid Fraktur", CharacterStyle: (byte)0), + (Variant: "bold-fraktur", Encoding: "EuclidFraktur", Font: "Euclid Fraktur", CharacterStyle: (byte)1), + (Variant: "sans-serif", Encoding: "WindowsANSI", Font: "Arial", CharacterStyle: (byte)0), + (Variant: "bold-sans-serif", Encoding: "WindowsANSI", Font: "Arial", CharacterStyle: (byte)1), + (Variant: "sans-serif-italic", Encoding: "WindowsANSI", Font: "Arial", CharacterStyle: (byte)2), + (Variant: "sans-serif-bold-italic", Encoding: "WindowsANSI", Font: "Arial", CharacterStyle: (byte)3), + (Variant: "monospace", Encoding: "WindowsANSI", Font: "Courier New", CharacterStyle: (byte)0), + (Variant: "bold-italic", Encoding: "WindowsANSI", Font: "Times New Roman", CharacterStyle: (byte)3), + }; + var existing = CountPrefixDefinitions(sourceMtef); + var created = 0; + foreach (var spec in specs) + { + var tokens = math.DescendantsAndSelf() + .Where(element => MatchesExplicitFontSpec(element, spec.Variant)) + .ToArray(); + if (tokens.Length == 0) continue; + + var existingStyleIndex = FindPrefixFontStyleIndex( + sourceMtef, + spec.Font, + spec.CharacterStyle); + if (existingStyleIndex is not null) + { + foreach (var token in tokens) + token.SetAttributeValue( + "data-mtef-explicit-typeface", + -existingStyleIndex.Value); + continue; + } + + created++; + var encodingIndex = 4 + existing.EncodingDefinitions + created; + var fontDefinitionIndex = existing.FontDefinitions + created; + var fontStyleIndex = existing.FontStyleDefinitions + created; + + output.Add(RecordEncodingDef); + WriteNullTerminatedAscii(output, spec.Encoding); + output.Add(RecordFontDef); + WriteUnsigned(output, encodingIndex); + WriteNullTerminatedAscii(output, spec.Font); + output.Add(RecordFontStyleDef); + WriteUnsigned(output, fontDefinitionIndex); + output.Add(spec.CharacterStyle); + + foreach (var token in tokens) + token.SetAttributeValue("data-mtef-explicit-typeface", -fontStyleIndex); + } + } + + + private static bool MatchesExplicitFontSpec(XElement element, string variant) + { + var local = element.Name.LocalName; + var scalars = EnumerateBmpScalars(element.Value).ToArray(); + if (scalars.Length == 0) return false; + + if (local == "mi") + { + if (string.Equals( + ((string?)element.Attribute("mathvariant"))?.Trim(), + variant, + StringComparison.OrdinalIgnoreCase)) + { + // The standard blackboard set letters use MathType's built-in + // MT Extra style, so they do not require an explicit EuclidMath2 + // prefix even when MathML calls them double-struck. + return !string.Equals(variant, "double-struck", StringComparison.OrdinalIgnoreCase) + || !scalars.All(scalar => TryMtExtraDoubleStruck(scalar, out _, out _)); + } + + if (string.Equals(variant, "script", StringComparison.OrdinalIgnoreCase) + && (scalars.All(scalar => TryEuclidMathOneGreekVariantEncoded8(scalar, out _)) + || scalars.All(scalar => TryEuclidMathOneDotlessJ(scalar, out _, out _)))) + { + // MathType 7 stores TeX \epsilon / \varrho / \varkappa and + // dotless-j in Euclid Math One even when MathML does not carry + // a script mathvariant. + return true; + } + } + + if (local == "mo") + { + if (string.Equals(variant, "script", StringComparison.OrdinalIgnoreCase) + && scalars.All(scalar => TryEuclidMathOneOperatorEncoded8(scalar, out _))) + { + return true; + } + if (string.Equals(variant, "double-struck", StringComparison.OrdinalIgnoreCase) + && scalars.All(scalar => TryEuclidMathTwoOperatorEncoded8(scalar, out _))) + { + return true; + } + } + + return false; + } + + + private static int? FindPrefixFontStyleIndex( + byte[] mtef, string fontName, byte expectedCharacterStyle) + { + var fontDefinitions = new List(); + var styleIndex = 0; + foreach (var record in ReadPrefixLayout(mtef).Records) + { + var position = record.Start + 1; + if (record.Type == RecordFontDef) + { + _ = ReadUnsigned(mtef, ref position); + fontDefinitions.Add(ReadPrefixNullTerminatedString(mtef, ref position)); + } + else if (record.Type == RecordFontStyleDef) + { + var fontDefinitionIndex = ReadUnsigned(mtef, ref position); + var actualCharacterStyle = mtef[position]; + styleIndex++; + if (actualCharacterStyle == expectedCharacterStyle + && fontDefinitionIndex > 0 && fontDefinitionIndex <= fontDefinitions.Count + && string.Equals(fontDefinitions[fontDefinitionIndex - 1], fontName, StringComparison.OrdinalIgnoreCase)) + return styleIndex; + } + } + return null; + } + + + private static string ReadPrefixNullTerminatedString( + byte[] data, + ref int position) + { + var start = position; + while (position < data.Length && data[position] != 0) position++; + if (position >= data.Length) + throw new EndOfStreamException("MTEF prefix string is not null terminated."); + var value = System.Text.Encoding.Default.GetString( + data, + start, + position - start); + position++; + return value; + } + + + private static (int EncodingDefinitions, int FontDefinitions, int FontStyleDefinitions) + CountPrefixDefinitions(byte[] mtef) + { + var records = ReadPrefixLayout(mtef).Records; + return (records.Count(item => item.Type == RecordEncodingDef), + records.Count(item => item.Type == RecordFontDef), + records.Count(item => item.Type == RecordFontStyleDef)); + } + + + private static void WriteNullTerminatedAscii(List output, string value) + { + output.AddRange(System.Text.Encoding.ASCII.GetBytes(value)); + output.Add(0); + } + + + private static void WriteUnsigned(List output, int value) + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value)); + if (value < 255) + { + output.Add((byte)value); + return; + } + output.Add(0xFF); + output.Add((byte)(value & 0xFF)); + output.Add((byte)((value >> 8) & 0xFF)); + } + + + private static void EmitNodeCore(XNode node, List output) + { + if (node is XText text) + { + EmitText(text.Value, TypefaceText, output); + return; + } + if (node is not XElement element) return; + var local = element.Name.LocalName; + switch (local) + { + case "math": + case "mrow": + case "semantics": + case "mpadded": + case "mphantom": + EmitContainerChildren(element, output, inheritedMathVariant: null); + break; + case "mstyle": + EmitContainerChildren( + element, + output, + ((string?)element.Attribute("mathvariant"))?.Trim()); + break; + case "mi": + EmitIdentifier(element, output); + break; + case "mn": + EmitText(element.Value, TypefaceNumber, output); + break; + case "mo": + { + var operatorValue = element.Value.Trim(); + if (TryResolveNativeIntegral(element, out var integral)) + EmitIntegralTemplate(integral, main: null, lower: null, upper: null, output); + else if (TryResolveNativeBigOperator(element, out var standaloneBigOperator)) + EmitBigOperatorTemplate(standaloneBigOperator, main: null, lower: null, upper: null, output); + else if (IsNamedLimitOperator(element) + || operatorValue.Length > 1 && operatorValue.All(char.IsLetter)) + // MathJax emits alphabetic operators such as TeX `\bmod` as + // one upright token. Writing each ASCII letter through + // the Symbol typeface made the MTEF reader recover three + // unrelated operator tokens (`m`, `o`, `d`), tripping the + // strict standalone semantic gate. MathType's native + // function run is the matching MTEF representation for an + // upright alphabetic operator and round-trips as one run. + EmitFunctionRun(operatorValue, output); + else + EmitOperator(element, output); + break; + } + case "mtext": + EmitText(element.Value, TypefaceText, output); + break; + case "mspace": + EmitCharacter(' ', TypefaceSpace, output); + break; + case "mfrac": + EmitFraction(element, output); + break; + case "msqrt": + EmitSquareRoot(element, output); + break; + case "mroot": + EmitNthRoot(element, output); + break; + case "msup": + EmitScript(element, TemplateSup, output); + break; + case "msub": + EmitScript(element, TemplateSub, output); + break; + case "msubsup": + EmitScript(element, TemplateSubSup, output); + break; + case "mfenced": + EmitFenced(element, output); + break; + case "mover": + EmitOver(element, output); + break; + case "munder": + EmitUnder(element, output); + break; + case "munderover": + EmitUnderOver(element, output); + break; + case "mtable": + EmitMatrix(element, output); + break; + case "mtr": + case "mlabeledtr": + case "mtd": + EmitContainerChildren(element, output, inheritedMathVariant: null); + break; + case "menclose": + EmitEnclose(element, output); + break; + case "mmultiscripts": + EmitMultiScripts(element, output); + break; + case "none": + case "annotation": + case "annotation-xml": + break; + default: + throw new InvalidDataException($"Unsupported MathML element <{local}>."); + } + } + + + private static IEnumerable SignificantChildren(XElement element) => + element.Nodes().Where(node => + node is XElement + || node is XText text && !string.IsNullOrWhiteSpace(text.Value)); + + + private static bool RequiresGroupedScriptBase(XElement element) + { + var local = element.Name.LocalName; + if (local is not ("mrow" or "mstyle" or "semantics" or "mpadded" or "math")) + return false; + var objectCount = 0; + foreach (var child in SignificantChildren(element)) + { + if (child is XElement childElement + && childElement.Name.LocalName is "annotation" or "annotation-xml") + continue; + objectCount++; + if (objectCount > 1) return true; + } + return false; + } + + + private static void EmitContainerChildren( + XElement element, + List output, + string? inheritedMathVariant) + { + var children = new List(); + foreach (var child in SignificantChildren(element)) + { + if (child is XElement childElement + && (childElement.Name.LocalName == "annotation" + || childElement.Name.LocalName == "annotation-xml")) + continue; + if (string.IsNullOrWhiteSpace(inheritedMathVariant) + || child is not XElement styledChild) + { + children.Add(child); + continue; + } + var clone = new XElement(styledChild); + ApplyInheritedMathVariant(clone, inheritedMathVariant!); + children.Add(clone); + } + + for (var index = 0; index < children.Count; index++) + { + if (children[index] is XElement bigOperatorHead + && TryResolveBigOperatorHead(bigOperatorHead, out var resolvedHead)) + { + var mainNodes = new List(); + var cursor = index + 1; + while (cursor < children.Count && !IsBigOperatorMainBoundary(children[cursor])) + { + mainNodes.Add(CloneNode(children[cursor])); + cursor++; + } + if (mainNodes.Count > 0) + { + var main = new XElement("mrow", mainNodes); + EmitResolvedBigOperator(resolvedHead, main, output); + index = cursor - 1; + continue; + } + } + EmitNode(children[index], output); + } + } + + + private readonly struct ResolvedBigOperatorHead + { + public ResolvedBigOperatorHead( + NativeIntegral integral, + NativeBigOperator bigOperator, + XElement? lower, + XElement? upper) + { + Integral = integral; + BigOperator = bigOperator; + Lower = lower; + Upper = upper; + } + + public NativeIntegral Integral { get; } + public NativeBigOperator BigOperator { get; } + public XElement? Lower { get; } + public XElement? Upper { get; } + public bool IsIntegral => Integral.VariationKind != 0; + } + + + private static bool TryResolveBigOperatorHead( + XElement element, + out ResolvedBigOperatorHead resolved) + { + resolved = default; + var local = element.Name.LocalName; + var children = element.Elements().ToArray(); + XElement? baseElement = element; + XElement? lower = null; + XElement? upper = null; + if (local is "msub" or "msup" or "msubsup" or "munder" or "mover" or "munderover") + { + if (children.Length < 2) return false; + baseElement = children[0]; + if (local is "msub" or "munder") lower = children[1]; + else if (local is "msup" or "mover") upper = children[1]; + else + { + if (children.Length < 3) return false; + lower = children[1]; + upper = children[2]; + } + } + else if (local != "mo") + { + return false; + } + + if (TryResolveNativeIntegral(baseElement, out var integral)) + { + resolved = new ResolvedBigOperatorHead(integral, default, lower, upper); + return true; + } + if (TryResolveNativeBigOperator( + baseElement, + out var bigOperator, + allowAmbiguousUnionIntersection: local != "mo")) + { + resolved = new ResolvedBigOperatorHead(default, bigOperator, lower, upper); + return true; + } + return false; + } + + + private static void EmitResolvedBigOperator( + ResolvedBigOperatorHead resolved, + XElement main, + List output) + { + if (resolved.IsIntegral) + EmitIntegralTemplate( + resolved.Integral, + main, + resolved.Lower, + resolved.Upper, + output); + else + EmitBigOperatorTemplate( + resolved.BigOperator, + main, + resolved.Lower, + resolved.Upper, + output); + } + + + private static bool IsBigOperatorMainBoundary(XNode node) + { + if (node is not XElement element || element.Name.LocalName != "mo") return false; + var value = element.Value.Trim(); + return value is "+" or "-" or "−" or "±" or "∓" + or "=" or "≠" or "<" or ">" or "≤" or "≥" or "≈" or "≃" or "∼" + or "≡" or "∝" or "∈" or "∉" or "⊂" or "⊆" or "⊃" or "⊇" + or "," or ";"; + } + + + private static XNode CloneNode(XNode node) => node switch + { + XElement element => new XElement(element), + XText text => new XText(text.Value), + _ => new XText(node.ToString()), + }; + + + private static void ApplyInheritedMathVariant(XElement element, string mathVariant) + { + if (element.Name.LocalName is "mi" or "mn" or "mo" or "mtext") + { + if (element.Attribute("mathvariant") is null) + element.SetAttributeValue("mathvariant", mathVariant); + return; + } + foreach (var descendant in element.Descendants()) + { + if (descendant.Name.LocalName is not ("mi" or "mn" or "mo" or "mtext")) + continue; + if (descendant.Attribute("mathvariant") is null) + descendant.SetAttributeValue("mathvariant", mathVariant); + } + } + + + private static void EmitFraction(XElement element, List output) + { + var children = element.Elements().ToArray(); + if (children.Length < 2) + throw new InvalidDataException("MathML mfrac requires numerator and denominator."); + + var lineThickness = ((string?)element.Attribute("linethickness") ?? string.Empty).Trim(); + if (lineThickness.Length > 0 && lineThickness.StartsWith("0", StringComparison.Ordinal)) + { + // MathJax represents \binom and related stacked expressions as an + // mfrac with a zero rule. MathType's native equivalent is a centered + // PILE, not the ordinary fraction template (which always draws a rule). + output.AddRange(new byte[] + { + RecordPile, + 0, + 2, // horizontal alignment: centered + 1, // vertical alignment: centered + }); + EmitLine(children[0], output); + EmitLine(children[1], output); + output.Add(RecordEnd); + return; + } + + output.AddRange(new byte[] { RecordTemplate, 0, TemplateFraction, 0, 0 }); + EmitLine(children[0], output); + EmitLine(children[1], output); + output.Add(RecordEnd); + output.Add(RecordFull); + } + + + private static void EmitSquareRoot(XElement element, List output) + { + output.AddRange(new byte[] { RecordTemplate, 0, TemplateRoot, 0, 0 }); + EmitLineContents(SignificantChildren(element), output); + output.Add(RecordSub); + output.AddRange(new byte[] { RecordLine, LineNull }); + output.Add(RecordEnd); + output.Add(RecordFull); + } + + + private static void EmitNthRoot(XElement element, List output) + { + var children = element.Elements().ToArray(); + if (children.Length < 2) + throw new InvalidDataException("MathML mroot requires radicand and index."); + output.AddRange(new byte[] { RecordTemplate, 0, TemplateRoot, 1, 0 }); + EmitLine(children[0], output); + output.Add(RecordSub); + EmitLine(children[1], output); + output.Add(RecordEnd); + output.Add(RecordFull); + } + + + private static void EmitScript(XElement element, byte selector, List output) + { + var children = element.Elements().ToArray(); + var required = selector == TemplateSubSup ? 3 : 2; + if (children.Length < required) + throw new InvalidDataException($"MathML {element.Name.LocalName} has too few children."); + if (TryResolveNativeIntegral(children[0], out var integral)) + { + EmitIntegralTemplate( + integral, + main: null, + lower: selector is TemplateSub or TemplateSubSup ? children[1] : null, + upper: selector == TemplateSup ? children[1] : selector == TemplateSubSup ? children[2] : null, + output); + return; + } + if (TryResolveNativeBigOperator( + children[0], + out var bigOperator, + allowAmbiguousUnionIntersection: true)) + { + EmitBigOperatorTemplate( + bigOperator, + main: null, + lower: selector is TemplateSub or TemplateSubSup ? children[1] : null, + upper: selector == TemplateSup ? children[1] : selector == TemplateSubSup ? children[2] : null, + output); + return; + } + if (IsNamedLimitOperator(children[0])) + { + EmitLimitTemplate( + children[0], + selector is TemplateSub or TemplateSubSup ? children[1] : null, + selector == TemplateSup ? children[1] : selector == TemplateSubSup ? children[2] : null, + output); + return; + } + // MathType's postfix script template binds to exactly one preceding MTEF + // object. Flattening a multi-object MathML mrow here makes a script on + // `(1+1/n)` bind only to the final ')' when Equation Native is read back. + // Preserve the whole composite base as one nested LINE object before + // appending the script template. Atomic bases keep the old path so common + // x^2 / a_i equations remain byte- and layout-compatible. + if (RequiresGroupedScriptBase(children[0])) + EmitLine(children[0], output); + else + EmitNode(children[0], output); + output.AddRange(new byte[] { RecordTemplate, 0, selector, 0, 0 }); + output.Add(RecordSub); + if (selector == TemplateSub) + { + EmitLine(children[1], output); + output.AddRange(new byte[] { RecordLine, LineNull }); + } + else if (selector == TemplateSup) + { + output.AddRange(new byte[] { RecordLine, LineNull }); + EmitLine(children[1], output); + } + else + { + EmitLine(children[1], output); + EmitLine(children[2], output); + } + output.Add(RecordEnd); + output.Add(RecordFull); + } + + + private static void EmitOver(XElement element, List output) + { + var children = element.Elements().ToArray(); + if (children.Length < 2) + { + EmitContainerChildren(element, output, inheritedMathVariant: null); + return; + } + if (TryResolveNativeIntegral(children[0], out var integral)) + { + EmitIntegralTemplate(integral, main: null, lower: null, upper: children[1], output); + return; + } + if (TryResolveNativeBigOperator( + children[0], + out var bigOperator, + allowAmbiguousUnionIntersection: true)) + { + EmitBigOperatorTemplate( + bigOperator, + main: null, + lower: null, + upper: children[1], + output); + return; + } + if (TryGetAnnotatedHorizontalBraceBody(children[0], top: true, out var annotatedBraceBody)) + { + EmitHorizontalFenceTemplate( + TemplateHorizontalBrace, + top: true, + annotatedBraceBody, + children[1], + output); + return; + } + var over = NormalizeAccentMark(children[1].Value.Trim()); + var embellishmentBody = UnwrapSingleTokenAccentBody(children[0]); + if (embellishmentBody is not null + && TryEmitSingleCharacterEmbellishment(embellishmentBody, over, output)) return; + switch (over) + { + case "\u00AF": + case "\u203E": + case "\u2015": + case "\u02C9": + EmitSingleSlotTemplate(TemplateOverbar, 0, children[0], output); + return; + case "→": + EmitVectorTemplate(0x02, 0x20D7, children[0], output); + return; + case "←": + EmitVectorTemplate(0x01, 0x20D6, children[0], output); + return; + case "↔": + EmitVectorTemplate(0x03, 0x20E1, children[0], output); + return; + case "^": + case "ˆ": + EmitSingleSlotTemplate(TemplateHat, 0, children[0], output); + return; + case "~": + case "˜": + EmitSingleSlotTemplate(TemplateTilde, 0, children[0], output); + return; + case "⌢": + case "⏜": + EmitSingleSlotTemplate(TemplateArc, 0, children[0], output); + return; + case "⏞": + EmitHorizontalFenceTemplate( + TemplateHorizontalBrace, + top: true, + children[0], + annotation: null, + output); + return; + } + EmitNode(children[0], output); + EmitTrailingScript(children[1], isSubscript: false, output); + } + + + private static void EmitUnder(XElement element, List output) + { + var children = element.Elements().ToArray(); + if (children.Length < 2) + { + EmitContainerChildren(element, output, inheritedMathVariant: null); + return; + } + if (TryResolveNativeIntegral(children[0], out var integral)) + { + EmitIntegralTemplate(integral, main: null, lower: children[1], upper: null, output); + return; + } + if (TryResolveNativeBigOperator( + children[0], + out var bigOperator, + allowAmbiguousUnionIntersection: true)) + { + EmitBigOperatorTemplate( + bigOperator, + main: null, + lower: children[1], + upper: null, + output); + return; + } + if (TryGetAnnotatedHorizontalBraceBody(children[0], top: false, out var annotatedBraceBody)) + { + EmitHorizontalFenceTemplate( + TemplateHorizontalBrace, + top: false, + annotatedBraceBody, + children[1], + output); + return; + } + if (IsNamedLimitOperator(children[0])) + { + EmitLimitTemplate(children[0], children[1], upper: null, output); + return; + } + var under = children[1].Value.Trim(); + if (under is "_" or "¯" or "‾" or "―" or "ˉ" or "̅" or "̲") + { + EmitSingleSlotTemplate(TemplateUnderbar, 0, children[0], output); + return; + } + if (under == "⏟") + { + EmitHorizontalFenceTemplate( + TemplateHorizontalBrace, + top: false, + children[0], + annotation: null, + output); + return; + } + EmitNode(children[0], output); + EmitTrailingScript(children[1], isSubscript: true, output); + } + + + private static void EmitUnderOver(XElement element, List output) + { + var children = element.Elements().ToArray(); + if (children.Length < 3) + { + EmitContainerChildren(element, output, inheritedMathVariant: null); + return; + } + if (TryResolveNativeIntegral(children[0], out var integral)) + { + EmitIntegralTemplate( + integral, + main: null, + lower: children[1], + upper: children[2], + output); + return; + } + if (TryResolveNativeBigOperator( + children[0], + out var bigOperator, + allowAmbiguousUnionIntersection: true)) + { + EmitBigOperatorTemplate( + bigOperator, + main: null, + lower: children[1], + upper: children[2], + output); + return; + } + if (IsNamedLimitOperator(children[0])) + { + EmitLimitTemplate(children[0], children[1], children[2], output); + return; + } + EmitNode(children[0], output); + output.AddRange(new byte[] { RecordTemplate, 0, TemplateSubSup, 0, 0, RecordSub }); + EmitLine(children[1], output); + EmitLine(children[2], output); + output.Add(RecordEnd); + output.Add(RecordFull); + } + + + private readonly struct NativeIntegral + { + public NativeIntegral(int variationKind, int integralCount) + { + VariationKind = variationKind; + IntegralCount = integralCount; + } + + public int VariationKind { get; } + public int IntegralCount { get; } + } + + + private static bool TryResolveNativeIntegral( + XElement element, + out NativeIntegral integral) + { + integral = default; + if (element.Name.LocalName != "mo") return false; + integral = element.Value.Trim() switch + { + "∫" => new NativeIntegral(0x01, 1), + "∬" => new NativeIntegral(0x02, 2), + "∭" => new NativeIntegral(0x03, 3), + "∮" => new NativeIntegral(0x05, 1), + "∲" => new NativeIntegral(0x08, 1), + "∳" => new NativeIntegral(0x0C, 1), + _ => default, + }; + return integral.VariationKind != 0; + } + + + private static void EmitIntegralTemplate( + NativeIntegral integral, + XElement? main, + XElement? lower, + XElement? upper, + List output) + { + // MathType 7 uses selector 15 for the integral family. Genuine native + // equations encode lower/upper presence in 0x10/0x20 and the integral + // kind in the low nibble (1=single, 2=double, 3=triple, 5=contour). + // A contour template also requires MathType Extra's loop adornment + // character before the ordinary integral glyph. Variation 4 with only + // the integral character makes MathPage access invalid native state. + var variation = integral.VariationKind + | (lower is null ? 0 : 0x10) + | (upper is null ? 0 : 0x20); + EmitTemplateHeader(TemplateIntegral, variation, output); + if (main is null) output.AddRange(new byte[] { RecordLine, LineNull }); + else EmitLine(main, output); + // MathType stores integral limits in SUB size. SIZE records are stateful; + // omitting this transition makes the lower/upper slots inherit FULL and + // renders limits visibly too large even though the MTEF remains readable. + if (lower is not null || upper is not null) + output.Add(RecordSub); + if (lower is null) output.AddRange(new byte[] { RecordLine, LineNull }); + else EmitLine(lower, output); + if (upper is null) output.AddRange(new byte[] { RecordLine, LineNull }); + else EmitLine(upper, output); + output.Add(RecordSym); + if (integral.VariationKind == 0x05) + { + // Exact bytes emitted by genuine MathType 7 for the closed-loop + // adornment: fnMTEXTRA, MTCode U+EE11, legacy position 0xD1. + EmitScalar( + 0xEE11, + TypefaceMtExtra, + output, + includeEncoded8: true, + encoded8Override: 0xD1); + } + for (var index = 0; index < integral.IntegralCount; index++) + { + EmitScalar( + 0x222B, + TypefaceSymbol, + output, + includeEncoded8: true, + encoded8Override: 0xF2); + } + output.Add(RecordEnd); + // SYM is a persistent MTEF typesize state. Genuine MathType BigOp + // equations normally place the complete integrand in the template's + // main slot, so there is no ordinary sibling text after the symbol. + // LaTeXSnipper can encounter presentation MathML where the integrand is a + // following sibling; always restore FULL after the template so that + // subsequent ordinary characters cannot inherit symbol size. + output.Add(RecordFull); + } + + + private readonly struct NativeBigOperator + { + public NativeBigOperator( + byte selector, + int mtCode, + int typeface, + byte encoded8) + { + Selector = selector; + MtCode = mtCode; + Typeface = typeface; + Encoded8 = encoded8; + } + + public byte Selector { get; } + public int MtCode { get; } + public int Typeface { get; } + public byte Encoded8 { get; } + } + + + private static bool TryResolveNativeBigOperator( + XElement element, + out NativeBigOperator bigOperator, + bool allowAmbiguousUnionIntersection = false) + { + bigOperator = default; + if (element.Name.LocalName != "mo") return false; + var value = element.Value.Trim(); + var explicitBigOperator = string.Equals( + ((string?)element.Attribute("data-mjx-texclass"))?.Trim(), + "OP", + StringComparison.OrdinalIgnoreCase) + || string.Equals( + ((string?)element.Attribute("movablelimits"))?.Trim(), + "true", + StringComparison.OrdinalIgnoreCase); + bigOperator = value switch + { + "∑" => new NativeBigOperator(TemplateSum, 0x2211, TypefaceSymbol, 0xE5), + "∏" => new NativeBigOperator(TemplateProduct, 0x220F, TypefaceSymbol, 0xD5), + "∐" => new NativeBigOperator(TemplateCoproduct, 0x2210, TypefaceMtExtra, 0x43), + "⋃" => new NativeBigOperator(TemplateUnion, 0x222A, TypefaceMtExtra, 0x55), + "⋂" => new NativeBigOperator(TemplateIntersection, 0x2229, TypefaceMtExtra, 0x49), + "∪" when allowAmbiguousUnionIntersection || explicitBigOperator + => new NativeBigOperator(TemplateUnion, 0x222A, TypefaceMtExtra, 0x55), + "∩" when allowAmbiguousUnionIntersection || explicitBigOperator + => new NativeBigOperator(TemplateIntersection, 0x2229, TypefaceMtExtra, 0x49), + _ => default, + }; + return bigOperator.Selector != 0; + } + + + private static void EmitBigOperatorTemplate( + NativeBigOperator bigOperator, + XElement? main, + XElement? lower, + XElement? upper, + List output) + { + // MathType 7 native BigOp storage observed from genuine Equation.DSMT4: + // variation 0x70 and line slots main/lower/upper followed by SYM + CHAR. + EmitTemplateHeader(bigOperator.Selector, 0x70, output); + if (main is null) output.AddRange(new byte[] { RecordLine, LineNull }); + else EmitLine(main, output); + // Sum/product/union/intersection limits use the same script-size state as + // native MathType. Without SUB, both limits are interpreted at FULL size. + if (lower is not null || upper is not null) + output.Add(RecordSub); + if (lower is null) output.AddRange(new byte[] { RecordLine, LineNull }); + else EmitLine(lower, output); + if (upper is null) output.AddRange(new byte[] { RecordLine, LineNull }); + else EmitLine(upper, output); + output.Add(RecordSym); + EmitScalar( + bigOperator.MtCode, + bigOperator.Typeface, + output, + includeEncoded8: true, + encoded8Override: bigOperator.Encoded8); + output.Add(RecordEnd); + // Do not leak the SYM typesize used by the large operator glyph into + // following siblings. MathType treats FULL/SUB/SYM as size state. + output.Add(RecordFull); + } + + + private static bool IsNamedLimitOperator(XElement element) + { + if (element.Name.LocalName is not ("mi" or "mo")) return false; + var value = element.Value.Trim(); + if (value.Length <= 1 || !value.All(char.IsLetter)) return false; + var movableLimits = string.Equals( + (string?)element.Attribute("movablelimits"), + "true", + StringComparison.OrdinalIgnoreCase); + var texClass = ((string?)element.Attribute("data-mjx-texclass") ?? string.Empty).Trim(); + var variant = ((string?)element.Attribute("mathvariant") ?? string.Empty).Trim(); + return movableLimits + || string.Equals(texClass, "OP", StringComparison.OrdinalIgnoreCase) + || variant.IndexOf("normal", StringComparison.OrdinalIgnoreCase) >= 0 + || variant.IndexOf("upright", StringComparison.OrdinalIgnoreCase) >= 0; + } + + + private static void EmitLimitTemplate( + XElement operatorElement, + XElement? lower, + XElement? upper, + List output) + { + // Genuine MathType 7 stores named operators such as max/min/lim/sup/inf + // as function-style CHAR records followed by the ordinary sub/sup + // template. Writing selector 23 (tmLIM) here produced Equation Native + // streams that our parser accepted but MathType could hang while opening. + var name = operatorElement.Value.Trim(); + EmitFunctionRun(name, output); + if (lower is null && upper is null) return; + + var selector = lower is not null && upper is not null + ? TemplateSubSup + : lower is not null + ? TemplateSub + : TemplateSup; + output.AddRange(new byte[] + { + RecordTemplate, + 0, + selector, + 0, + 0, + RecordSub, + }); + if (lower is null) + output.AddRange(new byte[] { RecordLine, LineNull }); + else + EmitLine(lower, output); + if (upper is null) + output.AddRange(new byte[] { RecordLine, LineNull }); + else + EmitLine(upper, output); + output.Add(RecordEnd); + output.Add(RecordFull); + } + + + private static void EmitFunctionRun(string name, List output) + { + var functionScalars = EnumerateBmpScalars(name).ToArray(); + for (var index = 0; index < functionScalars.Length; index++) + { + EmitScalar( + functionScalars[index], + TypefaceFunction, + output, + functionStart: index == 0); + } + } + + + private static void EmitTrailingScript( + XElement script, + bool isSubscript, + List output) + { + output.AddRange(new byte[] + { + RecordTemplate, + 0, + isSubscript ? TemplateSub : TemplateSup, + 0, + 0, + RecordSub, + }); + if (isSubscript) + { + EmitLine(script, output); + output.AddRange(new byte[] { RecordLine, LineNull }); + } + else + { + output.AddRange(new byte[] { RecordLine, LineNull }); + EmitLine(script, output); + } + output.Add(RecordEnd); + output.Add(RecordFull); + } + + + private static void EmitSingleSlotTemplate( + byte selector, + int variation, + XElement body, + List output) + { + EmitTemplateHeader(selector, variation, output); + EmitLine(body, output); + output.Add(RecordEnd); + output.Add(RecordFull); + } + + + private static void EmitVectorTemplate( + int variation, + int combiningArrow, + XElement body, + List output) + { + // Genuine MathType 7 tmVEC HatBox records are not a generic one-slot + // template. After the body line MathType persists the selected combining + // arrow as an explicit MTCode character using its expanding-glyph + // typeface. Omitting this trailing character makes Word accept the CFB + // initially but reject/rematerialize its OLE presentation for multi-token + // vectors such as \overrightarrow{AB} / \overleftrightarrow{AB}. + EmitTemplateHeader(TemplateVector, variation, output); + EmitColor(0, output); + EmitLine(body, output); + EmitScalar( + combiningArrow, + TypefaceFence, + output, + includeEncoded8: false); + output.Add(RecordEnd); + output.Add(RecordFull); + } + + + private static bool TryGetAnnotatedHorizontalBraceBody( + XElement candidate, + bool top, + out XElement body) + { + body = null!; + var current = candidate; + while (current.Name.LocalName == "mrow") + { + var wrapped = current.Elements().ToArray(); + if (wrapped.Length != 1) break; + current = wrapped[0]; + } + if (current.Name.LocalName != (top ? "mover" : "munder")) return false; + var inner = current.Elements().ToArray(); + if (inner.Length < 2 || inner[1].Name.LocalName != "mo") return false; + var marker = inner[1].Value.Trim(); + var expected = top ? "⏞" : "⏟"; + if (!IsHorizontalBraceMarker(marker, top)) return false; + body = inner[0]; + return true; + } + + + private static void EmitHorizontalFenceTemplate( + byte selector, + bool top, + XElement body, + XElement? annotation, + List output) + { + EmitTemplateHeader(selector, top ? 1 : 0, output); + + // Match MathType 7's native HorizontalBrace/HorizontalBracket layout. + // A real Equation.DSMT4 stores the main body at full size, switches to + // SUB for the annotation, restores FULL, then writes the private fence + // glyph using the expanding-fence typeface. Writing the public Unicode + // U+23DE/U+23DF operators here produces an MTEF stream that LaTeXSnipper can + // parse itself but that MathType may reject while activating the OLE. + EmitColor(0, output); + EmitMathTypeColoredLine(body, output, resetColorAfter: false); + output.Add(RecordSub); + EmitColor(0, output); + if (annotation is null) + output.AddRange(new byte[] { RecordLine, LineNull }); + else + EmitMathTypeColoredLine(annotation, output, resetColorAfter: false); + output.Add(RecordFull); + EmitScalar( + top ? 0xFE37 : 0xFE38, + TypefaceFence, + output, + includeEncoded8: false); + output.Add(RecordEnd); + } + + + private static void EmitTemplateHeader(byte selector, int variation, List output) + { + output.Add(RecordTemplate); + output.Add(0); + output.Add(selector); + if (variation < 0x80) + { + output.Add((byte)variation); + } + else + { + output.Add((byte)(0x80 | (variation & 0x7F))); + output.Add((byte)(variation >> 8)); + } + output.Add(0); + } + + + private static XElement? UnwrapSingleTokenAccentBody(XElement body) + { + var current = body; + while (current.Name.LocalName == "mrow") + { + var children = current.Elements().ToArray(); + if (children.Length != 1) break; + current = children[0]; + } + return current.Name.LocalName is "mi" or "mn" or "mo" ? current : null; + } + + + private static string NormalizeAccentMark(string mark) => mark switch + { + "\u0305" or "\u203E" or "\u2015" or "\u02C9" => "¯", + "\u0302" or "ˆ" => "^", + "\u0303" or "˜" => "~", + "\u20D7" => "→", + "\u20D6" => "←", + "\u20E1" => "↔", + "." or "\u0307" => "˙", + "\u0308" => "¨", + "\u030C" => "ˇ", + "\u0306" => "˘", + "\u0301" => "´", + "\u0300" => "`", + "\u030A" => "˚", + _ => mark, + }; + + + private static bool TryEmitSingleCharacterEmbellishment( + XElement body, + string over, + List output) + { + var code = NormalizeAccentMark(over) switch + { + "." or "˙" => EmbellDot, + "¨" => EmbellDoubleDot, + "~" or "˜" => EmbellTilde, + "^" or "ˆ" => EmbellHat, + "→" => EmbellRightArrow, + "←" => EmbellLeftArrow, + "↔" => EmbellBothArrow, + "\u00AF" or "\u203E" or "\u2015" or "\u02C9" => EmbellOverbar, + _ => (byte)0, + }; + if (code == 0 || body.Name.LocalName is not ("mi" or "mn" or "mo")) + return false; + var scalars = EnumerateBmpScalars(body.Value).ToArray(); + if (scalars.Length != 1) return false; + var typeface = ResolveTokenTypeface(body, scalars[0]); + EmitScalar( + scalars[0], + typeface, + output, + includeEncoded8: body.Name.LocalName == "mo" && scalars[0] <= 0xFF, + embellishments: new[] { code }); + return true; + } + + + private static bool TryEmitAlignedPile( + XElement element, + List output) + { + var rows = element.Elements() + .Where(row => row.Name.LocalName is "mtr" or "mlabeledtr") + .ToArray(); + if (rows.Length == 0) return false; + + var cells = rows.Select(row => row.Elements() + .Where(cell => cell.Name.LocalName == "mtd") + .ToArray()) + .ToArray(); + var columnCount = cells.Max(row => row.Length); + if (columnCount < 2 || (columnCount & 1) != 0) return false; + + var columnAlignment = ((string?)element.Attribute("columnalign") ?? string.Empty) + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (columnAlignment.Length < columnCount) return false; + for (var column = 0; column < columnCount; column++) + { + var expected = (column & 1) == 0 ? "right" : "left"; + if (!string.Equals( + columnAlignment[column], + expected, + StringComparison.OrdinalIgnoreCase)) + return false; + } + + var pairCount = columnCount / 2; + var rulerText = ((string?)element.Attribute(MtefRulerStopsAttribute) ?? string.Empty) + .Trim(); + var rulerStops = rulerText.Length == 0 + ? Array.Empty() + : rulerText.Split(',') + .Select(value => int.TryParse( + value.Trim(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var parsed) + ? parsed + : -1) + .ToArray(); + var hasExactRuler = rulerStops.Length == pairCount + && rulerStops.All(stop => stop > 0 && stop <= ushort.MaxValue) + && rulerStops.Zip(rulerStops.Skip(1), (left, right) => right > left).All(valid => valid); + if (!hasExactRuler) return false; + + // MathType's true multi-point alignment has two distinct fnMARKER + // primitives. U+0009 starts a tab group; MTCode 0xEF00 is MathType's + // non-printing alignment symbol inside that group. With an explicit + // RULER, each group is positioned so its alignment symbol lands exactly + // on the supplied stop. This is the native equivalent of one TeX + // right/left `&` pair. A plain sequence of Ctrl+Tab markers is not enough: + // MathType's default stops are 0.5in apart, so a wide left operand can + // skip a stop and visibly misalign one row. + output.AddRange(new byte[] + { + RecordPile, + 0x02, + 1, // horizontal alignment: left; tab groups own their reference points + 1, // vertical alignment: center line + }); + // LP_RULER is an inline field of LINE/PILE. MathType's native MTEF + // readers consume n_stops immediately after the alignment bytes; the + // enclosing option bit already identifies the payload as a RULER. + // Emitting an additional standalone record tag (7) here shifts every + // following byte and MathPage renders the equation blank. + output.Add((byte)rulerStops.Length); + foreach (var stop in rulerStops) + { + output.Add(0); // left tab; an in-group alignment symbol overrides it + output.Add((byte)(stop & 0xff)); + output.Add((byte)((stop >> 8) & 0xff)); + } + + foreach (var row in cells) + { + output.AddRange(new byte[] { RecordLine, 0 }); + for (var pair = 0; pair < pairCount; pair++) + { + // Start the group at the next explicit ruler stop, then place + // the TeX pair boundary at MathType's own alignment symbol. + EmitScalar('\t', TypefaceMarker, output, includeEncoded8: false); + var leftColumn = pair * 2; + var rightColumn = leftColumn + 1; + if (leftColumn < row.Length) + EmitContainerChildren(row[leftColumn], output, inheritedMathVariant: null); + EmitScalar( + MathTypeAlignmentMarkerMtCode, + TypefaceMarker, + output, + includeEncoded8: false); + if (rightColumn < row.Length) + EmitContainerChildren(row[rightColumn], output, inheritedMathVariant: null); + } + output.Add(RecordEnd); + } + output.Add(RecordEnd); + return true; + } + + + private static void EmitMatrix(XElement element, List output) + { + var rows = element.Elements() + .Where(row => row.Name.LocalName is "mtr" or "mlabeledtr") + .ToArray(); + if (rows.Length == 0) + { + output.AddRange(new byte[] { RecordMatrix, 0, 1, 2, 1, 1, 1, 0, 0 }); + output.AddRange(new byte[] { RecordLine, LineNull, RecordEnd, RecordFull }); + return; + } + var cells = rows.Select(row => row.Elements() + .Where(cell => cell.Name.LocalName == "mtd") + .ToArray()) + .ToArray(); + var columnCount = Math.Max(1, cells.Max(row => row.Length)); + if (rows.Length > byte.MaxValue || columnCount > byte.MaxValue) + throw new InvalidDataException("MathType MTEF matrix exceeds 255 rows or columns."); + + output.AddRange(new byte[] + { + RecordMatrix, + 0, + 1, // vertical alignment: center line + 1, // horizontal cell justification: centered (MathType 7 native value) + 1, // vertical cell justification: baseline center + (byte)rows.Length, + (byte)columnCount, + }); + for (var index = 0; index < PartitionByteCount(rows.Length + 1); index++) output.Add(0); + for (var index = 0; index < PartitionByteCount(columnCount + 1); index++) output.Add(0); + for (var row = 0; row < rows.Length; row++) + { + for (var column = 0; column < columnCount; column++) + { + var isLastCell = row == rows.Length - 1 && column == columnCount - 1; + if (column < cells[row].Length) + EmitMathTypeColoredLine(cells[row][column], output, resetColorAfter: !isLastCell); + else output.AddRange(new byte[] { RecordLine, LineNull }); + } + } + output.Add(RecordEnd); + } + + + private static int PartitionByteCount(int partitionCount) => + Math.Max(1, (partitionCount + 3) / 4); + + + private static void EmitEnclose(XElement element, List output) + { + var notation = ((string?)element.Attribute("notation") ?? string.Empty) + .ToLowerInvariant(); + var significant = SignificantChildren(element).ToArray(); + var body = significant.Length == 1 && significant[0] is XElement singleElement + ? new XElement(singleElement) + : new XElement("mrow", significant); + if (notation.Contains("radical")) + { + EmitSquareRoot(new XElement("msqrt", new XElement(body)), output); + return; + } + if (notation.Contains("box")) + { + EmitSingleSlotTemplate(TemplateBox, 0x1E, body, output); + return; + } + var strikeVariation = 0; + if (notation.Contains("horizontalstrike")) strikeVariation |= 0x01; + if (notation.Contains("updiagonalstrike")) strikeVariation |= 0x02; + if (notation.Contains("downdiagonalstrike")) strikeVariation |= 0x04; + if (strikeVariation != 0) + { + EmitSingleSlotTemplate(TemplateStrike, strikeVariation, body, output); + return; + } + EmitNode(body, output); + } + + + private static void EmitMultiScripts(XElement element, List output) + { + var children = element.Elements().ToArray(); + if (children.Length == 0) return; + if (RequiresGroupedScriptBase(children[0])) EmitLine(children[0], output); + else EmitNode(children[0], output); + var index = 1; + while (index < children.Length + && children[index].Name.LocalName != "mprescripts") + { + var sub = children[index]; + var sup = index + 1 < children.Length ? children[index + 1] : null; + if (sub.Name.LocalName == "none" && (sup is null || sup.Name.LocalName == "none")) + { + index += 2; + continue; + } + if (sub.Name.LocalName == "none" && sup is not null) + EmitTrailingScript(sup, isSubscript: false, output); + else if (sup is null || sup.Name.LocalName == "none") + EmitTrailingScript(sub, isSubscript: true, output); + else + { + output.AddRange(new byte[] { RecordTemplate, 0, TemplateSubSup, 0, 0, RecordSub }); + EmitLine(sub, output); + EmitLine(sup, output); + output.Add(RecordEnd); + output.Add(RecordFull); + } + index += 2; + } + // MTEF v5 tvSU_PRECEDES=1 positions the script to the left of its base. + // The record still follows its base in the object list; both script slots + // are serialized, with LINE_NULL for a genuinely absent half. + if (index < children.Length && children[index].Name.LocalName == "mprescripts") + { + index++; + while (index < children.Length) + { + var sub = children[index]; + var sup = index + 1 < children.Length ? children[index + 1] : null; + var hasSub = sub.Name.LocalName != "none"; + var hasSup = sup is not null && sup.Name.LocalName != "none"; + if (hasSub || hasSup) + { + var selector = hasSub && hasSup ? TemplateSubSup : hasSub ? TemplateSub : TemplateSup; + output.AddRange(new byte[] { RecordTemplate, 0, selector, 1, 0, RecordSub }); + if (hasSub) EmitLine(sub, output); + else output.AddRange(new byte[] { RecordLine, LineNull }); + if (hasSup) EmitLine(sup!, output); + else output.AddRange(new byte[] { RecordLine, LineNull }); + output.Add(RecordEnd); + output.Add(RecordFull); + } + index += 2; + } + } + } + + + private static void EmitFenced(XElement element, List output) + { + var open = NormalizeFence((string?)element.Attribute("open") ?? "("); + var close = NormalizeFence((string?)element.Attribute("close") ?? ")"); + var selector = SelectFenceTemplate(open, close); + if (selector is null) + { + if (!string.IsNullOrEmpty(open)) EmitOperator(open, output); + foreach (var child in SignificantChildren(element)) EmitNode(child, output); + if (!string.IsNullOrEmpty(close)) EmitOperator(close, output); + return; + } + var variation = (string.IsNullOrEmpty(open) ? 0 : 1) + | (string.IsNullOrEmpty(close) ? 0 : 2); + EmitTemplateHeader(selector.Value, variation, output); + EmitColor(0, output); + EmitLineContents(SignificantChildren(element), output); + if (!string.IsNullOrEmpty(open)) EmitFenceCharacter(open, opening: true, output); + if (!string.IsNullOrEmpty(close)) EmitFenceCharacter(close, opening: false, output); + output.Add(RecordEnd); + } + + + private static void EmitColor(int index, List output) + { + output.Add(RecordColor); + WriteUnsigned(output, ColorStates.GetOrCreateValue(output).Current); + } + + + private static void EmitMathTypeColoredLine( + XElement element, + List output, + bool resetColorAfter) + { + output.AddRange(new byte[] { RecordLine, 0 }); + EmitColor(1, output); + EmitNode(element, output); + output.Add(RecordEnd); + if (resetColorAfter) EmitColor(0, output); + } + + + private static void EmitFenceCharacter( + string value, + bool opening, + List output) + { + foreach (var scalar in EnumerateBmpScalars(value)) + { + // MathType's fnFENCE typeface is MTCode-encoded rather than a + // generic Unicode font. These values are captured from genuine + // MathType 7 Equation Native streams for the same expandable + // delimiters. Some delimiters (| and ||) use the same Unicode text + // on both sides but distinct left/right MTCode glyphs, so the side + // of the fence is part of the mapping. + var mtCode = scalar switch + { + '(' => 0x0028, + ')' => 0x0029, + '[' => 0x005B, + ']' => 0x005D, + '{' => 0x007B, + '}' => 0x007D, + 0x27E8 => 0x2329, // \langle + 0x27E9 => 0x232A, // \rangle + '|' => opening ? 0xEC07 : 0xEC08, + 0x2016 => opening ? 0xEC09 : 0xEC0A, // \| / norm bars + 0x230A => 0xF8F0, // \lfloor + 0x230B => 0xF8FB, // \rfloor + 0x2308 => 0xF8EE, // \lceil + 0x2309 => 0xF8F9, // \rceil + _ => scalar, + }; + EmitScalar(mtCode, TypefaceFence, output, includeEncoded8: false); + } + } + + + private static string NormalizeFence(string value) => value switch + { + "." => string.Empty, + "〈" or "〈" or "⟨" => "⟨", + "〉" or "〉" or "⟩" => "⟩", + _ => value, + }; + + + private static byte? SelectFenceTemplate(string open, string close) + { + var probe = !string.IsNullOrEmpty(open) ? open : close; + if ((open is "⟨" or "〈" || string.IsNullOrEmpty(open)) + && (close is "⟩" or "〉" || string.IsNullOrEmpty(close)) + && probe is "⟨" or "〈" or "⟩" or "〉") return TemplateAngle; + if ((open == "(" || string.IsNullOrEmpty(open)) + && (close == ")" || string.IsNullOrEmpty(close)) + && probe is "(" or ")") return TemplateParen; + if ((open == "[" || string.IsNullOrEmpty(open)) + && (close == "]" || string.IsNullOrEmpty(close)) + && probe is "[" or "]") return TemplateBracket; + if ((open == "{" || string.IsNullOrEmpty(open)) + && (close == "}" || string.IsNullOrEmpty(close)) + && probe is "{" or "}") return TemplateBrace; + if ((open == "|" || string.IsNullOrEmpty(open)) + && (close == "|" || string.IsNullOrEmpty(close)) + && probe == "|") return TemplateBar; + if ((open is "‖" or "||" || string.IsNullOrEmpty(open)) + && (close is "‖" or "||" || string.IsNullOrEmpty(close)) + && probe is "‖" or "||") return TemplateDoubleBar; + if ((open == "⌊" || string.IsNullOrEmpty(open)) + && (close == "⌋" || string.IsNullOrEmpty(close)) + && probe is "⌊" or "⌋") return TemplateFloor; + if ((open == "⌈" || string.IsNullOrEmpty(open)) + && (close == "⌉" || string.IsNullOrEmpty(close)) + && probe is "⌈" or "⌉") return TemplateCeiling; + return null; + } + + + private static void EmitLine(XElement element, List output) + { + output.AddRange(new byte[] { RecordLine, 0 }); + EmitNode(element, output); + output.Add(RecordEnd); + } + + + private static void EmitLineContents(IEnumerable nodes, List output) + { + output.AddRange(new byte[] { RecordLine, 0 }); + foreach (var node in nodes) EmitNode(node, output); + output.Add(RecordEnd); + } + +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.cs new file mode 100644 index 00000000..b2bab47d --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeMtefCodec.cs @@ -0,0 +1,472 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Globalization; +using System.Text; +using System.Xml.Linq; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +/// +/// Dependency-free MTEF v5 structural codec for creating standalone native +/// equations and reading their current content without an equation server. +/// +internal static partial class MathTypeMtefCodec +{ + private const byte MtefVersion5 = 5; + private const byte RecordEnd = 0; + private const byte RecordLine = 1; + private const byte RecordChar = 2; + private const byte RecordTemplate = 3; + private const byte RecordPile = 4; + private const byte RecordMatrix = 5; + private const byte RecordEmbellishment = 6; + private const byte RecordFontStyleDef = 8; + private const byte RecordSize = 9; + private const byte RecordFull = 10; + private const byte RecordSub = 11; + private const byte RecordSym = 13; + private const byte RecordSubSym = 14; + private const byte RecordColor = 15; + private const byte RecordColorDef = 16; + private const byte RecordFontDef = 17; + private const byte RecordEqnPrefs = 18; + private const byte RecordEncodingDef = 19; + + private const byte LineNull = 0x01; + private const byte CharHasEmbellishment = 0x01; + private const byte CharFunctionStart = 0x02; + private const byte CharEncoded8 = 0x04; + + private const byte TypefaceText = 1; + private const byte TypefaceFunction = 2; + private const byte TypefaceVariable = 3; + private const byte TypefaceLowerGreek = 4; + private const byte TypefaceUpperGreek = 5; + private const byte TypefaceSymbol = 6; + private const byte TypefaceVector = 7; + private const byte TypefaceNumber = 8; + private const byte TypefaceMtExtra = 11; + // MathType 7 uses built-in typeface 12 for a small set of native glyphs + // such as reverse membership (\\ni) and \\bigcirc. These are not Adobe + // Symbol positions and carry no encoded8 byte. + private const byte TypefaceMathTypeSpecial12 = 12; + private const byte TypefaceMarker = 23; + // MathType 7 persists expanding fence characters ((), [], {}, |, ...) + // with typeface 22 and MTCode, not Symbol style. Using Symbol here makes + // the same ASCII code point resolve to a different fence glyph. + private const byte TypefaceFence = 22; + private const byte TypefaceSpace = 24; + + private const int MathTypeAlignmentMarkerMtCode = 0xEF00; + private const string MtefRulerStopsAttribute = + "data-mtef-ruler-stops"; + + private const byte TemplateAngle = 0; + private const byte TemplateParen = 1; + private const byte TemplateBrace = 2; + private const byte TemplateBracket = 3; + private const byte TemplateBar = 4; + private const byte TemplateDoubleBar = 5; + private const byte TemplateFloor = 6; + private const byte TemplateCeiling = 7; + private const byte TemplateRoot = 10; + private const byte TemplateFraction = 11; + private const byte TemplateUnderbar = 12; + private const byte TemplateOverbar = 13; + private const byte TemplateArrow = 14; + private const byte TemplateIntegral = 15; + private const byte TemplateSum = 16; + private const byte TemplateProduct = 17; + private const byte TemplateCoproduct = 18; + private const byte TemplateUnion = 19; + private const byte TemplateIntersection = 20; + private const byte TemplateLimit = 23; + private const byte TemplateHorizontalBrace = 24; + private const byte TemplateHorizontalBracket = 25; + private const byte TemplateSub = 27; + private const byte TemplateSup = 28; + private const byte TemplateSubSup = 29; + private const byte TemplateVector = 31; + private const byte TemplateTilde = 32; + private const byte TemplateHat = 33; + private const byte TemplateArc = 34; + private const byte TemplateStrike = 36; + private const byte TemplateBox = 37; + + private const byte EmbellDot = 2; + private const byte EmbellDoubleDot = 3; + private const byte EmbellPrime = 5; + private const byte EmbellDoublePrime = 6; + private const byte EmbellBackPrime = 7; + private const byte EmbellTriplePrime = 18; + private const byte EmbellTilde = 8; + private const byte EmbellHat = 9; + private const byte EmbellRightArrow = 11; + private const byte EmbellLeftArrow = 12; + private const byte EmbellBothArrow = 13; + private const byte EmbellOverbar = 17; + + internal static string ReadEquationNativeMathMl(byte[] equationNative) + { + var mtef = ExtractNativeMtef(equationNative); + return ReadMtefMathMl(mtef); + } + + private static byte[] ExtractNativeMtef(byte[] equationNative) + { + if (equationNative is null || equationNative.Length < 40) + throw new InvalidDataException("MathType Equation Native stream is too short."); + var headerLength = BitConverter.ToUInt16(equationNative, 0); + if (headerLength != 28 || headerLength >= equationNative.Length) + throw new InvalidDataException( + $"Unsupported MathType OLE native header length: {headerLength}."); + var objectLength = checked((int)BitConverter.ToUInt32(equationNative, 8)); + if (objectLength <= 0 || headerLength + objectLength > equationNative.Length) + throw new InvalidDataException( + $"Invalid MathType OLE MTEF length: {objectLength}."); + var mtef = new byte[objectLength]; + Buffer.BlockCopy(equationNative, headerLength, mtef, 0, objectLength); + if (mtef[0] != MtefVersion5) + throw new InvalidDataException( + $"LaTeXSnipper currently reads MathType OLE directly for MTEF v5 only, actual={mtef[0]}."); + + return mtef; + } + + private static string ReadMtefMathMl(byte[] mtef) + { + var structureOffset = FindRootStructureOffset(mtef); + var isEmptyEquation = + structureOffset == mtef.Length - 1 + && mtef[structureOffset] == RecordEnd; + var reader = new MtefStructureReader(mtef, structureOffset); + var content = isEmptyEquation ? Array.Empty() : reader.ReadRoot(); + if (!isEmptyEquation && !reader.HasOnlyEquationEndRemaining()) + throw new InvalidDataException("MTEF has unexpected content after the root equation."); + XNamespace mathMlNamespace = "http://www.w3.org/1998/Math/MathML"; + var math = new XElement( + mathMlNamespace + "math", + content); + + // MtefStructureReader deliberately builds compact XElement trees using + // local names (mi/mo/mrow/...). Once those nodes are attached below a + // namespaced , LINQ to XML serializes them with xmlns="" unless we + // promote their XName as well. That produced visually plausible MathML + // which our LaTeX reader tolerated, but standards-based MathML->OMML XSLT + // saw an empty equation. Normalize every parser-owned descendant into the + // MathML namespace at the direct MTEF read boundary. + foreach (var element in math.Descendants().ToArray()) + { + if (element.Name.Namespace == XNamespace.None) + element.Name = mathMlNamespace + element.Name.LocalName; + } + foreach (var marker in math.DescendantsAndSelf() + .Attributes("data-mtef-run") + .ToArray()) + marker.Remove(); + return new XDocument(math).ToString(SaveOptions.DisableFormatting); + } + + private const string StandaloneMtefPrefixBase64 = + "BQEABwhEU01UNwAAE1dpbkFsbEJhc2ljQ29kZVBhZ2VzABEFVGltZXMgTmV3IFJvbWFuABEDU3ltYm9sABEFQ291cmllciBOZXcAEQRNVCBFeHRyYQATV2luQWxsQ29kZVBhZ2VzABEGy87M5QASAAghL0WPRC9BUPQQD0dfQVDyHx5BUPQVD0EA9EX0JfSPQl9BAPQQD0NfQQD0j0X0Kl9I9I9BAPQQD0D0j0F/SPQQD0EqX0RfRfRfRfRfQQ8MAQABAAECAgICAAIAAQEBAAMAAQAEAAUACg=="; + + private static readonly byte[] StandaloneEquationNativeHeader = + { + 0x1C, 0x00, // EQNOLEFILEHDR.cbHdr = 28 + 0x00, 0x00, 0x02, 0x00, // Equation Native format version + 0x42, 0xC2, // MathType native clipboard format used by DSMT7 + 0x00, 0x00, 0x00, 0x00, // cbObject, filled below + 0x00, 0x00, 0x00, 0x00, + 0xFC, 0xDE, 0x56, 0x0A, + 0x2D, 0xDF, 0xD4, 0x00, + 0x0C, 0x00, 0x85, 0x09, + }; + + private static byte[] CreateEquationNative( + string mathMl) + { + if (string.IsNullOrWhiteSpace(mathMl)) + throw new InvalidDataException("MathType creation requires MathML."); + + var prefix = Convert.FromBase64String(StandaloneMtefPrefixBase64); + var seedMtef = new byte[prefix.Length + 4]; + Buffer.BlockCopy(prefix, 0, seedMtef, 0, prefix.Length); + seedMtef[prefix.Length] = RecordLine; + seedMtef[prefix.Length + 1] = 0; + seedMtef[prefix.Length + 2] = RecordEnd; + seedMtef[prefix.Length + 3] = RecordEnd; + if (FindRootStructureOffset(seedMtef) != prefix.Length) + throw new InvalidDataException( + "LaTeXSnipper's standalone MathType MTEF prefix is internally inconsistent."); + + var document = XDocument.Parse(mathMl, LoadOptions.PreserveWhitespace); + var math = document.Root?.DescendantsAndSelf() + .FirstOrDefault(element => element.Name.LocalName == "math") + ?? throw new InvalidDataException("MathML has no root."); + var generated = BuildRootStructure(math, seedMtef, out var prefixDefinitions); + var mtef = AssembleRewrittenMtef( + seedMtef, + prefix.Length, + prefixDefinitions, + generated); + + var equationNative = new byte[StandaloneEquationNativeHeader.Length + mtef.Length]; + Buffer.BlockCopy( + StandaloneEquationNativeHeader, + 0, + equationNative, + 0, + StandaloneEquationNativeHeader.Length); + Buffer.BlockCopy( + BitConverter.GetBytes((uint)mtef.Length), + 0, + equationNative, + 8, + sizeof(uint)); + Buffer.BlockCopy( + mtef, + 0, + equationNative, + StandaloneEquationNativeHeader.Length, + mtef.Length); + + return equationNative; + } + + private static int FindRootStructureOffset(byte[] mtef) + => ReadPrefixLayout(mtef).Root; + + private sealed class MtefPrefixLayout + { + internal MtefPrefixLayout(int root, int preferences, + IReadOnlyList<(byte Type, int Start, int End)> records) + { Root = root; Preferences = preferences; Records = records; } + internal int Root { get; } + internal int Preferences { get; } + internal IReadOnlyList<(byte Type, int Start, int End)> Records { get; } + } + + // The sole prefix boundary parser. Consumers inspect its typed record spans; + // they must not independently guess where preferences, fonts, sizes or the + // root begin. This also preserves unknown length-delimited vendor records. + private static MtefPrefixLayout ReadPrefixLayout(byte[] mtef) + { + if (mtef is null || mtef.Length < 16 || mtef[0] != MtefVersion5) + throw new InvalidDataException("Invalid or unsupported MTEF stream."); + var position = FindEquationOptionsOffset(mtef) + 1; + var sawPreferences = false; + var preferencesOffset = -1; + var sawInitialSize = false; + var records = new List<(byte Type, int Start, int End)>(); + while (position < mtef.Length) + { + var recordStart = position; + var record = mtef[position]; + switch (record) + { + case RecordEncodingDef: + position = SkipNullTerminated(mtef, position + 1); + break; + case RecordFontDef: + position++; + _ = ReadUnsigned(mtef, ref position); + position = SkipNullTerminated(mtef, position); + break; + case RecordFontStyleDef: + position++; + _ = ReadUnsigned(mtef, ref position); + Require(mtef, position, 1); + position++; + break; + case RecordColorDef: + position = SkipColorDefinition(mtef, position); + break; + case RecordEqnPrefs: + preferencesOffset = position; + position = SkipEquationPreferences(mtef, position); + sawPreferences = true; + break; + case >= 100: + // MTEF v5 explicitly reserves record ids >= 100 for forward + // compatible extensions. MathType versions are free to emit + // them before the root; readers must skip the length-prefixed + // payload instead of treating the record as the initial SIZE. + position = SkipFutureRecord(mtef, position); + break; + default: + if (!sawPreferences) + throw new InvalidDataException( + $"Unexpected MTEF record {record} before equation preferences at offset {position}."); + if (record is RecordLine or RecordPile or RecordMatrix) + { + if (!sawInitialSize) + System.Diagnostics.Trace.WriteLine( + $"mathtype-mtef-root-without-initial-size offset={position} record={record}"); + return new MtefPrefixLayout(position, preferencesOffset, records); + } + if (record == RecordSize + || record >= RecordFull && record <= RecordSubSym) + { + // The published layout contains one initial size record, + // but accepting repeated size changes here is harmless and + // makes the reader tolerant of vendor/version prefixes while + // keeping all of them in the immutable source prefix. + position = SkipInitialSizeRecord(mtef, position); + sawInitialSize = true; + break; + } + if (record == RecordColor) + { + position++; + _ = ReadUnsigned(mtef, ref position); + break; + } + // Never search arbitrary preference/extension bytes for a + // plausible LINE. A malformed boundary must fail before any + // Word mutation, not silently adopt a nested sub-expression. + // A genuine empty MathType equation is encoded as the normal + // prefix/initial-size state followed directly by the final + // equation END. MathType 7.8.x therefore legitimately has a + // zero at the same offset where non-empty DSMT7 equations put + // their root LINE. Treat only the terminal END as empty; any + // other zero remains a hard parse failure. + if (record == RecordEnd && position == mtef.Length - 1) + { + System.Diagnostics.Trace.WriteLine( + $"mathtype-mtef-empty-equation endOffset={position}"); + return new MtefPrefixLayout(position, preferencesOffset, records); + } + throw new InvalidDataException( + $"Unsupported MathType root record {record} at offset {position}."); + } + if (position <= recordStart || position > mtef.Length) + throw new InvalidDataException($"Invalid MTEF prefix extent at {recordStart}."); + records.Add((record, recordStart, position)); + } + throw new InvalidDataException("MTEF has no root equation structure."); + } + + private static int FindEquationOptionsOffset(byte[] mtef) + { + Require(mtef, 0, 6); + var position = 5; // version/platform/product/version/subversion + position = SkipNullTerminated(mtef, position); + Require(mtef, position, 1); + return position; + } + + private static int SkipEquationPreferences(byte[] data, int position) + { + Require(data, position, 2); + position += 2; // record type + options + position = SkipDimensionArray(data, position); + position = SkipDimensionArray(data, position); + Require(data, position, 1); + var styleCount = data[position++]; + for (var index = 0; index < styleCount; index++) + { + var fontIndex = ReadUnsigned(data, ref position); + if (fontIndex != 0) + { + Require(data, position, 1); + position++; // character style + } + } + return position; + } + + private static int SkipDimensionArray(byte[] data, int position) + { + Require(data, position, 1); + var count = data[position++]; + var nibbleIndex = 0; + byte ReadNibble() + { + Require(data, position, 1); + var value = nibbleIndex == 0 + ? (byte)(data[position] >> 4) + : (byte)(data[position] & 0x0F); + if (nibbleIndex == 0) nibbleIndex = 1; + else + { + nibbleIndex = 0; + position++; + } + return value; + } + + for (var dimension = 0; dimension < count; dimension++) + { + _ = ReadNibble(); // units + while (ReadNibble() != 0x0F) { } + } + if (nibbleIndex != 0) position++; // padded low nibble + return position; + } + + private static int SkipColorDefinition(byte[] data, int position) + { + Require(data, position, 2); + var options = data[position + 1]; + position += 2; + var cmyk = (options & 0x01) != 0; + var named = (options & 0x04) != 0; + Require(data, position, cmyk ? 8 : 6); + position += cmyk ? 8 : 6; + if (named) position = SkipNullTerminated(data, position); + return position; + } + + private static int SkipFutureRecord(byte[] data, int position) + { + Require(data, position, 1); + if (data[position] < 100) + throw new InvalidDataException( + $"MTEF record at offset {position} is not a future-extension record."); + position++; + var length = ReadUnsigned(data, ref position); + Require(data, position, length); + return position + length; + } + + private static int SkipInitialSizeRecord(byte[] data, int position) + { + Require(data, position, 1); + var record = data[position]; + if (record >= RecordFull && record <= RecordSubSym) + return position + 1; + if (record != RecordSize) + throw new InvalidDataException( + $"Expected initial MTEF size record at offset {position}, actual={record}."); + Require(data, position, 2); + var form = data[position + 1]; + if (form == 100 || form == 101) + { + Require(data, position, form == 100 ? 5 : 4); + return position + (form == 100 ? 5 : 4); + } + Require(data, position, 3); + return position + 3; + } + + private static int SkipNullTerminated(byte[] data, int position) + { + while (position < data.Length && data[position] != 0) position++; + if (position >= data.Length) + throw new EndOfStreamException("MTEF null-terminated field is truncated."); + return position + 1; + } + + private static int ReadUnsigned(byte[] data, ref int position) + { + Require(data, position, 1); + var value = data[position++]; + if (value != 0xFF) return value; + Require(data, position, 2); + var expanded = data[position] | (data[position + 1] << 8); + position += 2; + return expanded; + } +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeNativeEquation.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeNativeEquation.cs new file mode 100644 index 00000000..ff6721fe --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeNativeEquation.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +/// Encodes and reads current MathType MTEF v5 content without an equation server. +public static class MathTypeNativeEquation +{ + public static byte[] Create(string mathMl, double fontSizePoints) + { + var math = XDocument.Parse(mathMl); + var size = math.Descendants().Attributes("mathsize").FirstOrDefault(a => a.Value != "normal"); + if (size != null) throw new InvalidDataException("MathType encoding does not support local mathsize=" + size.Value + "; the original equation was retained."); + var result = MathTypeMtefCodec.CreateEquationNativeAtFontSize(mathMl, fontSizePoints); + string decoded = ReadMathMl(result); + if (MathTypeMtefCodec.SemanticSignature(mathMl) != MathTypeMtefCodec.SemanticSignature(decoded)) + throw new InvalidDataException("MathType MTEF semantic validation failed; the original equation was retained."); + if (Math.Abs(ReadFontSizePoints(result) - fontSizePoints) > 0.001) + throw new InvalidDataException("MathType MTEF font size validation failed."); + return result; + } + + public static string ReadMathMl(byte[] equationNative) => MathTypeMtefCodec.ReadEquationNativeMathMl(equationNative); + + public static double ReadFontSizePoints(byte[] equationNative) => MathTypeMtefCodec.ReadEquationNativeFullFontSize(equationNative); +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeOleBridge.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeOleBridge.cs index bd610634..5ee1f26c 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeOleBridge.cs +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeOleBridge.cs @@ -1,22 +1,12 @@ #if NETFRAMEWORK using System; -using System.ComponentModel; -using System.IO; -using System.Linq; using System.Runtime.InteropServices; -using System.Runtime.InteropServices.ComTypes; -using System.Text; -using System.Threading; -using System.Xml.Linq; -using Microsoft.Win32; namespace LaTeXSnipper.OfficePlugin.Rendering; -/// Writes MathML into a host-created native MathType object using its installed SDK. public static class MathTypeOleBridge { public const string ProgId = "Equation.DSMT4"; - private static readonly object Gate = new(); public static bool IsEquation(object shape) { @@ -24,156 +14,5 @@ public static bool IsEquation(object shape) catch (COMException) { return false; } catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) { return false; } } - - public static void EnsureAvailable() - { - if (!File.Exists(SdkLibraryPath())) - throw new InvalidOperationException("当前 MathType 安装缺少对应位数的桌面 SDK,无法转换公式。"); - } - - public static void WriteMathMl(object equation, string mathMl) - { - if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) - throw new InvalidOperationException("MathType 转换必须在 Office STA 线程执行。"); - mathMl = MathTypeMathMl.PrepareForImport(mathMl); - lock (Gate) - { - using var sdk = new Sdk(); - IntPtr dispatch = IntPtr.Zero; - try - { - dispatch = Marshal.GetIDispatchForObject(equation); - try - { - Check(sdk.SetEquation(dispatch, 2, mathMl, mathMl.Length), "导入 MathML"); - // A successful SDK call alone is insufficient: require readable native content. - _ = ReadMathMl(equation); - } - finally { Check(sdk.CloseEquation(1, dispatch), "关闭公式"); } - } - finally - { - if (dispatch != IntPtr.Zero) Marshal.Release(dispatch); - } - } - } - - public static string ReadMathMl(object equation) - { - var format = Format("MathML", TYMED.TYMED_HGLOBAL); - ((IDataObject)equation).GetData(ref format, out STGMEDIUM medium); - try - { - int size = checked((int)GlobalSize(medium.unionmember).ToUInt64()); - if (size <= 0 || size > 32 * 1024 * 1024) throw new InvalidDataException("MathType 返回了无效的 MathML 大小。"); - IntPtr data = GlobalLock(medium.unionmember); - if (data == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); - try - { - var bytes = new byte[size]; - Marshal.Copy(data, bytes, 0, size); - int end = Array.IndexOf(bytes, (byte)0); - string xml = Encoding.UTF8.GetString(bytes, 0, end < 0 ? size : end); - if (XDocument.Parse(xml).Root?.Name != XName.Get("math", "http://www.w3.org/1998/Math/MathML")) - throw new InvalidDataException("MathType 返回的内容不是 MathML 公式。"); - return xml; - } - finally { GlobalUnlock(medium.unionmember); } - } - finally { ReleaseStgMedium(ref medium); } - } - - public static string ReadAndCloseMathMl(object equation) - { - if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) - throw new InvalidOperationException("MathType 转换必须在 Office STA 线程执行。"); - lock (Gate) - { - using var sdk = new Sdk(); - IntPtr dispatch = Marshal.GetIDispatchForObject(equation); - try - { - var root = XDocument.Parse(ReadMathMl(equation)).Root!; - // Store one complete MathML root, without translator comments, - // XML declarations or the producer's namespace prefix aliases. - foreach (var node in root.DescendantsAndSelf()) - node.Attributes().Where(attribute => attribute.IsNamespaceDeclaration).Remove(); - return root.ToString(SaveOptions.DisableFormatting); - } - finally - { - try { Check(sdk.CloseEquation(1, dispatch), "关闭公式"); } - finally { Marshal.Release(dispatch); } - } - } - } - - private static FORMATETC Format(string name, TYMED medium) => new() - { - cfFormat = unchecked((short)RegisterClipboardFormat(name)), - dwAspect = DVASPECT.DVASPECT_CONTENT, lindex = -1, tymed = medium - }; - - private static void Check(int result, string action) - { - if (result != 0) throw new InvalidOperationException($"MathType {action}失败(SDK {result})。"); - } - - private static string SdkLibraryPath() - { - Type? type = Type.GetTypeFromProgID(ProgId); - if (type == null) throw new InvalidOperationException("请先安装 MathType,再转换为 MathType 公式。"); - using var key = Registry.ClassesRoot.OpenSubKey(@"CLSID\" + type.GUID.ToString("B") + @"\LocalServer32"); - string command = Convert.ToString(key?.GetValue("")) ?? ""; - int end = command.IndexOf(".exe", StringComparison.OrdinalIgnoreCase); - if (end < 0) throw new InvalidOperationException("未找到 MathType 安装路径。"); - string executable = command.Substring(0, end + 4).TrimStart('"'); - if (!File.Exists(executable)) throw new InvalidOperationException("MathType 注册的程序路径不存在,请修复 MathType 安装。"); - return Path.Combine(Path.GetDirectoryName(executable)!, "System", IntPtr.Size == 8 ? "64" : "32", "MT6.dll"); - } - - private sealed class Sdk : IDisposable - { - private readonly IntPtr library; - private readonly Disconnect disconnect; - public readonly SetEquationDelegate SetEquation; - public readonly CloseEquationDelegate CloseEquation; - - public Sdk() - { - library = LoadLibraryEx(SdkLibraryPath(), IntPtr.Zero, 0x1100); - if (library == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "无法加载已安装的 MathType SDK。"); - try - { - disconnect = Function("MTAPIDisconnect"); - SetEquation = Function("MTSetEqnFromLangStr"); - CloseEquation = Function("MTCloseOleObject"); - Check(Function("MTAPIConnect")(1, 10), "连接"); - } - catch { FreeLibrary(library); throw; } - } - - private T Function(string name) where T : Delegate - { - IntPtr address = GetProcAddress(library, name); - if (address == IntPtr.Zero) throw new EntryPointNotFoundException("MathType SDK 缺少 " + name); - return Marshal.GetDelegateForFunctionPointer(address); - } - - public void Dispose() { try { disconnect(); } finally { FreeLibrary(library); } } - } - - [UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate int Connect(short mode, short timeout); - [UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate int Disconnect(); - [UnmanagedFunctionPointer(CallingConvention.Winapi, CharSet = CharSet.Unicode)] private delegate int SetEquationDelegate(IntPtr equation, int language, string value, int length); - [UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate int CloseEquationDelegate(int save, IntPtr equation); - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags); - [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] private static extern IntPtr GetProcAddress(IntPtr library, string name); - [DllImport("kernel32.dll")] private static extern bool FreeLibrary(IntPtr library); - [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr GlobalLock(IntPtr memory); - [DllImport("kernel32.dll")] private static extern bool GlobalUnlock(IntPtr memory); - [DllImport("kernel32.dll")] private static extern UIntPtr GlobalSize(IntPtr memory); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern uint RegisterClipboardFormat(string name); - [DllImport("ole32.dll")] private static extern void ReleaseStgMedium(ref STGMEDIUM medium); } #endif diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypePowerPointPackage.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypePowerPointPackage.cs new file mode 100644 index 00000000..557b50a6 --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypePowerPointPackage.cs @@ -0,0 +1,153 @@ +using System; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using LaTeXSnipper.OfficePlugin.Abstractions; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +/// Embeds native MathType content and its preview in a single-slide PowerPoint package. +public static class MathTypePowerPointPackage +{ + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static readonly XNamespace Relationships = "http://schemas.openxmlformats.org/package/2006/relationships"; + private static readonly XNamespace ContentTypes = "http://schemas.openxmlformats.org/package/2006/content-types"; + private const int MaxPartBytes = 64 * 1024 * 1024; + + public static byte[] Create(byte[] template, byte[] compoundFile, OlePresentationResult presentation, + float left, float top, float width, float height, float rotation, float slideWidth, float slideHeight) + { + if (!(width > 0) || !(height > 0) || !(slideWidth > 0) || !(slideHeight > 0) + || new[] { left, top, width, height, rotation, slideWidth, slideHeight }.Any(v => float.IsInfinity(v) || float.IsNaN(v))) + throw new InvalidDataException("Invalid PowerPoint MathType geometry."); + _ = MathTypeNativeEquation.ReadMathMl(MathTypeCompoundFile.ReadNative(compoundFile)); + byte[] preview = MathTypeWmfPreview.Create(presentation.Payload, (float)presentation.WidthPoints, (float)presentation.HeightPoints); + using var buffer = new MemoryStream(); + buffer.Write(template, 0, template.Length); + buffer.Position = 0; + using (var zip = new ZipArchive(buffer, ZipArchiveMode.Update, leaveOpen: true)) + { + var main = ReadXml(zip, "ppt/presentation.xml"); + var slides = main.Descendants(P + "sldId").ToArray(); + if (slides.Length != 1) throw new InvalidDataException("MathType insertion requires a single-slide template."); + var size = main.Root!.Element(P + "sldSz") ?? throw new InvalidDataException("PowerPoint slide dimensions are missing."); + size.SetAttributeValue("cx", Emu(slideWidth)); + size.SetAttributeValue("cy", Emu(slideHeight)); + size.Attribute("type")?.Remove(); + string slidePart = Resolve(zip, "ppt/presentation.xml", (string)slides[0].Attribute(R + "id")!, "slide"); + var slide = ReadXml(zip, slidePart); + var tree = slide.Descendants(P + "spTree").Single(); + if (tree.Elements().Any(e => e.Name != P + "nvGrpSpPr" && e.Name != P + "grpSpPr")) + throw new InvalidDataException("MathType insertion template must have an empty slide."); + var picture = new XElement(P + "pic", + new XElement(P + "nvPicPr", new XElement(P + "cNvPr", new XAttribute("id", 0), new XAttribute("name", "")), + new XElement(P + "cNvPicPr"), new XElement(P + "nvPr")), + new XElement(P + "blipFill", new XElement(A + "blip", new XAttribute(R + "embed", "formulaPreview")), + new XElement(A + "stretch", new XElement(A + "fillRect"))), + new XElement(P + "spPr", Transform(A + "xfrm", left, top, width, height, rotation), + new XElement(A + "prstGeom", new XAttribute("prst", "rect"), new XElement(A + "avLst")))); + tree.Add(new XElement(P + "graphicFrame", + new XElement(P + "nvGraphicFramePr", + new XElement(P + "cNvPr", new XAttribute("id", 2), new XAttribute("name", "MathType Equation")), + new XElement(P + "cNvGraphicFramePr", new XElement(A + "graphicFrameLocks", new XAttribute("noChangeAspect", 1))), + new XElement(P + "nvPr")), + Transform(P + "xfrm", left, top, width, height, rotation), + new XElement(A + "graphic", new XElement(A + "graphicData", new XAttribute("uri", "http://schemas.openxmlformats.org/presentationml/2006/ole"), + new XElement(P + "oleObj", new XAttribute("name", "Equation"), new XAttribute("progId", "Equation.DSMT4"), + new XAttribute(R + "id", "formulaNative"), new XAttribute("imgW", Emu(presentation.WidthPoints)), + new XAttribute("imgH", Emu(presentation.HeightPoints)), new XElement(P + "embed"), picture))))); + var relations = ReadXml(zip, RelationshipPart(slidePart)); + relations.Root!.Add(Relationship("formulaNative", "oleObject", "../embeddings/formula.bin"), + Relationship("formulaPreview", "image", "../media/formula.wmf")); + var types = ReadXml(zip, "[Content_Types].xml"); + types.Root!.Add(new XElement(ContentTypes + "Override", new XAttribute("PartName", "/ppt/embeddings/formula.bin"), + new XAttribute("ContentType", "application/vnd.openxmlformats-officedocument.oleObject"))); + if (!types.Root.Elements(ContentTypes + "Default").Any(e => (string?)e.Attribute("Extension") == "wmf")) + types.Root.Add(new XElement(ContentTypes + "Default", new XAttribute("Extension", "wmf"), new XAttribute("ContentType", "image/x-wmf"))); + WriteXml(zip, slidePart, slide); + WriteXml(zip, "ppt/presentation.xml", main); + WriteXml(zip, RelationshipPart(slidePart), relations); + WriteXml(zip, "[Content_Types].xml", types); + WritePart(zip, "ppt/embeddings/formula.bin", compoundFile); + WritePart(zip, "ppt/media/formula.wmf", preview); + } + return buffer.ToArray(); + } + + public static byte[] ReadNative(string presentationPath, int slideIndex, int shapeId) + { + using var file = File.OpenRead(presentationPath); + using var zip = new ZipArchive(file, ZipArchiveMode.Read); + var slides = ReadXml(zip, "ppt/presentation.xml").Descendants(P + "sldId").ToArray(); + if (slideIndex < 1 || slideIndex > slides.Length) throw new InvalidDataException("MathType slide is missing."); + string slidePart = Resolve(zip, "ppt/presentation.xml", (string)slides[slideIndex - 1].Attribute(R + "id")!, "slide"); + var frame = ReadXml(zip, slidePart).Descendants(P + "graphicFrame").SingleOrDefault(e => + (string?)e.Element(P + "nvGraphicFramePr")?.Element(P + "cNvPr")?.Attribute("id") == shapeId.ToString(CultureInfo.InvariantCulture)) + ?? throw new InvalidDataException("MathType shape is missing from the presentation."); + var objects = frame.Descendants(P + "oleObj").ToArray(); + if (objects.Length == 0 || objects.Any(e => !string.Equals((string?)e.Attribute("progId"), "Equation.DSMT4", StringComparison.OrdinalIgnoreCase) + || e.Element(P + "embed") == null)) + throw new InvalidDataException("Expected an embedded native MathType equation."); + var ids = objects.Select(e => (string?)e.Attribute(R + "id")).Distinct().ToArray(); + if (ids.Length != 1 || string.IsNullOrEmpty(ids[0])) throw new InvalidDataException("MathType relationship is missing or ambiguous."); + string id = ids[0]!; + string nativePart = Resolve(zip, slidePart, id, "oleObject"); + return MathTypeCompoundFile.ReadNative(ReadPart(zip, nativePart)); + } + + private static long Emu(double points) => checked((long)Math.Round(points * 12700d)); + + private static XElement Transform(XName name, float left, float top, float width, float height, float rotation) + => new XElement(name, name == A + "xfrm" ? new XAttribute("rot", Math.Round(rotation * 60000d)) : null, + new XElement(A + "off", new XAttribute("x", Emu(left)), new XAttribute("y", Emu(top))), + new XElement(A + "ext", new XAttribute("cx", Emu(width)), new XAttribute("cy", Emu(height)))); + + private static string RelationshipPart(string part) + { + int split = part.LastIndexOf('/') + 1; + return part.Substring(0, split) + "_rels/" + part.Substring(split) + ".rels"; + } + + private static XElement Relationship(string id, string type, string target) => new XElement(Relationships + "Relationship", + new XAttribute("Id", id), new XAttribute("Type", R.NamespaceName + "/" + type), new XAttribute("Target", target)); + + private static string Resolve(ZipArchive zip, string part, string id, string type) + { + var relation = ReadXml(zip, RelationshipPart(part)).Descendants(Relationships + "Relationship") + .SingleOrDefault(e => (string?)e.Attribute("Id") == id) + ?? throw new InvalidDataException("Missing PowerPoint relationship: " + id); + if ((string?)relation.Attribute("Type") != R.NamespaceName + "/" + type || (string?)relation.Attribute("TargetMode") == "External") + throw new InvalidDataException("Unexpected external or mismatched PowerPoint relationship."); + var target = new Uri(new Uri("http://package/" + part), (string)relation.Attribute("Target")!); + if (target.Host != "package" || target.Query.Length != 0 || target.Fragment.Length != 0) + throw new InvalidDataException("Invalid embedded PowerPoint part path."); + return target.AbsolutePath.TrimStart('/'); + } + + private static XDocument ReadXml(ZipArchive zip, string part) => XDocument.Parse(Encoding.UTF8.GetString(ReadPart(zip, part))); + + private static byte[] ReadPart(ZipArchive zip, string part) + { + var entry = zip.GetEntry(part) ?? throw new InvalidDataException("Missing PowerPoint part: " + part); + if (entry.Length > MaxPartBytes) throw new InvalidDataException("PowerPoint part exceeds the formula size limit."); + using var stream = entry.Open(); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } + + private static void WriteXml(ZipArchive zip, string part, XDocument document) + => WritePart(zip, part, Encoding.UTF8.GetBytes(document.ToString(SaveOptions.DisableFormatting))); + + private static void WritePart(ZipArchive zip, string part, byte[] bytes) + { + zip.GetEntry(part)?.Delete(); + using var stream = zip.CreateEntry(part, CompressionLevel.Optimal).Open(); + stream.Write(bytes, 0, bytes.Length); + } +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeWmfPreview.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeWmfPreview.cs new file mode 100644 index 00000000..e2c65cd1 --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeWmfPreview.cs @@ -0,0 +1,231 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +internal static class MathTypeWmfPreview +{ + private const uint AldusPlaceableKey = 0x9AC6CDD7; + private const ushort PlaceableInch = 2304; + private const int MmAnisotropic = 8; + internal static byte[] Create(byte[] emf, float widthPt, float heightPt) + { + string path = Path.Combine(Path.GetTempPath(), "latexsnipper-preview-" + Guid.NewGuid().ToString("N") + ".emf"); + try + { + File.WriteAllBytes(path, emf); + return EnsureMathTypePlaceableWmfWindowRecords(ConvertEnhancedMetafileToPlaceableWmf(path, widthPt, heightPt)); + } + finally { File.Delete(path); } + } + + private static byte[] ConvertEnhancedMetafileToPlaceableWmf( + string emfPath, + float widthPt, + float heightPt) + { + if (string.IsNullOrWhiteSpace(emfPath) || !File.Exists(emfPath)) + throw new FileNotFoundException("EMF preview is unavailable.", emfPath); + if (!(widthPt > 0) || !(heightPt > 0)) + throw new InvalidDataException( + $"Invalid WMF preview size {widthPt}x{heightPt} pt."); + + var enhancedMetafile = GetEnhMetaFileW(emfPath); + if (enhancedMetafile == IntPtr.Zero) + throw new InvalidDataException($"Windows could not open EMF preview '{emfPath}'."); + var referenceDc = GetDC(IntPtr.Zero); + try + { + var rawLength = GetWinMetaFileBits( + enhancedMetafile, + 0, + null, + MmAnisotropic, + referenceDc); + if (rawLength == 0 || rawLength > 64 * 1024 * 1024) + throw new InvalidDataException( + $"Windows could not convert the EMF preview to WMF; size={rawLength}."); + var rawWmf = new byte[rawLength]; + var written = GetWinMetaFileBits( + enhancedMetafile, + rawLength, + rawWmf, + MmAnisotropic, + referenceDc); + if (written != rawLength) + throw new InvalidDataException( + $"Windows returned an incomplete WMF preview: {written}/{rawLength} bytes."); + + // Genuine MathType WMFs use whole-point picture bounds (the same + // dimensions later written to Word's dxaOrig/dyaOrig). Preserve the + // visual VML size separately, but make the embedded WMF's original + // coordinate extent match MathType's native object format. + var originalWidthPt = widthPt; + var originalHeightPt = heightPt; + var right = checked((short)Math.Max( + 1, + Math.Min(short.MaxValue, (int)Math.Round(originalWidthPt * PlaceableInch / 72d)))); + var bottom = checked((short)Math.Max( + 1, + Math.Min(short.MaxValue, (int)Math.Round(originalHeightPt * PlaceableInch / 72d)))); + var placeable = BuildPlaceableHeader(right, bottom); + var result = new byte[placeable.Length + rawWmf.Length]; + Buffer.BlockCopy(placeable, 0, result, 0, placeable.Length); + Buffer.BlockCopy(rawWmf, 0, result, placeable.Length, rawWmf.Length); + return result; + } + finally + { + if (referenceDc != IntPtr.Zero) ReleaseDC(IntPtr.Zero, referenceDc); + DeleteEnhMetaFile(enhancedMetafile); + } + } + + + private static byte[] BuildPlaceableHeader(short right, short bottom) + { + var bytes = new byte[22]; + WriteUInt32(bytes, 0, AldusPlaceableKey); + WriteUInt16(bytes, 4, 0); + WriteInt16(bytes, 6, 0); + WriteInt16(bytes, 8, 0); + WriteInt16(bytes, 10, right); + WriteInt16(bytes, 12, bottom); + WriteUInt16(bytes, 14, PlaceableInch); + WriteUInt32(bytes, 16, 0); + ushort checksum = 0; + for (var offset = 0; offset < 20; offset += 2) + checksum ^= BitConverter.ToUInt16(bytes, offset); + WriteUInt16(bytes, 20, checksum); + return bytes; + } + + + private static void WriteUInt16(byte[] bytes, int offset, ushort value) + { + var encoded = BitConverter.GetBytes(value); + Buffer.BlockCopy(encoded, 0, bytes, offset, encoded.Length); + } + + + private static void WriteInt16(byte[] bytes, int offset, short value) => + WriteUInt16(bytes, offset, unchecked((ushort)value)); + + + private static void WriteUInt32(byte[] bytes, int offset, uint value) + { + var encoded = BitConverter.GetBytes(value); + Buffer.BlockCopy(encoded, 0, bytes, offset, encoded.Length); + } + + + [DllImport("gdi32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetEnhMetaFileW(string fileName); + + + [DllImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteEnhMetaFile(IntPtr enhancedMetafile); + + + [DllImport("gdi32.dll")] + private static extern uint GetWinMetaFileBits( + IntPtr enhancedMetafile, + uint bufferSize, + [Out] byte[]? data, + int mapMode, + IntPtr referenceDc); + + + [DllImport("user32.dll")] + private static extern IntPtr GetDC(IntPtr window); + + + [DllImport("user32.dll")] + private static extern int ReleaseDC(IntPtr window, IntPtr dc); + + + private static byte[] EnsureMathTypePlaceableWmfWindowRecords(byte[] previewWmf) + { + const uint aldusPlaceableKey = 0x9ac6cdd7; + const ushort metaSetWindowOrg = 0x020b; + const ushort metaSetWindowExt = 0x020c; + const int firstRecordOffset = 40; + if (previewWmf is null + || previewWmf.Length < firstRecordOffset + 6 + || BitConverter.ToUInt32(previewWmf, 0) != aldusPlaceableKey) + throw new InvalidDataException("MathType preview is not a valid placeable WMF."); + + var declaredWords = BitConverter.ToUInt32(previewWmf, 28); + if (declaredWords > int.MaxValue / 2 + || checked((int)declaredWords * 2) != previewWmf.Length - 22) + throw new InvalidDataException("MathType WMF header length does not match its payload."); + + var hasWindowOrigin = false; + var hasWindowExtent = false; + var insertionOffset = firstRecordOffset; + var leadingSetup = true; + for (var offset = firstRecordOffset; offset + 6 <= previewWmf.Length;) + { + var words = BitConverter.ToUInt32(previewWmf, offset); + if (words < 3 || words > int.MaxValue / 2) + throw new InvalidDataException("MathType WMF contains an invalid record length."); + var size = checked((int)words * 2); + if (offset + size > previewWmf.Length) + throw new InvalidDataException("MathType WMF contains a truncated record."); + var function = BitConverter.ToUInt16(previewWmf, offset + 4); + hasWindowOrigin |= function == metaSetWindowOrg; + hasWindowExtent |= function == metaSetWindowExt; + if (leadingSetup + && function is 0x0102 or 0x0201 or 0x012e) + insertionOffset = offset + size; + else + leadingSetup = false; + offset += size; + if (function == 0) break; + } + + if (hasWindowOrigin && hasWindowExtent) return previewWmf; + if (hasWindowOrigin || hasWindowExtent) + throw new InvalidDataException( + "MathType WMF contains only one of SETWINDOWORG/SETWINDOWEXT."); + + var left = BitConverter.ToInt16(previewWmf, 6); + var top = BitConverter.ToInt16(previewWmf, 8); + var right = BitConverter.ToInt16(previewWmf, 10); + var bottom = BitConverter.ToInt16(previewWmf, 12); + var width = checked((short)(right - left)); + var height = checked((short)(bottom - top)); + if (width <= 0 || height <= 0) + throw new InvalidDataException("MathType placeable WMF has invalid logical bounds."); + + var result = new byte[checked(previewWmf.Length + 20)]; + Buffer.BlockCopy(previewWmf, 0, result, 0, insertionOffset); + WriteWmfRecord(result, insertionOffset, metaSetWindowOrg, top, left); + WriteWmfRecord(result, insertionOffset + 10, metaSetWindowExt, height, width); + Buffer.BlockCopy( + previewWmf, + insertionOffset, + result, + insertionOffset + 20, + previewWmf.Length - insertionOffset); + WriteUInt32(result, 28, checked(declaredWords + 10)); + return result; + } + + + private static void WriteWmfRecord( + byte[] target, + int offset, + ushort function, + short firstParameter, + short secondParameter) + { + WriteUInt32(target, offset, 5); + WriteUInt16(target, offset + 4, function); + WriteUInt16(target, offset + 6, unchecked((ushort)firstParameter)); + WriteUInt16(target, offset + 8, unchecked((ushort)secondParameter)); + } + +} \ No newline at end of file diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeWordPackage.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeWordPackage.cs new file mode 100644 index 00000000..b980eb7a --- /dev/null +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/MathTypeWordPackage.cs @@ -0,0 +1,114 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using LaTeXSnipper.OfficePlugin.Abstractions; + +namespace LaTeXSnipper.OfficePlugin.Rendering; + +/// Word Flat OPC containing a native MathType equation and a vector preview. +public static class MathTypeWordPackage +{ + private static readonly XNamespace P = "http://schemas.microsoft.com/office/2006/xmlPackage"; + private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static readonly XNamespace Rel = "http://schemas.openxmlformats.org/package/2006/relationships"; + private static readonly XNamespace V = "urn:schemas-microsoft-com:vml"; + private static readonly XNamespace O = "urn:schemas-microsoft-com:office:office"; + + public static string Create(byte[] compoundFile, OlePresentationResult presentation, float widthPoints, float heightPoints) + { + if (!(widthPoints > 0) || !(heightPoints > 0) || float.IsInfinity(widthPoints) || float.IsInfinity(heightPoints)) + throw new InvalidDataException("Invalid MathType display dimensions."); + byte[] preview = MathTypeWmfPreview.Create(presentation.Payload, widthPoints, heightPoints); + string shapeId = "_x0000_i" + unchecked((uint)Guid.NewGuid().GetHashCode()).ToString(CultureInfo.InvariantCulture); + var shapeType = new XElement( + V + "shapetype", + new XAttribute("id", "_x0000_t75"), + new XAttribute("coordsize", "21600,21600"), + new XAttribute(O + "spt", "75"), + new XAttribute(O + "preferrelative", "t"), + new XAttribute("path", "m@4@5l@4@11@9@11@9@5xe"), + new XAttribute("filled", "f"), + new XAttribute("stroked", "f"), + new XElement(V + "stroke", new XAttribute("joinstyle", "miter")), + new XElement( + V + "formulas", + new XElement(V + "f", new XAttribute("eqn", "if lineDrawn pixelLineWidth 0")), + new XElement(V + "f", new XAttribute("eqn", "sum @0 1 0")), + new XElement(V + "f", new XAttribute("eqn", "sum 0 0 @1")), + new XElement(V + "f", new XAttribute("eqn", "prod @2 1 2")), + new XElement(V + "f", new XAttribute("eqn", "prod @3 21600 pixelWidth")), + new XElement(V + "f", new XAttribute("eqn", "prod @3 21600 pixelHeight")), + new XElement(V + "f", new XAttribute("eqn", "sum @0 0 1")), + new XElement(V + "f", new XAttribute("eqn", "prod @6 1 2")), + new XElement(V + "f", new XAttribute("eqn", "prod @7 21600 pixelWidth")), + new XElement(V + "f", new XAttribute("eqn", "sum @8 21600 0")), + new XElement(V + "f", new XAttribute("eqn", "prod @7 21600 pixelHeight")), + new XElement(V + "f", new XAttribute("eqn", "sum @10 21600 0"))), + new XElement( + V + "path", + new XAttribute(O + "extrusionok", "f"), + new XAttribute("gradientshapeok", "t"), + new XAttribute(O + "connecttype", "rect")), + new XElement( + O + "lock", + new XAttribute(V + "ext", "edit"), + new XAttribute("aspectratio", "t"))); + var equation = new XElement(W + "object", + new XAttribute(W + "dxaOrig", Math.Round(widthPoints * 20)), + new XAttribute(W + "dyaOrig", Math.Round(heightPoints * 20)), + shapeType, + new XElement(V + "shape", new XAttribute("id", shapeId), new XAttribute("type", "#_x0000_t75"), + new XAttribute("style", string.Format(CultureInfo.InvariantCulture, "width:{0:0.###}pt;height:{1:0.###}pt", widthPoints, heightPoints)), + new XAttribute(O + "ole", ""), + new XElement(V + "imagedata", new XAttribute(R + "id", "preview"), new XAttribute(O + "title", ""))), + new XElement(O + "OLEObject", new XAttribute("Type", "Embed"), new XAttribute("ProgID", "Equation.DSMT4"), + new XAttribute("ShapeID", shapeId), new XAttribute("DrawAspect", "Content"), new XAttribute("ObjectID", "_" + Guid.NewGuid().ToString("N")), + new XAttribute(R + "id", "equation"))); + var document = new XElement(W + "document", new XAttribute(XNamespace.Xmlns + "w", W), + new XAttribute(XNamespace.Xmlns + "r", R), new XAttribute(XNamespace.Xmlns + "v", V), new XAttribute(XNamespace.Xmlns + "o", O), + new XElement(W + "body", new XElement(W + "p", new XElement(W + "r", equation)))); + return new XDocument(new XElement(P + "package", new XAttribute(XNamespace.Xmlns + "pkg", P), + XmlPart("/_rels/.rels", "application/vnd.openxmlformats-package.relationships+xml", + new XElement(Rel + "Relationships", Relationship("document", "officeDocument", "word/document.xml"))), + XmlPart("/word/document.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", document), + XmlPart("/word/_rels/document.xml.rels", "application/vnd.openxmlformats-package.relationships+xml", + new XElement(Rel + "Relationships", Relationship("preview", "image", "media/equation.wmf"), + Relationship("equation", "oleObject", "embeddings/equation.bin"))), + BinaryPart("/word/media/equation.wmf", "image/x-wmf", preview), + BinaryPart("/word/embeddings/equation.bin", "application/vnd.openxmlformats-officedocument.oleObject", compoundFile))) + .ToString(SaveOptions.DisableFormatting); + } + + public static byte[] ReadNative(string flatOpc) + { + if (flatOpc == null || flatOpc.Length > 192 * 1024 * 1024) throw new InvalidDataException("Invalid MathType Flat OPC size."); + var package = XDocument.Parse(flatOpc); + var equation = package.Descendants(O + "OLEObject").Single(); + if ((string?)equation.Attribute("ProgID") != "Equation.DSMT4") throw new InvalidDataException("Expected a native MathType object."); + string id = (string?)equation.Attribute(R + "id") ?? throw new InvalidDataException("Missing MathType relationship."); + var documentPart = equation.Ancestors(P + "part").Single(); + string partName = (string)documentPart.Attribute(P + "name")!; + var partUri = new Uri("http://package" + partName); + string relName = partName.Substring(0, partName.LastIndexOf('/') + 1) + "_rels/" + partName.Substring(partName.LastIndexOf('/') + 1) + ".rels"; + var relPart = package.Descendants(P + "part").Single(p => (string?)p.Attribute(P + "name") == relName); + var relationship = relPart.Descendants(Rel + "Relationship").Single(r => (string?)r.Attribute("Id") == id); + if ((string?)relationship.Attribute("TargetMode") == "External") throw new InvalidDataException("MathType object must be embedded."); + string target = new Uri(partUri, (string)relationship.Attribute("Target")!).AbsolutePath; + var binary = package.Descendants(P + "part").Single(p => (string?)p.Attribute(P + "name") == target).Element(P + "binaryData") + ?? throw new InvalidDataException("Missing MathType binary data."); + return MathTypeCompoundFile.ReadNative(Convert.FromBase64String(binary.Value)); + } + + private static XElement Relationship(string id, string type, string target) => new XElement(Rel + "Relationship", + new XAttribute("Id", id), new XAttribute("Type", R.NamespaceName + "/" + type), new XAttribute("Target", target)); + + private static XElement XmlPart(string name, string type, XElement content) => new XElement(P + "part", + new XAttribute(P + "name", name), new XAttribute(P + "contentType", type), new XElement(P + "xmlData", content)); + + private static XElement BinaryPart(string name, string type, byte[] content) => new XElement(P + "part", + new XAttribute(P + "name", name), new XAttribute(P + "contentType", type), new XAttribute(P + "compression", "store"), + new XElement(P + "binaryData", Convert.ToBase64String(content))); +} diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OfficeStaDispatcher.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OfficeStaDispatcher.cs index 0f9d0939..ef39afc8 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OfficeStaDispatcher.cs +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OfficeStaDispatcher.cs @@ -22,11 +22,11 @@ public OfficeStaDispatcher() _ = control.Handle; } - public Task InvokeAsync(Func action, CancellationToken cancellationToken, bool defer = false) + public Task InvokeAsync(Func action, CancellationToken cancellationToken) { if (disposed) throw new ObjectDisposedException(nameof(OfficeStaDispatcher)); cancellationToken.ThrowIfCancellationRequested(); - if (!defer && Thread.CurrentThread.ManagedThreadId == threadId) return Task.FromResult(action()); + if (Thread.CurrentThread.ManagedThreadId == threadId) return Task.FromResult(action()); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); control.BeginInvoke(new Action(() => { diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OleFormulaContent.cs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OleFormulaContent.cs index dc44db30..b716d501 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OleFormulaContent.cs +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Rendering/OleFormulaContent.cs @@ -29,7 +29,9 @@ public static bool IsFormula(object shapeObject) dynamic shape = shapeObject; try { - string id = Convert.ToString(shape.OLEFormat.ProgID) ?? ""; + dynamic? format = shape.OLEFormat; + if (format == null) return false; + string id = Convert.ToString(format.ProgID) ?? ""; return string.Equals(id, ProgId, StringComparison.OrdinalIgnoreCase) || string.Equals(id, "LaTeXSnipper.Formula", StringComparison.OrdinalIgnoreCase); } diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/FailingPowerPointAdapter.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/FailingPowerPointAdapter.cs index f1443c6b..3105b695 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/FailingPowerPointAdapter.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/FailingPowerPointAdapter.cs @@ -2,6 +2,8 @@ using System.Reflection; using System.Runtime.Remoting.Messaging; using System.Runtime.Remoting.Proxies; +using System.Text.RegularExpressions; +using System.Threading; using LaTeXSnipper.OfficePlugin.PowerPointAddIn; internal sealed class FailingPowerPointAdapter : RealProxy @@ -37,7 +39,7 @@ public override IMessage Invoke(IMessage message) && call.Args[0] is PowerPointFormulaEditTarget target && target.Metadata.Identity.EquationId == _equationId) throw new InvalidOperationException("Injected MathType conversion failure"); - if (method.Name == nameof(IPowerPointApplicationAdapter.ReadMathTypeMathMlAsync) + if (method.Name == nameof(IPowerPointApplicationAdapter.ReadMathTypeAsync) && call.Args[0] is LaTeXSnipper.OfficePlugin.Abstractions.MathTypeFormulaTarget mathType && mathType.Location.ToString() == _equationId) throw new InvalidOperationException("Injected MathType import failure"); @@ -57,14 +59,22 @@ public override IMessage Invoke(IMessage message) internal sealed class BatchStatusSink : IPowerPointStatusSink { + private CancellationTokenSource? batchCancellation; public PowerPointStatusKind Kind { get; private set; } public string Message { get; private set; } = string.Empty; + public void CancelAfterNextBatch(CancellationTokenSource cancellation) => batchCancellation = cancellation; + public void Post(PowerPointStatusKind kind, string message) { Kind = kind; Message = message; Console.WriteLine("STATUS|" + kind + "|" + message); + if (batchCancellation != null && Regex.IsMatch(message, @"\d+\s*/\s*\d+", RegexOptions.CultureInvariant)) + { + batchCancellation.Cancel(); + batchCancellation = null; + } } public void SetBusy(bool busy) { } diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/LaTeXSnipper.OfficePlugin.PowerPointE2E.csproj b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/LaTeXSnipper.OfficePlugin.PowerPointE2E.csproj index 0c2b3fdb..bf8bdcae 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/LaTeXSnipper.OfficePlugin.PowerPointE2E.csproj +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/LaTeXSnipper.OfficePlugin.PowerPointE2E.csproj @@ -5,8 +5,11 @@ true + + + diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/MathTypeLayoutRoundTrip.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/MathTypeLayoutRoundTrip.cs new file mode 100644 index 00000000..e20cb927 --- /dev/null +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/MathTypeLayoutRoundTrip.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using LaTeXSnipper.OfficePlugin.Abstractions; +using LaTeXSnipper.OfficePlugin.Automation; +using LaTeXSnipper.OfficePlugin.Editor; +using LaTeXSnipper.OfficePlugin.PowerPointAddIn; +using LaTeXSnipper.OfficePlugin.Rendering; + +internal static class MathTypeLayoutRoundTrip +{ + public static async Task VerifyAsync(object application, string output) + { + dynamic app = application, previous = app.ActivePresentation; + dynamic? source = app.Presentations.Add(), copied = null; + using var adapter = new DynamicPowerPointApplicationAdapter(application); + using var renderer = new MathJaxSvgRenderer(new WebView2MathJaxJavaScriptRuntime("PowerPointMathTypeLayoutE2E")); + var status = new BatchStatusSink(); + using var controller = new PowerPointPluginController(new FormulaEditorSession(new MathTypeRoundTrip.UnusedEditor()), + new AutomationApiClient(new AutomationApiOptions()), adapter, renderer, + new OlePresentationPipeline(new IOlePresentationRenderer[] { new EnhancedMetafilePresentationRenderer() }), status); + var token = CancellationToken.None; + try + { + dynamic slide = source.Slides.Add(1, 12); + slide.Shapes.AddTextbox(1, 40, 30, 500, 30).TextFrame.TextRange.Text = "MathType conversion"; + string[] formulas = { @"\frac{x}{y}+\text{端}", @"\sqrt{x^2+1}", + @"\begin{pmatrix}a&b\\c&d\end{pmatrix}", @"\begin{cases}x&x>0\\-x&x<0\end{cases}", + @"\begin{aligned}x&=12\\y&=\frac{1}{2}\end{aligned}", @"\frac{x}{y}+\text{端}" }; + var geometry = new List<(float Left, float Top, float Width, float Height)>(); + var content = new List(); + for (int index = 0; index < formulas.Length; index++) + { + var typography = FormulaTypography.Default.WithFontSize(16 + index); + var metadata = new FormulaMetadata(new FormulaIdentity(adapter.GetCurrentDocumentId(), Guid.NewGuid().ToString("N")), + formulas[index], FormulaDisplayMode.Display, NumberingMode.None, "", RenderEngineKind.MathJaxSvg, + FormulaMetadata.CurrentSchemaVersion, typography); + var svg = await renderer.RenderAsync(new RenderRequest(metadata.Latex, metadata.DisplayMode, metadata.RenderEngine, typography), token); + var emf = await new EnhancedMetafilePresentationRenderer().RenderPresentationAsync(new OlePresentationRequest(svg, OlePresentationKind.EnhancedMetafile), token); + await adapter.InsertOleFormulaObjectOnSlideAsync(1, metadata, emf, 80 + index % 2 * 350, 100 + index / 2 * 120, 1.2f, token); + dynamic shape = slide.Shapes.Item(index + 2); + geometry.Add((Convert.ToSingle(shape.Left), Convert.ToSingle(shape.Top), Convert.ToSingle(shape.Width), Convert.ToSingle(shape.Height))); + string mathMl = await renderer.ConvertTypographyToMathMlAsync(metadata.Latex, metadata.DisplayMode, typography, token); + content.Add(MathTypeNativeEquation.ReadMathMl(MathTypeNativeEquation.Create(mathMl, typography.FontSizePoints))); + } + slide.Shapes.AddTextbox(1, 40, 490, 500, 30).TextFrame.TextRange.Text = "After formulas"; + slide.Shapes.Range().Select(); + using (var cancellation = new CancellationTokenSource()) + { + status.CancelAfterNextBatch(cancellation); + bool cancelled = false; + try { await Task.Run(() => controller.ConvertSelectedToMathTypeAsync(cancellation.Token)); } + catch (OperationCanceledException) { cancelled = true; } + Check(cancelled, "MathType batch cancellation was not propagated"); + } + Check((await adapter.LoadFormulaEntriesAsync(true, token)).Count == 1, "Cancellation changed an unprocessed formula"); + Check(Convert.ToInt32(slide.Shapes.Count) == 8, "Cancellation left duplicate shapes"); + slide.Shapes.Range().Select(); + await Task.Run(() => controller.ConvertSelectedToMathTypeAsync(token)); + for (int index = 0; index < formulas.Length; index++) + { + dynamic shape = slide.Shapes.Item(index + 2); + Check(MathTypeOleBridge.IsEquation((object)shape), "Conversion changed the original layer order"); + VerifyGeometry((object)shape, geometry[index]); + } + source.SaveAs(Path.Combine(Path.GetDirectoryName(output)!, "powerpoint-mathtype-layout.pdf"), 32); + slide.Shapes.Range().Copy(); + copied = app.Presentations.Add(); + copied.Slides.Add(1, 12); + dynamic targetSlide = copied.Slides.Add(2, 12); + targetSlide.Shapes.Paste(); + source.Saved = -1; + source.Close(); + source = null; + copied.Windows.Item(1).View.GotoSlide(2); + copied.Saved = -1; + for (int index = 0; index < formulas.Length; index++) + { + dynamic shape = targetSlide.Shapes.Item(index + 2); + var native = await adapter.ReadMathTypeAsync(new MathTypeFormulaTarget((object)copied, adapter.GetCurrentDocumentId(), + Convert.ToInt32(shape.Id), 2), token); + Check(native.MathMl == content[index] && Math.Abs(native.FontSizePoints - (16 + index)) < 0.001, + "Copied MathType content changed after the source closed"); + geometry[index] = (Convert.ToSingle(shape.Left), Convert.ToSingle(shape.Top), Convert.ToSingle(shape.Width), Convert.ToSingle(shape.Height)); + } + Check(Convert.ToInt32(copied.Saved) == -1, "Reading MathType content changed the document saved state"); + targetSlide.Shapes.Range().Select(); + await Task.Run(() => controller.ConvertSelectedToOleAsync(token)); + for (int index = 0; index < formulas.Length; index++) + { + dynamic shape = targetSlide.Shapes.Item(index + 2); + Check(OleFormulaContent.IsFormula((object)shape), "Import changed the original layer order"); + VerifyGeometry((object)shape, geometry[index]); + } + Check((await adapter.LoadFormulaEntriesAsync(true, token)).Count == formulas.Length, "Copied MathType import did not finish"); + copied.SaveAs(Path.Combine(Path.GetDirectoryName(output)!, "powerpoint-mathtype-layout-import.pptx"), 24); + Console.WriteLine("PASS|PPT MathType roots/matrix/cases/alignment/twins, cancellation/retry, layers, saved state, copy after source closed"); + } + finally + { + if (source != null) Close((object)source); + if (copied != null) Close((object)copied); + previous.Windows.Item(1).Activate(); + } + } + + private static void VerifyGeometry(object shapeObject, (float Left, float Top, float Width, float Height) expected) + { + dynamic shape = shapeObject; + Check(Math.Abs(Convert.ToSingle(shape.Left) - expected.Left) < 0.1 + && Math.Abs(Convert.ToSingle(shape.Top) - expected.Top) < 0.1 + && Math.Abs(Convert.ToSingle(shape.Width) - expected.Width) < 0.1 + && Math.Abs(Convert.ToSingle(shape.Height) - expected.Height) < 0.1, "Conversion changed formula geometry"); + } + + private static void Close(object presentation) + { + try { ((dynamic)presentation).Saved = -1; ((dynamic)presentation).Close(); } + catch (COMException) { } + } + + private static void Check(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/MathTypeRoundTrip.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/MathTypeRoundTrip.cs index dec4697e..a13e0277 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/MathTypeRoundTrip.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/MathTypeRoundTrip.cs @@ -1,25 +1,32 @@ using System; using System.IO; +using System.IO.Compression; using System.Linq; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; +using System.Windows.Forms; +using System.Diagnostics; using LaTeXSnipper.OfficePlugin.Abstractions; using LaTeXSnipper.OfficePlugin.PowerPointAddIn; using LaTeXSnipper.OfficePlugin.Automation; using LaTeXSnipper.OfficePlugin.Editor; using LaTeXSnipper.OfficePlugin.Rendering; +using LaTeXSnipper.OfficePlugin.Testing; internal static class MathTypeRoundTrip { public static async Task VerifyAsync(object application, object mainPresentation, - PowerPointPluginController controller, MathJaxSvgRenderer renderer, string output) + PowerPointPluginController controller, MathJaxSvgRenderer renderer, string output, bool nativeEdit) { dynamic app = application, main = mainPresentation; dynamic presentation = app.Presentations.Add(); presentation.Slides.Add(1, 12); - var adapter = new DynamicPowerPointApplicationAdapter(application); + using var adapter = new DynamicPowerPointApplicationAdapter(application); + var originalMathTypeProcesses = Process.GetProcessesByName("MathType").Select(p => p.Id).ToArray(); + string clipboardText = "MathType conversion clipboard " + Guid.NewGuid().ToString("N"); + Clipboard.SetText(clipboardText); var token = CancellationToken.None; try { @@ -30,10 +37,12 @@ public static async Task VerifyAsync(object application, object mainPresentation var emf = await new EnhancedMetafilePresentationRenderer().RenderPresentationAsync(new OlePresentationRequest(svg, OlePresentationKind.EnhancedMetafile), token); await adapter.InsertOleFormulaObjectOnSlideAsync(1, metadata, emf, 80, 90, 1.5f, token); dynamic original = presentation.Slides.Item(1).Shapes.Item(1); + original.LockAspectRatio = 0; + original.Width *= 1.2f; float width = original.Width, height = original.Height; var second = new FormulaMetadata(new FormulaIdentity(metadata.Identity.DocumentId, Guid.NewGuid().ToString("N")), @"\textcolor{#ff0000}{e^{i\pi}+1=0}", metadata.DisplayMode, metadata.NumberingMode, metadata.NumberText, - metadata.RenderEngine, metadata.SchemaVersion, metadata.Typography); + metadata.RenderEngine, metadata.SchemaVersion, metadata.Typography.WithFontSize(24)); var secondSvg = await renderer.RenderAsync(new RenderRequest(second.Latex, second.DisplayMode, second.RenderEngine, second.Typography), token); var secondEmf = await new EnhancedMetafilePresentationRenderer().RenderPresentationAsync(new OlePresentationRequest(secondSvg, OlePresentationKind.EnhancedMetafile), token); await adapter.InsertOleFormulaObjectOnSlideAsync(1, second, secondEmf, 80, 180, 1.5f, token); @@ -51,26 +60,38 @@ public static async Task VerifyAsync(object application, object mainPresentation Check((await adapter.LoadFormulaEntriesAsync(true, token)).Count == 1, "MathType batch stopped after first failure"); original.Select(); await Task.Run(() => controller.ConvertSelectedToMathTypeAsync(token)); - dynamic converted = presentation.Slides.Item(1).Shapes.Item(2); + Check(Clipboard.GetText() == clipboardText, "MathType conversion changed the clipboard"); + dynamic converted = presentation.Slides.Item(1).Shapes.Item(1); Check(Convert.ToInt32(presentation.Slides.Item(1).Shapes.Count) == 2, "MathType conversion duplicated the object"); Check(Math.Abs(Convert.ToSingle(converted.Width) - width) < 0.1 && Math.Abs(Convert.ToSingle(converted.Height) - height) < 0.1 && Math.Abs(Convert.ToSingle(converted.Rotation)) < 0.1, "MathType conversion changed instance geometry: " + width + "x" + height + " -> " + converted.Width + "x" + converted.Height); - VerifyNative(converted); - VerifyNative(presentation.Slides.Item(1).Shapes.Item(1)); + await VerifyNativeAsync(adapter, (object)presentation, (object)converted, token); + await VerifyNativeAsync(adapter, (object)presentation, (object)presentation.Slides.Item(1).Shapes.Item(2), token); string path = Path.Combine(Path.GetDirectoryName(output)!, "powerpoint-mathtype.pptx"); presentation.SaveAs(path, 24); presentation.Close(); presentation = app.Presentations.Open(path, 0, 0, -1); - VerifyNative(presentation.Slides.Item(1).Shapes.Item(1)); - VerifyNative(presentation.Slides.Item(1).Shapes.Item(2)); + await VerifyNativeAsync(adapter, (object)presentation, (object)presentation.Slides.Item(1).Shapes.Item(1), token); + await VerifyNativeAsync(adapter, (object)presentation, (object)presentation.Slides.Item(1).Shapes.Item(2), token); presentation.SaveAs(Path.ChangeExtension(path, ".pdf"), 32); string changed = await renderer.ConvertTypographyToMathMlAsync(@"z^2+\text{新}", FormulaDisplayMode.Display, FormulaTypography.Default, token); - presentation.Slides.Item(1).Shapes.Item(1).OLEFormat.Activate(); - object server = presentation.Slides.Item(1).Shapes.Item(1).OLEFormat.Object; - try { MathTypeOleBridge.WriteMathMl(server, changed); } - finally { Marshal.ReleaseComObject(server); } + if (nativeEdit) + { + presentation.Slides.Item(1).Shapes.Item(1).OLEFormat.Activate(); + object server = presentation.Slides.Item(1).Shapes.Item(1).OLEFormat.Object; + try { MathTypeNativeServer.WriteMathMl(server, changed); } + finally { Marshal.ReleaseComObject(server); } + Console.WriteLine("PASS|PowerPoint MathType native server opened and edited independently generated MTEF"); + } + else + { + // Replace the persisted native payload to verify reading current content without a server. + presentation.Close(); + ReplacePersistedNative(path, MathTypeCompoundFile.Create(MathTypeNativeEquation.Create(changed, FormulaTypography.Default.FontSizePoints))); + presentation = app.Presentations.Open(path, 0, 0, -1); + } presentation.Slides.Item(1).Shapes.Range().Select(); string failedShapeId = Convert.ToInt32(presentation.Slides.Item(1).Shapes.Item(1).Id).ToString(); status = new BatchStatusSink(); @@ -87,37 +108,44 @@ public static async Task VerifyAsync(object application, object mainPresentation Check(imported.Count == 2, "Mixed OLE/MathType import did not finish"); var edited = imported.Single(e => XDocument.Parse(e.Metadata.Latex).Root!.Value.Contains("新")); Check(XDocument.Parse(edited.Metadata.Latex).Root!.Value.Contains("z"), "Import restored stale content instead of MathType edit"); + Check(Math.Abs(edited.Metadata.Typography.FontSizePoints - FormulaTypography.Default.FontSizePoints) < 0.001, "Import lost the native font size"); presentation.SaveAs(Path.Combine(Path.GetDirectoryName(output)!, "powerpoint-mathtype-import.pptx"), 24); Console.WriteLine("PASS|PPT MathType to OLE: current edited content, mixed selection, first failure continues, worker thread"); Console.WriteLine("PASS|PPT MathType native fraction/Chinese, geometry and save/reopen"); + await MathTypeLayoutRoundTrip.VerifyAsync(application, output); + if (!nativeEdit) + { + Check(Process.GetProcessesByName("MathType").All(p => originalMathTypeProcesses.Contains(p.Id)), "Conversion activated the MathType server"); + Check(!Process.GetCurrentProcess().Modules.Cast().Any(m => m.ModuleName.Equals("MT6.dll", StringComparison.OrdinalIgnoreCase)), + "Conversion loaded the MathType SDK"); + Console.WriteLine("PASS|PowerPoint MathType conversion did not activate a server or load its SDK"); + } } finally { - presentation.Saved = -1; - presentation.Close(); + try { presentation.Saved = -1; presentation.Close(); } + catch (COMException) { } main.Windows.Item(1).Activate(); } } - private static void VerifyNative(dynamic shape) + private static async Task VerifyNativeAsync(DynamicPowerPointApplicationAdapter adapter, object presentation, object shapeObject, CancellationToken token) { + dynamic shape = shapeObject; Check(Convert.ToString(shape.OLEFormat.ProgID) == MathTypeOleBridge.ProgId, "MathType class identity"); - shape.OLEFormat.Activate(); - object server = shape.OLEFormat.Object; - string mathMl; - try - { - mathMl = MathTypeOleBridge.ReadAndCloseMathMl(server); - } - finally { Marshal.ReleaseComObject(server); } - var xml = XDocument.Parse(mathMl); + var content = await adapter.ReadMathTypeAsync(new MathTypeFormulaTarget(presentation, adapter.GetCurrentDocumentId(), + Convert.ToInt32(shape.Id), Convert.ToInt32(shape.Parent.SlideIndex)), token); + var xml = XDocument.Parse(content.MathMl); bool fraction = xml.Descendants().Any(e => e.Name.LocalName == "mfrac") && xml.Root!.Value.Contains("端"); bool euler = xml.Descendants().Any(e => e.Name.LocalName == "msup") && xml.Root!.Value.Contains("e") && xml.Root.Value.Contains("π") && xml.Root.Value.Contains("1") && xml.Root.Value.Contains("0"); Check(fraction || euler, "MathType lost fraction/Chinese or part of the Euler formula"); + Check(Math.Abs(content.FontSizePoints - (euler ? 24 : FormulaTypography.Default.FontSizePoints)) < 0.001, + "MathType font size was not preserved"); + if (euler) Check(xml.Descendants().Attributes("mathcolor").Any(a => a.Value == "#ff0000"), "MathType lost the Euler color"); } - private sealed class UnusedEditor : IFormulaEditor + internal sealed class UnusedEditor : IFormulaEditor { public Task WarmUpAsync(CancellationToken token) => Task.CompletedTask; public Task OpenAsync(FormulaMetadata metadata, bool updateMode, long generation, CancellationToken token) @@ -126,4 +154,22 @@ public void Dispose() { } } private static void Check(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } + + private static void ReplacePersistedNative(string path, byte[] compoundFile) + { + using var zip = ZipFile.Open(path, ZipArchiveMode.Update); + var slideEntry = zip.GetEntry("ppt/slides/slide1.xml")!; + XDocument slide; + using (var stream = slideEntry.Open()) slide = XDocument.Load(stream); + XNamespace p = "http://schemas.openxmlformats.org/presentationml/2006/main"; + XNamespace r = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + string id = (string)slide.Descendants(p + "oleObj").First().Attribute(r + "id")!; + XDocument relations; + using (var stream = zip.GetEntry("ppt/slides/_rels/slide1.xml.rels")!.Open()) relations = XDocument.Load(stream); + string target = (string)relations.Root!.Elements().Single(e => (string?)e.Attribute("Id") == id).Attribute("Target")!; + string part = new Uri(new Uri("http://package/ppt/slides/slide1.xml"), target).AbsolutePath.TrimStart('/'); + zip.GetEntry(part)!.Delete(); + using var native = zip.CreateEntry(part).Open(); + native.Write(compoundFile, 0, compoundFile.Length); + } } diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/PowerPointRoundTrip.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/PowerPointRoundTrip.cs index 6b4603b9..ec45468c 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/PowerPointRoundTrip.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/PowerPointRoundTrip.cs @@ -16,7 +16,8 @@ internal static class PowerPointRoundTrip { private static readonly CancellationToken Token = CancellationToken.None; - public static async Task RunAsync(string output, bool includeOle, bool batchOnly = false, bool includeMathType = false, bool copyOnly = false, bool gestureOnly = false) + public static async Task RunAsync(string output, bool includeOle, bool batchOnly = false, bool includeMathType = false, + bool copyOnly = false, bool gestureOnly = false, bool mathTypeNativeEdit = false, bool mathTypeOnly = false) { if (Process.GetProcessesByName("POWERPNT").Length != 0) throw new InvalidOperationException("Close PowerPoint before running this isolated test."); @@ -50,7 +51,8 @@ public static async Task RunAsync(string output, bool includeOle, bool batchOnly new AutomationApiClient(new AutomationApiOptions()), adapter, renderer, new OlePresentationPipeline(new IOlePresentationRenderer[] { new EnhancedMetafilePresentationRenderer() }), statusSink: new BatchStatusSink(), optionsProvider: options); - if (includeMathType) await MathTypeRoundTrip.VerifyAsync((object)app, (object)presentation, controller, renderer, output); + if (includeMathType) await MathTypeRoundTrip.VerifyAsync((object)app, (object)presentation, controller, renderer, output, mathTypeNativeEdit); + if (mathTypeOnly) return; var style = new FormulaTypography("mathjax-stix2", "Times New Roman", "SimSun", FormulaMathStyle.BoldItalic, 15.5, "#123ABC"); if (copyOnly || gestureOnly) diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/Program.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/Program.cs index d6844a2c..243d2244 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/Program.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/Program.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Windows.Forms; internal static class Program @@ -7,9 +8,15 @@ internal static class Program [STAThread] private static int Main(string[] args) { - if (args.Length < 1 || args.Length > 3 || (args.Length >= 2 && args[1] != "--ole" && args[1] != "--batch" && args[1] != "--copy" && args[1] != "--gesture") - || (args.Length == 3 && ((args[1] != "--ole" && args[1] != "--copy") || args[2] != "--mathtype"))) - { Console.Error.WriteLine("Usage: PowerPointE2E [--ole [--mathtype]|--batch|--copy [--mathtype]|--gesture]"); return 2; } + string mode = args.Length >= 2 ? args[1] : "--ole"; + bool includeMathType = mode == "--mathtype-only" || args.Skip(2).Contains("--mathtype"); + bool nativeEdit = args.Skip(2).Contains("--mathtype-native-edit"); + if (args.Length < 1 || args.Length > 4 || !new[] { "--ole", "--batch", "--copy", "--gesture", "--mathtype-only" }.Contains(mode) + || args.Skip(2).Any(a => a != "--mathtype" && a != "--mathtype-native-edit") + || args.Skip(2).Distinct().Count() != args.Skip(2).Count() + || includeMathType && mode != "--ole" && mode != "--copy" && mode != "--mathtype-only" + || nativeEdit && !includeMathType) + { Console.Error.WriteLine("Usage: PowerPointE2E [--ole|--batch|--copy|--gesture|--mathtype-only] [--mathtype] [--mathtype-native-edit]"); return 2; } Application.EnableVisualStyles(); System.Diagnostics.Trace.Listeners.Add(new System.Diagnostics.ConsoleTraceListener()); int exitCode = 1; @@ -20,8 +27,8 @@ private static int Main(string[] args) try { await PowerPointRoundTrip.RunAsync(Path.GetFullPath(args[0]), - args.Length >= 2 && args[1] == "--ole", args.Length >= 2 && args[1] == "--batch", args.Length == 3, - args.Length >= 2 && args[1] == "--copy", args.Length >= 2 && args[1] == "--gesture"); + mode == "--ole", mode == "--batch", includeMathType, + mode == "--copy", mode == "--gesture", nativeEdit, mode == "--mathtype-only"); exitCode = 0; } catch (Exception error) { Console.Error.WriteLine(error); } diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/README.md b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/README.md index 7c4151d1..d0a52a31 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/README.md +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.PowerPointE2E/README.md @@ -8,7 +8,7 @@ - 文本光标处插入原生公式,包括中文;选中已有公式时可以新建其他公式; - 多选格式化、转换和全文格式化的单项失败续跑、错误信息与重试; - OLE 跨演示文稿复制、来源关闭、同页副本独立编辑与固定演示文稿目标保存; -- `-IncludeMathType` 验证从工作线程调用实际 MathType 转换、多选单项失败续跑和重试、原生分式/中文、对象几何和保存重开; +- `-IncludeMathType` 验证从工作线程双向转换原生 MathType、多选单项失败续跑和重试、分式/中文、根式/矩阵/分段/对齐、颜色/字号、用户拉伸尺寸、层次、剪贴板、取消和跨演示文稿复制;核心验收不激活 MathType 或加载 SDK; - 保存 PPTX、关闭并重新打开后,源码、身份、样式、页码与位置一致。 测试使用独立渲染实例,不修改用户设置;格式化读取当前设置。完整测试包含选中对象后再次插入 OLE,不能作为纯 PNG 测试运行。`--ole` 还验证 PNG/OLE 双向转换、OLE 激活、重新编辑与保存重开;`--copy` 只验证 OLE 复制、独立编辑与保存重开;`--batch` 只验证 PNG 批处理,不依赖 OLE handler。 @@ -21,6 +21,10 @@ office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode Copy # 检查 MathType 批量转换与 OLE 复制: office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode Copy -IncludeMathType +# 只检查 MathType 双向转换: +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode MathType +# 额外打开已安装的 MathType 编辑服务器,修改内容后再导入: +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode MathType -PowerPointMathTypeNativeEdit # 交互式双击验收:出现 READY 后在 90 秒内双击第一页公式,验证正式回调加载命中的对象: office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode Gesture # 只检查批处理: @@ -29,4 +33,4 @@ office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPoi 先构建 Release 版本;需要 Windows、PowerPoint、WebView2 和所需系统字体。统一脚本使用唯一的临时输出目录,并为完整测试临时注册 OLE handler;详细要求见 [插件说明](../../README.md)。完整测试保存的 PPTX 可用于人工复核;批处理模式不保存 PPTX,临时 PNG 在测试结束时清理。 -MathType 验收还覆盖当前原生预览比例、红色 Euler 公式、修改原生内容后通过“转为 OLE”导入、混合多选及首项失败续跑;转换后的 MathML 是唯一源码。 +核心 MathType 验收通过修改保存文件中的原生内容,确认“转为 OLE”读取当前 MathML 与字号。可选原生编辑验收需要安装 MathType,并通过其实际编辑服务器修改同一对象;SDK 辅助代码只编译进测试程序。 diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/Fixtures/MathTypeChineseEquationNative.bin b/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/Fixtures/MathTypeChineseEquationNative.bin new file mode 100644 index 0000000000000000000000000000000000000000..9255cb12aa773f3e5576b99e6c233c4a5aa057ea GIT binary patch literal 264 zcmb1PU|?Wk&^`PHNN-_?WBPDC1ju4w-~qB(85!6(U4nf>%orGj!!z?7b8?&#i!+m* z^HWj-64O(Q83b8FGILXl75q}m6@v0}6Z04ZnS(2Hlk#(bQqK9MMVYBZAV~&67T*vB z*NT#&Lzwc=gkUB7^kBR>x#0}wI+0nmqxjEoG-Kt2lt XD+3oJg8端` and reading the saved Word object's `Equation Native` stream. Its MTEF header reports format v5, producer MathType 6.9, and its full-size preference is 12 pt. + +This is independent input for the decoder. The optional Word native-edit E2E test can generate another reference stream in its output directory. The binary contains one Chinese character, font definitions, preferences and standard native-header fields; it contains no original document or personal data. diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/LaTeXSnipper.OfficePlugin.Typography.Tests.csproj b/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/LaTeXSnipper.OfficePlugin.Typography.Tests.csproj index 6ece8331..36c22d80 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/LaTeXSnipper.OfficePlugin.Typography.Tests.csproj +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/LaTeXSnipper.OfficePlugin.Typography.Tests.csproj @@ -5,12 +5,15 @@ false + + + diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypeMathMlTests.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypeMathMlTests.cs deleted file mode 100644 index d139cefc..00000000 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypeMathMlTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Xml.Linq; -using LaTeXSnipper.OfficePlugin.Rendering; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace LaTeXSnipper.OfficePlugin.Typography.Tests; - -[TestClass] -public sealed class MathTypeMathMlTests -{ - [TestMethod] - public void OneSidedFenceKeepsTableAndStyleWithoutEmptyOperator() - { - const string source = "" - + "{μ"; - var expected = XDocument.Parse(source); - XNamespace ns = expected.Root!.Name.Namespace; - expected.Root.Element(ns + "mstyle")!.Element(ns + "mrow")!.Element(ns + "mtable")!.NextNode!.Remove(); - var actual = XDocument.Parse(MathTypeMathMl.PrepareForImport(source)); - Assert.IsTrue(XNode.DeepEquals(expected, actual)); - } - - [TestMethod] - public void OrdinaryOperatorsAndExplicitSpaceRemainIntact() - { - const string source = "ei" - + "+1=0"; - Assert.IsTrue(XNode.DeepEquals(XDocument.Parse(source), XDocument.Parse(MathTypeMathMl.PrepareForImport(source)))); - } - - [TestMethod] - public void NonMathMlInputIsRejected() - => Assert.ThrowsExactly(() => MathTypeMathMl.PrepareForImport("x")); -} diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypeNativeEquationTests.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypeNativeEquationTests.cs new file mode 100644 index 00000000..a094533e --- /dev/null +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypeNativeEquationTests.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using LaTeXSnipper.OfficePlugin.Rendering; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LaTeXSnipper.OfficePlugin.Typography.Tests; + +[TestClass] +public sealed class MathTypeNativeEquationTests +{ + [TestMethod] + public void ReadsEquationNativeProducedByInstalledMathType() + { + using var fixture = typeof(MathTypeNativeEquationTests).Assembly.GetManifestResourceStream( + "LaTeXSnipper.OfficePlugin.Typography.Tests.Fixtures.MathTypeChineseEquationNative.bin")!; + using var buffer = new MemoryStream(); + fixture.CopyTo(buffer); + byte[] native = buffer.ToArray(); + Assert.AreEqual("端", XDocument.Parse(MathTypeNativeEquation.ReadMathMl(native)).Root!.Value); + Assert.AreEqual(12d, MathTypeNativeEquation.ReadFontSizePoints(native), 0.001); + } + + [TestMethod] + public void ScopedColorsDoNotLeakIntoFollowingTokens() + { + byte[] native = MathTypeNativeEquation.Create("xyzw", 12); + var tokens = XDocument.Parse(MathTypeNativeEquation.ReadMathMl(native)).Descendants().Where(e => e.Name.LocalName == "mi").ToArray(); + CollectionAssert.AreEqual(new[] { "#ff0000", "#0000ff", "#ff0000", "" }, tokens.Select(e => (string?)e.Attribute("mathcolor") ?? "").ToArray()); + } + + [TestMethod] + public void LocalSizeIsRejectedRatherThanSilentlyDiscarded() + => Assert.ThrowsExactly(() => MathTypeNativeEquation.Create("x", 12)); + [TestMethod] + [DataRow("eiπ+1=0", "msup")] + [DataRow("xy端", "mfrac")] + [DataRow("x+1", "msqrt")] + [DataRow("abcd", "mtable")] + public void NativeContentRetainsStructureColorSizeAndNamespace(string body, string structure) + { + string mathMl = "" + body + ""; + byte[] native = MathTypeNativeEquation.Create(mathMl, 18); + var decoded = XDocument.Parse(MathTypeNativeEquation.ReadMathMl(native)); + Assert.AreEqual(18d, MathTypeNativeEquation.ReadFontSizePoints(native), 0.001); + Assert.IsTrue(decoded.Descendants().Any(e => e.Name.LocalName == structure)); + Assert.IsTrue(decoded.Descendants().All(e => e.Name.NamespaceName == "http://www.w3.org/1998/Math/MathML")); + Assert.IsTrue(decoded.Descendants().Attributes("mathcolor").Any(a => a.Value == "#d52020")); + if (body.Contains("端")) Assert.IsTrue(decoded.Root!.Value.Contains("端")); + } + + [TestMethod] + public void TruncatedNativeDataIsRejected() + { + var native = MathTypeNativeEquation.Create("x", 12); + Assert.ThrowsExactly(() => MathTypeNativeEquation.ReadMathMl(native.Take(native.Length - 2).ToArray())); + } + + [TestMethod] + public void UnsupportedStructureIsRejected() + => Assert.ThrowsExactly(() => MathTypeNativeEquation.Create("x", 12)); +} diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypePowerPointPackageTests.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypePowerPointPackageTests.cs new file mode 100644 index 00000000..a8ce85cc --- /dev/null +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.Typography.Tests/MathTypePowerPointPackageTests.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; +using LaTeXSnipper.OfficePlugin.Rendering; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LaTeXSnipper.OfficePlugin.Typography.Tests; + +[TestClass] +public sealed class MathTypePowerPointPackageTests +{ + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static readonly XNamespace Relations = "http://schemas.openxmlformats.org/package/2006/relationships"; + + [TestMethod] + public void ReadsCurrentNativeContentByLogicalSlideOrderAndShapeIdentity() + { + string path = CreatePresentation(); + try + { + Assert.AreEqual("selected", Value(MathTypePowerPointPackage.ReadNative(path, 1, 43))); + Assert.AreEqual("other", Value(MathTypePowerPointPackage.ReadNative(path, 2, 43))); + Assert.AreEqual(23.5, MathTypeNativeEquation.ReadFontSizePoints(MathTypePowerPointPackage.ReadNative(path, 1, 43)), 0.001); + } + finally { File.Delete(path); } + } + + [TestMethod] + public void ChoiceAndFallbackCanReferenceTheSameNativeObject() + { + string path = CreatePresentation(duplicateObject: true); + try { Assert.AreEqual("selected", Value(MathTypePowerPointPackage.ReadNative(path, 1, 43))); } + finally { File.Delete(path); } + } + + [TestMethod] + [DataRow("External", "oleObject", "Equation.DSMT4")] + [DataRow("", "image", "Equation.DSMT4")] + [DataRow("", "oleObject", "Excel.Sheet.12")] + public void RejectsLinkedOrMismatchedObjects(string targetMode, string relationType, string progId) + { + string path = CreatePresentation(targetMode, relationType, progId); + try { Assert.ThrowsExactly(() => MathTypePowerPointPackage.ReadNative(path, 1, 43)); } + finally { File.Delete(path); } + } + + private static string Value(byte[] native) => XDocument.Parse(MathTypeNativeEquation.ReadMathMl(native)).Root!.Value; + + private static string CreatePresentation(string targetMode = "", string relationType = "oleObject", + string progId = "Equation.DSMT4", bool duplicateObject = false) + { + string path = Path.Combine(Path.GetTempPath(), "latexsnipper-ppt-package-test-" + Guid.NewGuid().ToString("N") + ".pptx"); + using var zip = ZipFile.Open(path, ZipArchiveMode.Create); + WriteXml(zip, "ppt/presentation.xml", new XElement(P + "presentation", new XElement(P + "sldIdLst", + new XElement(P + "sldId", new XAttribute(R + "id", "first")), + new XElement(P + "sldId", new XAttribute(R + "id", "second"))))); + WriteXml(zip, "ppt/_rels/presentation.xml.rels", new XElement(Relations + "Relationships", + Relationship("first", "slide", "slides/slide7.xml"), Relationship("second", "slide", "slides/slide3.xml"))); + var nativeObject = new XElement(P + "oleObj", new XAttribute("progId", progId), + new XAttribute(R + "id", "currentNative"), new XElement(P + "embed")); + var frame = new XElement(P + "graphicFrame", new XElement(P + "nvGraphicFramePr", + new XElement(P + "cNvPr", new XAttribute("id", 43))), nativeObject); + if (duplicateObject) + { + XNamespace mc = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + nativeObject.Remove(); + frame.Add(new XElement(mc + "AlternateContent", new XElement(mc + "Choice", nativeObject), + new XElement(mc + "Fallback", new XElement(nativeObject)))); + } + WriteXml(zip, "ppt/slides/slide7.xml", new XElement(P + "sld", frame)); + WriteXml(zip, "ppt/slides/_rels/slide7.xml.rels", new XElement(Relations + "Relationships", + Relationship("currentNative", relationType, "../embeddings/selected.bin", targetMode))); + WriteXml(zip, "ppt/slides/slide3.xml", new XElement(P + "sld", new XElement(frame))); + WriteXml(zip, "ppt/slides/_rels/slide3.xml.rels", new XElement(Relations + "Relationships", + Relationship("currentNative", "oleObject", "../embeddings/other.bin"))); + Write(zip, "ppt/embeddings/selected.bin", Native("selected")); + Write(zip, "ppt/embeddings/other.bin", Native("other")); + return path; + } + + private static byte[] Native(string value) => MathTypeCompoundFile.Create(MathTypeNativeEquation.Create( + "" + value + "", 23.5)); + + private static XElement Relationship(string id, string type, string target, string targetMode = "") + => new XElement(Relations + "Relationship", new XAttribute("Id", id), new XAttribute("Type", R.NamespaceName + "/" + type), + new XAttribute("Target", target), targetMode.Length == 0 ? null : new XAttribute("TargetMode", targetMode)); + + private static void WriteXml(ZipArchive zip, string name, XElement element) + => Write(zip, name, Encoding.UTF8.GetBytes(element.ToString(SaveOptions.DisableFormatting))); + + private static void Write(ZipArchive zip, string name, byte[] bytes) + { + using var stream = zip.CreateEntry(name).Open(); + stream.Write(bytes, 0, bytes.Length); + } +} diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/FailingWordAdapter.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/FailingWordAdapter.cs index 1ebb89ff..d9cdfd1a 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/FailingWordAdapter.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/FailingWordAdapter.cs @@ -41,7 +41,7 @@ public override IMessage Invoke(IMessage message) && call.Args[0] is WordFormulaEditTarget target && target.Metadata.Identity.EquationId == _equationId) throw new InvalidOperationException("Injected MathType conversion failure"); - if (method.Name == nameof(IWordApplicationAdapter.ReadMathTypeMathMlAsync) + if (method.Name == nameof(IWordApplicationAdapter.ReadMathTypeAsync) && call.Args[0] is LaTeXSnipper.OfficePlugin.Abstractions.MathTypeFormulaTarget mathType && mathType.Location.ToString() == _equationId) throw new InvalidOperationException("Injected MathType import failure"); diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/LaTeXSnipper.OfficePlugin.WordParsingE2E.csproj b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/LaTeXSnipper.OfficePlugin.WordParsingE2E.csproj index 60b9b080..6effa23c 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/LaTeXSnipper.OfficePlugin.WordParsingE2E.csproj +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/LaTeXSnipper.OfficePlugin.WordParsingE2E.csproj @@ -1,6 +1,7 @@ + diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/MathTypeRoundTrip.Layout.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/MathTypeRoundTrip.Layout.cs new file mode 100644 index 00000000..48620073 --- /dev/null +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/MathTypeRoundTrip.Layout.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using LaTeXSnipper.OfficePlugin.Abstractions; +using LaTeXSnipper.OfficePlugin.Rendering; +using LaTeXSnipper.OfficePlugin.WordAddIn; + +namespace LaTeXSnipper.OfficePlugin.WordParsingE2E; + +internal static partial class MathTypeRoundTrip +{ + private static async Task VerifyLayoutAsync(object application, object sourceDocument, string output) + { + dynamic word = application; + dynamic? document = word.Documents.Add(); + dynamic? copiedDocument = null; + var adapter = new DynamicWordApplicationAdapter(application); + var status = new RecordingStatusSink(); + using var controller = WordAddInFactory.CreateController(application, statusSink: status, applicationAdapter: adapter, + mathJaxHostName: "MathTypeLayout-" + Guid.NewGuid().ToString("N")); + try + { + document.Content.Text = "before after\r"; + const string source = @"e^{i\pi}+1=0"; + dynamic sourceRange = ((dynamic)sourceDocument).InlineShapes.Item(2).Range; + for (int i = 0; i < 2; i++) + { + sourceRange.Copy(); + document.Range(7 + i, 7 + i).Paste(); + document.InlineShapes.Item(i + 1).Range.Select(); + _ = await adapter.LoadSelectedFormulaTargetAsync(CancellationToken.None); + } + dynamic table = document.Tables.Add(document.Range(Convert.ToInt32(document.Content.End) - 1), 1, 1); + table.Cell(1, 1).Range.Text = "table-before table-after"; + int start = Convert.ToInt32(table.Cell(1, 1).Range.Start) + 13; + for (int i = 0; i < 5; i++) + { + sourceRange.Copy(); + document.Range(start + i, start + i).PasteSpecial(DataType: 0, Placement: 0); + document.InlineShapes.Item(i + 3).Range.Select(); + _ = await adapter.LoadSelectedFormulaTargetAsync(CancellationToken.None); + } + string text = Convert.ToString(document.Content.Text); + int paragraphs = Convert.ToInt32(document.Paragraphs.Count); + float untouchedWidth = Convert.ToSingle(document.InlineShapes.Item(1).Width); + dynamic selected = document.InlineShapes.Item(2); + selected.LockAspectRatio = -1; + selected.Height = Convert.ToSingle(selected.Height) * 1.5f; + float width = Convert.ToSingle(selected.Width), height = Convert.ToSingle(selected.Height); + selected.Range.Select(); + await controller.ConvertSelectedToMathTypeAsync(CancellationToken.None); + E2EAssert.Equal(7, Convert.ToInt32(document.InlineShapes.Count), "Converting one of two identical objects changed count"); + E2EAssert.Equal(6, (await adapter.LoadFormulaEntriesAsync(true, CancellationToken.None)).Count, "Converting one formula also converted its twin"); + E2EAssert.True(Math.Abs(Convert.ToSingle(document.InlineShapes.Item(1).Width) - untouchedWidth) < 0.1, "Unselected twin changed size"); + E2EAssert.True(Math.Abs(Convert.ToSingle(document.InlineShapes.Item(2).Width) - width) < 0.1 + && Math.Abs(Convert.ToSingle(document.InlineShapes.Item(2).Height) - height) < 0.1, "User scaling was lost"); + using (var cancellation = new CancellationTokenSource()) + { + status.CancelAfterNextBatch(cancellation); + document.Content.Select(); + bool cancelled = false; + try { await controller.ConvertSelectedToMathTypeAsync(cancellation.Token); } + catch (OperationCanceledException) { cancelled = true; } + E2EAssert.True(cancelled, "MathType batch cancellation was not propagated"); + } + E2EAssert.Equal(1, (await adapter.LoadFormulaEntriesAsync(true, CancellationToken.None)).Count, "Cancellation modified unprocessed targets"); + E2EAssert.Equal(7, Convert.ToInt32(document.InlineShapes.Count), "Cancellation left extra objects"); + document.Content.Select(); + await controller.ConvertSelectedToMathTypeAsync(CancellationToken.None); + E2EAssert.Equal(text, Convert.ToString(document.Content.Text), "MathType conversion changed surrounding body/table text"); + E2EAssert.Equal(paragraphs, Convert.ToInt32(document.Paragraphs.Count), "MathType conversion added paragraphs"); + E2EAssert.Equal(1, Convert.ToInt32(document.Tables.Count), "MathType conversion changed table structure"); + document.SaveAs2(Path.Combine(Path.GetDirectoryName(output)!, "word-mathtype-layout.docx"), 16); + copiedDocument = word.Documents.Add(); + document.InlineShapes.Item(2).Range.Copy(); + copiedDocument.Range(0, 0).Paste(); + document.Close(0); + document = null; + VerifyShape(copiedDocument.InlineShapes.Item(1), source, "msup", false); + E2EAssert.True(Math.Abs(Convert.ToSingle(copiedDocument.InlineShapes.Item(1).Width) - width) < 0.1, "Cross-document copy changed width"); + Console.WriteLine("PASS|MathType identical twins, scaling, adjacent objects, table/body text, cancellation/retry, copy after source closed"); + } + finally + { + if (document != null) document.Close(0); + if (copiedDocument != null) copiedDocument.Close(0); + } + } + +} diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/MathTypeRoundTrip.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/MathTypeRoundTrip.cs index 86a92308..c74f170f 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/MathTypeRoundTrip.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/MathTypeRoundTrip.cs @@ -5,15 +5,17 @@ using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; +using System.Drawing; using LaTeXSnipper.OfficePlugin.Abstractions; using LaTeXSnipper.OfficePlugin.Rendering; using LaTeXSnipper.OfficePlugin.WordAddIn; +using LaTeXSnipper.OfficePlugin.Testing; namespace LaTeXSnipper.OfficePlugin.WordParsingE2E; -internal static class MathTypeRoundTrip +internal static partial class MathTypeRoundTrip { - public static async Task VerifyAsync(object application, string output) + public static async Task VerifyAsync(object application, string output, bool editNative) { dynamic word = application; dynamic document = word.Documents.Add(); @@ -31,9 +33,19 @@ public static async Task VerifyAsync(object application, string output) (Source: @"\textcolor{red}{x}+\mathbf{B}", Element: "mi"), (Source: @"\textcolor{#d52020}{\mu(n)=\begin{cases}1,&n=1\\(-1)^k,&n=p_1\cdots p_k\\0,&p^2\mid n\end{cases}}", Element: "mtable") }; - var sizes = new (float Width, float Height)[examples.Length]; try { + if (editNative) + { + dynamic reference = document.InlineShapes.AddOLEObject(ClassType: MathTypeOleBridge.ProgId, DisplayAsIcon: false, Range: document.Range(0, 0)); + object nativeServer = reference.OLEFormat.Object; + try { MathTypeNativeServer.WriteMathMl(nativeServer, "端"); } + finally { Marshal.ReleaseComObject(nativeServer); } + byte[] referenceNative = MathTypeWordPackage.ReadNative(Convert.ToString(reference.Range.WordOpenXML)!); + File.WriteAllBytes(Path.Combine(Path.GetDirectoryName(output)!, "mathtype-reference-chinese.bin"), referenceNative); + Console.WriteLine("REFERENCE|" + MathTypeNativeEquation.ReadMathMl(referenceNative)); + reference.Delete(); + } foreach (var example in examples) { Console.WriteLine("STAGE|MathType sample " + example.Element + ": render"); @@ -45,7 +57,6 @@ public static async Task VerifyAsync(object application, string output) Console.WriteLine("STAGE|MathType sample: insert LaTeXSnipper OLE"); await adapter.InsertOleFormulaObjectAsync(metadata, emf, false, CancellationToken.None); dynamic shape = document.InlineShapes.Item(document.InlineShapes.Count); - sizes[Array.IndexOf(examples, example)] = (Convert.ToSingle(shape.Width), Convert.ToSingle(shape.Height)); shape.Range.Select(); word.Selection.Collapse(0); word.Selection.TypeParagraph(); @@ -63,19 +74,32 @@ public static async Task VerifyAsync(object application, string output) E2EAssert.Equal(1, (await adapter.LoadFormulaEntriesAsync(true, CancellationToken.None)).Count, "MathType batch stopped after first failure"); document.Content.Select(); await Task.Run(() => controller.ConvertSelectedToMathTypeAsync(CancellationToken.None)); - for (int i = 0; i < examples.Length; i++) VerifyShape(document.InlineShapes.Item(i + 1), examples[i].Source, examples[i].Element, sizes[i]); + E2EAssert.Equal(examples.Length, Convert.ToInt32(document.InlineShapes.Count), "MathType conversion changed the equation count"); + for (int i = 0; i < examples.Length; i++) VerifyShape(document.InlineShapes.Item(i + 1), examples[i].Source, examples[i].Element, editNative); + var dimensions = Enumerable.Range(1, examples.Length).Select(i => ( + Width: Convert.ToSingle(document.InlineShapes.Item(i).Width), Height: Convert.ToSingle(document.InlineShapes.Item(i).Height))).ToArray(); string path = Path.Combine(Path.GetDirectoryName(output)!, "word-mathtype.docx"); document.SaveAs2(path, 16); document.Close(0); document = word.Documents.Open(path); - for (int i = 0; i < examples.Length; i++) VerifyShape(document.InlineShapes.Item(i + 1), examples[i].Source, examples[i].Element, sizes[i]); + E2EAssert.Equal(examples.Length, Convert.ToInt32(document.InlineShapes.Count), "MathType save/reopen changed the equation count"); + for (int i = 0; i < examples.Length; i++) + { + VerifyShape(document.InlineShapes.Item(i + 1), examples[i].Source, examples[i].Element, editNative); + E2EAssert.True(Math.Abs(Convert.ToSingle(document.InlineShapes.Item(i + 1).Width) - dimensions[i].Width) < 0.1, "MathType width changed after save/reopen"); + E2EAssert.True(Math.Abs(Convert.ToSingle(document.InlineShapes.Item(i + 1).Height) - dimensions[i].Height) < 0.1, "MathType height changed after save/reopen"); + } document.ExportAsFixedFormat(Path.ChangeExtension(path, ".pdf"), 17); // Change native MathType content, then import it through the existing OLE batch command. - string changed = await renderer.ConvertTypographyToMathMlAsync(@"z^2+\text{新}", FormulaDisplayMode.Inline, FormulaTypography.Default, CancellationToken.None); - document.InlineShapes.Item(1).OLEFormat.Activate(); - object server = document.InlineShapes.Item(1).OLEFormat.Object; - try { MathTypeOleBridge.WriteMathMl(server, changed); } - finally { Marshal.ReleaseComObject(server); } + if (editNative) + { + string changed = await renderer.ConvertTypographyToMathMlAsync(@"z^2+\text{新}", FormulaDisplayMode.Inline, FormulaTypography.Default, CancellationToken.None); + document.InlineShapes.Item(1).OLEFormat.Activate(); + object server = document.InlineShapes.Item(1).OLEFormat.Object; + try { MathTypeNativeServer.WriteMathMl(server, changed); } + finally { Marshal.ReleaseComObject(server); } + Console.WriteLine("PASS|MathType native server opened and edited independently generated MTEF"); + } document.Content.Select(); status = new RecordingStatusSink(); string failedStart = Convert.ToInt32(document.InlineShapes.Item(examples.Length).Range.Start).ToString(); @@ -88,29 +112,46 @@ public static async Task VerifyAsync(object application, string output) await Task.Run(() => controller.ConvertSelectedToOleAsync(CancellationToken.None)); var imported = await adapter.LoadFormulaEntriesAsync(true, CancellationToken.None); E2EAssert.Equal(examples.Length, imported.Count, "Mixed OLE/MathType selection did not finish importing"); - var edited = imported.Single(e => XDocument.Parse(e.Metadata!.Latex).Root!.Value.Contains("新")); - E2EAssert.True(XDocument.Parse(edited.Metadata!.Latex).Root!.Value.Contains("z"), "Import restored stale content instead of edited MathType content"); + if (editNative) + { + var edited = imported.Single(e => XDocument.Parse(e.Metadata!.Latex).Root!.Value.Contains("新")); + E2EAssert.True(XDocument.Parse(edited.Metadata!.Latex).Root!.Value.Contains("z"), "Import restored stale content instead of edited MathType content"); + } document.SaveAs2(Path.Combine(Path.GetDirectoryName(output)!, "word-mathtype-import.docx"), 16); Console.WriteLine("PASS|Word MathType to OLE: current edited content, mixed selection, first failure continues, worker thread"); Console.WriteLine("PASS|MathType native fraction, Chinese, superscript, root, matrix, integral and styles; Word save/reopen"); + await VerifyLayoutAsync(application, (object)document, output); } finally { document.Close(0); } } - private static void VerifyShape(dynamic shape, string source, string element, (float Width, float Height) size) + private static void VerifyShape(dynamic shape, string source, string element, bool nativeRead) { E2EAssert.Equal(MathTypeOleBridge.ProgId, Convert.ToString(shape.OLEFormat.ProgID), "MathType class identity"); - E2EAssert.True(Math.Abs(Convert.ToSingle(shape.Width) - size.Width) <= Math.Max(0.5f, size.Width * 0.01f) - && Math.Abs(Convert.ToSingle(shape.Height) - size.Height) <= Math.Max(0.5f, size.Height * 0.01f), - "MathType changed the original object's width or height"); - shape.OLEFormat.Activate(); - object server = shape.OLEFormat.Object; - string mathMl; - try + byte[] native = MathTypeWordPackage.ReadNative(Convert.ToString(shape.Range.WordOpenXML)!); + string mathMl = MathTypeNativeEquation.ReadMathMl(native); + string flatOpc = Convert.ToString(shape.Range.WordOpenXML)!; + var package = XDocument.Parse(flatOpc); + XNamespace pkg = "http://schemas.microsoft.com/office/2006/xmlPackage"; + var picture = package.Descendants(pkg + "part").Single(p => (string?)p.Attribute(pkg + "contentType") == "image/x-wmf"); + using (var stream = new MemoryStream(Convert.FromBase64String(picture.Element(pkg + "binaryData")!.Value))) + using (var image = Image.FromStream(stream)) + using (var bitmap = new Bitmap(320, 200)) { - mathMl = MathTypeOleBridge.ReadAndCloseMathMl(server); + using (var graphics = Graphics.FromImage(bitmap)) { graphics.Clear(Color.White); graphics.DrawImage(image, 0, 0, bitmap.Width, bitmap.Height); } + int ink = 0; + for (int x = 0; x < bitmap.Width; x++) + for (int y = 0; y < bitmap.Height; y++) + if (bitmap.GetPixel(x, y).ToArgb() != Color.White.ToArgb()) ink++; + E2EAssert.True(ink > 10, "MathType WMF preview is blank"); + } + if (nativeRead) + { + shape.OLEFormat.Activate(); + object server = shape.OLEFormat.Object; + try { mathMl = MathTypeNativeServer.ReadAndCloseMathMl(server); } + finally { Marshal.ReleaseComObject(server); } } - finally { Marshal.ReleaseComObject(server); } var xml = XDocument.Parse(mathMl); Console.WriteLine("MATHTYPE|" + source + "|" + mathMl); E2EAssert.True(xml.Descendants().Any(e => e.Name.LocalName == element @@ -118,5 +159,6 @@ private static void VerifyShape(dynamic shape, string source, string element, (f if (element == "msubsup") E2EAssert.True(xml.Root!.Value.Contains("∫") && xml.Root.Value.Contains("0") && xml.Root.Value.Contains("1"), "MathType lost integral bounds"); if (source.Contains("端")) E2EAssert.True(xml.Root!.Value.Contains("端"), "MathType lost Chinese text"); + if (!nativeRead && source.Contains("textcolor")) E2EAssert.True(xml.Descendants().Attributes("mathcolor").Any(), "MathType native data lost color"); } } diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/Program.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/Program.cs index 840e5159..90e907a7 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/Program.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/Program.cs @@ -73,6 +73,8 @@ private E2EOptions(FormulaInsertionBackend backend, string outputPath) public bool CopyOnly { get; private set; } public bool IncludeMathType { get; private set; } + public bool MathTypeOnly { get; private set; } + public bool MathTypeNativeEdit { get; private set; } public static E2EOptions Parse(string[] args) { @@ -80,6 +82,7 @@ public static E2EOptions Parse(string[] args) string outputPath = string.Empty; bool copyOnly = false; bool includeMathType = false; + bool mathTypeOnly = false, mathTypeNativeEdit = false; for (int index = 0; index < args.Length; index++) { string argument = args[index]; @@ -93,6 +96,8 @@ public static E2EOptions Parse(string[] args) } else if (argument == "--copy") { copyOnly = true; } else if (argument == "--mathtype") { includeMathType = true; } + else if (argument == "--mathtype-only") { includeMathType = true; mathTypeOnly = true; } + else if (argument == "--mathtype-native-edit") { includeMathType = true; mathTypeNativeEdit = true; } else { throw new ArgumentException("Unknown argument: " + argument); @@ -109,7 +114,7 @@ public static E2EOptions Parse(string[] args) throw new ArgumentException("--output is required."); } - return new E2EOptions(backend.Value, outputPath) { CopyOnly = copyOnly, IncludeMathType = includeMathType }; + return new E2EOptions(backend.Value, outputPath) { CopyOnly = copyOnly, IncludeMathType = includeMathType, MathTypeOnly = mathTypeOnly, MathTypeNativeEdit = mathTypeNativeEdit }; } private static FormulaInsertionBackend ParseBackend(string value) diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md index c80171fc..345adc51 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md @@ -18,7 +18,7 @@ - 全文格式化为粗斜体、15.5 pt 后,所有公式保存相同的完整样式快照,源码和公式身份不变; - 带标签、表格内、行内、独行与手动编号公式可重复处理,保存并重新打开后继续验证文档结构和样式快照。 - OLE 复制到新文档、关闭来源、同文档副本独立身份与编辑、切换活动文档后的固定目标保存; -- `-IncludeMathType` 使用实际安装的 MathType 桌面 SDK,验证分式、中文、上下标、根式、矩阵、积分、粗体与原生对象保存重开;不把源码快照附加到转换后的对象; +- `-IncludeMathType` 验证 Word 的直接 MTEF/CFB 转换、分式、中文、上下标、根式、矩阵、积分、颜色、粗体、预览与保存重开,不激活 MathType; - OLE 后端额外验证行内与手动编号公式的 OMML/OLE 双向转换、原生激活和重新编辑,检查源码、完整样式、身份和编号保持。 运行前必须关闭所有 Word 窗口,且测试完成前不要再次启动 Word,以确保测试实例与日常文档隔离: @@ -29,4 +29,12 @@ 先构建 Release 版本;统一脚本默认使用 Release,可通过 `-Configuration Debug` 改变。通过后,临时输出目录会生成 OMML/OLE 两份 DOCX、Word 导出的同名 PDF 和日志;可通过 `-OutputDirectory` 指定位置。只测原生公式时使用 `-WordBackend Omml`,不涉及 OLE 注册。 -MathType 验收还覆盖当前原生预览比例、红色 Euler 公式、修改原生内容后通过“转为 OLE”导入、混合多选及首项失败续跑;转换后的 MathML 是唯一源码。 +仅运行 Word MathType 核心转换验收: + +```powershell +.\office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope Word -WordBackend Ole -WordMathTypeOnly +``` + +核心验收覆盖红色 Euler 和用户报告的红色 cases、WMF 实际绘制非空、对象数量及保存重开尺寸、混合选择和首项失败续跑、两个相同公式仅转换一个、保留用户缩放、正文/表格文字和段落结构、批量取消后重试、关闭来源后的跨文档复制。核心测试不因没有 MathType 而跳过。 + +追加 `-WordMathTypeNativeEdit` 才启用需要安装 MathType 的独立验收:实际原生服务器读取生成的每个对象,修改内容,再通过不激活 MathType 的正式反向入口读取最新内容。SDK MathML 导出器可能省略颜色,因此颜色以持久化 MTEF 和可见预览验证,不能只依据 SDK 返回的 MathML。尚未完成未安装 MathType 的隔离环境验证。 diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/WordParsingE2ERunner.cs b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/WordParsingE2ERunner.cs index 0f4a14e8..c55cd01a 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/WordParsingE2ERunner.cs +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/WordParsingE2ERunner.cs @@ -61,7 +61,8 @@ public async Task RunAsync() Console.WriteLine("STAGE|Document created"); if (_options.Backend == FormulaInsertionBackend.Ole) { - if (_options.IncludeMathType) await MathTypeRoundTrip.VerifyAsync((object)word, _options.OutputPath); + if (_options.IncludeMathType) await MathTypeRoundTrip.VerifyAsync((object)word, _options.OutputPath, _options.MathTypeNativeEdit); + if (_options.MathTypeOnly) return; document.Activate(); await WordOleCopyRoundTrip.VerifyAsync((object)word, (object)document, _options.OutputPath); if (_options.CopyOnly) return; diff --git a/office_plugin/tests/OfficeE2E/MathTypeNativeServer.cs b/office_plugin/tests/OfficeE2E/MathTypeNativeServer.cs new file mode 100644 index 00000000..00be5b07 --- /dev/null +++ b/office_plugin/tests/OfficeE2E/MathTypeNativeServer.cs @@ -0,0 +1,169 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; +using System.Threading; +using System.Xml.Linq; +using Microsoft.Win32; + +namespace LaTeXSnipper.OfficePlugin.Testing; + +/// Optional E2E checks against an installed MathType native editing server. +internal static class MathTypeNativeServer +{ + public const string ProgId = "Equation.DSMT4"; + private static readonly object Gate = new(); + + public static void WriteMathMl(object equation, string mathMl) + { + if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) + throw new InvalidOperationException("MathType 转换必须在 Office STA 线程执行。"); + XNamespace ns = "http://www.w3.org/1998/Math/MathML"; + var root = XDocument.Parse(mathMl).Root; + if (root?.Name != ns + "math") throw new ArgumentException("Expected complete MathML.", nameof(mathMl)); + // The installed SDK rejects MathJax's empty closing operator for one-sided fences. + root.Descendants(ns + "mo").Where(node => !node.HasElements && string.IsNullOrWhiteSpace(node.Value)).Remove(); + mathMl = root.ToString(SaveOptions.DisableFormatting); + lock (Gate) + { + using var sdk = new Sdk(); + IntPtr dispatch = IntPtr.Zero; + try + { + dispatch = Marshal.GetIDispatchForObject(equation); + try + { + Check(sdk.SetEquation(dispatch, 2, mathMl, mathMl.Length), "导入 MathML"); + // A successful SDK call alone is insufficient: require readable native content. + _ = ReadMathMl(equation); + } + finally { Check(sdk.CloseEquation(1, dispatch), "关闭公式"); } + } + finally + { + if (dispatch != IntPtr.Zero) Marshal.Release(dispatch); + } + } + } + + private static string ReadMathMl(object equation) + { + var format = Format("MathML", TYMED.TYMED_HGLOBAL); + ((IDataObject)equation).GetData(ref format, out STGMEDIUM medium); + try + { + int size = checked((int)GlobalSize(medium.unionmember).ToUInt64()); + if (size <= 0 || size > 32 * 1024 * 1024) throw new InvalidDataException("MathType 返回了无效的 MathML 大小。"); + IntPtr data = GlobalLock(medium.unionmember); + if (data == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + try + { + var bytes = new byte[size]; + Marshal.Copy(data, bytes, 0, size); + int end = Array.IndexOf(bytes, (byte)0); + string xml = Encoding.UTF8.GetString(bytes, 0, end < 0 ? size : end); + if (XDocument.Parse(xml).Root?.Name != XName.Get("math", "http://www.w3.org/1998/Math/MathML")) + throw new InvalidDataException("MathType 返回的内容不是 MathML 公式。"); + return xml; + } + finally { GlobalUnlock(medium.unionmember); } + } + finally { ReleaseStgMedium(ref medium); } + } + + public static string ReadAndCloseMathMl(object equation) + { + if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) + throw new InvalidOperationException("MathType 转换必须在 Office STA 线程执行。"); + lock (Gate) + { + using var sdk = new Sdk(); + IntPtr dispatch = Marshal.GetIDispatchForObject(equation); + try + { + var root = XDocument.Parse(ReadMathMl(equation)).Root!; + // Store one complete MathML root, without translator comments, + // XML declarations or the producer's namespace prefix aliases. + foreach (var node in root.DescendantsAndSelf()) + node.Attributes().Where(attribute => attribute.IsNamespaceDeclaration).Remove(); + return root.ToString(SaveOptions.DisableFormatting); + } + finally + { + try { Check(sdk.CloseEquation(1, dispatch), "关闭公式"); } + finally { Marshal.Release(dispatch); } + } + } + } + + private static FORMATETC Format(string name, TYMED medium) => new() + { + cfFormat = unchecked((short)RegisterClipboardFormat(name)), + dwAspect = DVASPECT.DVASPECT_CONTENT, lindex = -1, tymed = medium + }; + + private static void Check(int result, string action) + { + if (result != 0) throw new InvalidOperationException($"MathType {action}失败(SDK {result})。"); + } + + private static string SdkLibraryPath() + { + Type? type = Type.GetTypeFromProgID(ProgId); + if (type == null) throw new InvalidOperationException("Native editing verification requires MathType."); + using var key = Registry.ClassesRoot.OpenSubKey(@"CLSID\" + type.GUID.ToString("B") + @"\LocalServer32"); + string command = Convert.ToString(key?.GetValue("")) ?? ""; + int end = command.IndexOf(".exe", StringComparison.OrdinalIgnoreCase); + if (end < 0) throw new InvalidOperationException("未找到 MathType 安装路径。"); + string executable = command.Substring(0, end + 4).TrimStart('"'); + if (!File.Exists(executable)) throw new InvalidOperationException("MathType 注册的程序路径不存在,请修复 MathType 安装。"); + return Path.Combine(Path.GetDirectoryName(executable)!, "System", IntPtr.Size == 8 ? "64" : "32", "MT6.dll"); + } + + private sealed class Sdk : IDisposable + { + private readonly IntPtr library; + private readonly Disconnect disconnect; + public readonly SetEquationDelegate SetEquation; + public readonly CloseEquationDelegate CloseEquation; + + public Sdk() + { + library = LoadLibraryEx(SdkLibraryPath(), IntPtr.Zero, 0x1100); + if (library == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "无法加载已安装的 MathType SDK。"); + try + { + disconnect = Function("MTAPIDisconnect"); + SetEquation = Function("MTSetEqnFromLangStr"); + CloseEquation = Function("MTCloseOleObject"); + Check(Function("MTAPIConnect")(1, 10), "连接"); + } + catch { FreeLibrary(library); throw; } + } + + private T Function(string name) where T : Delegate + { + IntPtr address = GetProcAddress(library, name); + if (address == IntPtr.Zero) throw new EntryPointNotFoundException("MathType SDK 缺少 " + name); + return Marshal.GetDelegateForFunctionPointer(address); + } + + public void Dispose() { try { disconnect(); } finally { FreeLibrary(library); } } + } + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate int Connect(short mode, short timeout); + [UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate int Disconnect(); + [UnmanagedFunctionPointer(CallingConvention.Winapi, CharSet = CharSet.Unicode)] private delegate int SetEquationDelegate(IntPtr equation, int language, string value, int length); + [UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate int CloseEquationDelegate(int save, IntPtr equation); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags); + [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] private static extern IntPtr GetProcAddress(IntPtr library, string name); + [DllImport("kernel32.dll")] private static extern bool FreeLibrary(IntPtr library); + [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr GlobalLock(IntPtr memory); + [DllImport("kernel32.dll")] private static extern bool GlobalUnlock(IntPtr memory); + [DllImport("kernel32.dll")] private static extern UIntPtr GlobalSize(IntPtr memory); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern uint RegisterClipboardFormat(string name); + [DllImport("ole32.dll")] private static extern void ReleaseStgMedium(ref STGMEDIUM medium); +} diff --git a/office_plugin/tools/Test-OfficeTypographyE2E.ps1 b/office_plugin/tools/Test-OfficeTypographyE2E.ps1 index b42a232e..7bcb8fad 100644 --- a/office_plugin/tools/Test-OfficeTypographyE2E.ps1 +++ b/office_plugin/tools/Test-OfficeTypographyE2E.ps1 @@ -11,8 +11,11 @@ param( [switch]$WordCopyOnly, [switch]$IncludeMathType, + [switch]$WordMathTypeOnly, + [switch]$WordMathTypeNativeEdit, + [switch]$PowerPointMathTypeNativeEdit, - [ValidateSet('Full', 'Batch', 'Copy', 'Gesture')] + [ValidateSet('Full', 'Batch', 'Copy', 'Gesture', 'MathType')] [string]$PowerPointMode = 'Full', [string]$OutputDirectory = (Join-Path $env:TEMP ('latexsnipper-office-' + [Guid]::NewGuid().ToString('N'))) ) @@ -101,15 +104,18 @@ try { $wordArgs = @('--backend', $backend, '--output', (Join-Path $OutputDirectory "word-$backend.docx")) if ($WordCopyOnly) { $wordArgs += '--copy' } if ($IncludeMathType) { $wordArgs += '--mathtype' } + if ($WordMathTypeOnly) { $wordArgs += '--mathtype-only' } + if ($WordMathTypeNativeEdit) { $wordArgs += '--mathtype-native-edit' } & $wordTest @wordArgs | Tee-Object -FilePath (Join-Path $OutputDirectory "word-$backend.log") if ($LASTEXITCODE -ne 0) { throw "Word $backend E2E failed: $LASTEXITCODE" } Wait-OfficeExit 'WINWORD' } } if ($runPowerPoint) { - $mode = switch ($PowerPointMode) { 'Batch' { '--batch' } 'Copy' { '--copy' } 'Gesture' { '--gesture' } default { '--ole' } } + $mode = switch ($PowerPointMode) { 'Batch' { '--batch' } 'Copy' { '--copy' } 'Gesture' { '--gesture' } 'MathType' { '--mathtype-only' } default { '--ole' } } $pptArgs = @((Join-Path $OutputDirectory 'powerpoint.pptx'), $mode) if ($IncludeMathType -and $PowerPointMode -in @('Full', 'Copy')) { $pptArgs += '--mathtype' } + if ($PowerPointMathTypeNativeEdit) { $pptArgs += '--mathtype-native-edit' } & $pptTest @pptArgs | Tee-Object -FilePath (Join-Path $OutputDirectory 'powerpoint.log') if ($LASTEXITCODE -ne 0) { throw "PowerPoint E2E failed: $LASTEXITCODE" } } diff --git a/tools/office-editor/tests/editor.spec.mjs b/tools/office-editor/tests/editor.spec.mjs index ae85beb0..bbad0d4f 100644 --- a/tools/office-editor/tests/editor.spec.mjs +++ b/tools/office-editor/tests/editor.spec.mjs @@ -129,7 +129,10 @@ test('source history includes visual edits; unfocused notifications cannot repla await page.locator('#mathfieldHost math-field').click(); await page.locator('#mathfieldHost math-field').press('Control+y'); expect(await submitted(page)).toBe('x+12'); - await source(page).fill('newest'); + await source(page).focus(); + await source(page).press('Control+a'); + await page.keyboard.insertText('newest'); + await expect(source(page)).toHaveText('newest'); await page.evaluate(() => { const mf = document.querySelector('#mathfieldHost math-field'); mf.setValue('stale', {silenceNotifications: true}); mf.dispatchEvent(new Event('input')); }); expect(await submitted(page)).toBe('newest'); expect(errors).toEqual([]); From dbaba48d8e9cf40103eeea6967b75b8804ffe0d8 Mon Sep 17 00:00:00 2001 From: LaTeXSnipper Developer Date: Sat, 3 Oct 2026 12:15:46 +0800 Subject: [PATCH 4/5] fix(word): remove non-formula deletion logic --- docs/office_plugin_formula_workflows.md | 14 +- .../PowerPointAddIn/PowerPointAddInText.cs | 4 +- ...ynamicWordApplicationAdapter.Operations.cs | 160 ------------------ ...DynamicWordApplicationAdapter.Selection.cs | 55 +----- .../WordAddIn/IWordApplicationAdapter.cs | 2 +- .../hosts/WordAddIn/WordAddInText.cs | 8 +- .../LaTeXSnipperOffice_3.1.0_amd64.exe | 4 +- .../LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 | 2 +- 8 files changed, 18 insertions(+), 231 deletions(-) diff --git a/docs/office_plugin_formula_workflows.md b/docs/office_plugin_formula_workflows.md index b633b51e..fad8307a 100644 --- a/docs/office_plugin_formula_workflows.md +++ b/docs/office_plugin_formula_workflows.md @@ -236,19 +236,7 @@ REF LaTeXSnipperEq_{equationId} \h ## Word 删除所选 -删除所选支持: - -- 选中的托管公式。 -- 选区内的 OLE 公式。 -- 选中的章/节编号边界控件。 -- 选中的 REF 引用字段。 -- 当前待完成的引用占位符。 - -删除时先收集目标,再按文档位置倒序删除,避免前面的删除动作改变后续范围。删除操作在一个 Word undo record 内执行。 - -当前不支持“只选中编号数字就删除对应公式”。这是有意收窄后的边界,避免编号字段和公式对象之间产生不稳定的反向选择关系。 - -删除引用字段只删除引用本身,不删除目标公式。删除章/节分隔符只删除边界控件;如果需要刷新后续自动编号,需要再执行“重编号”。 +删除所选只处理选中的插件 OMML 和 OLE 公式。先收集公式目标,再按文档位置倒序删除,操作在同一个 Word undo record 内执行。 ## Word 转换链路 diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs index eeb39d11..3a5cd87a 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs @@ -45,7 +45,7 @@ private static string GetEnglish(string key) "InsertFormulaTip" => "Open the formula editor.", "ScreenshotOcrTip" => "Wait for screenshot OCR; click again to cancel.", "LoadSelectedTip" => "Load the selected formula into the editor.", - "DeleteSelectedTip" => "Delete the selected plugin formulas.", + "DeleteSelectedTip" => "Delete selected formulas.", "ToOleTip" => "Convert selected PNG or MathType equations to plugin OLE formulas.", "ToPngTip" => "Convert the selected OLE formulas to PNG.", "FormatSelectedTip" => "Reset selected formulas to the default style and natural size.", @@ -129,7 +129,7 @@ private static string GetChinese(string key) "InsertFormulaTip" => "打开公式编辑器。", "ScreenshotOcrTip" => "等待截图识别结果,再次点击取消。", "LoadSelectedTip" => "将所选公式加载到编辑器。", - "DeleteSelectedTip" => "删除所选插件公式。", + "DeleteSelectedTip" => "删除所选公式。", "ToOleTip" => "将所选 PNG 或 MathType 公式转为插件 OLE 公式。", "ToPngTip" => "将所选 OLE 公式转换为 PNG。", "FormatSelectedTip" => "恢复所选公式的默认样式和自然大小。", diff --git a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Operations.cs b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Operations.cs index 56c95ee3..b6500450 100644 --- a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Operations.cs +++ b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Operations.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using LaTeXSnipper.OfficePlugin.Abstractions; @@ -129,165 +128,6 @@ public Task ApplyNumberingBoundaryVisibilityAsync(CancellationToken cancellation return Task.CompletedTask; } - private IReadOnlyList FindSelectedCommandControls() - { - dynamic selectionRange = _wordApplication.Selection.Range; - var controls = new List(); - var seen = new HashSet(StringComparer.Ordinal); - try - { - object? parent = selectionRange.ParentContentControl; - if (parent != null && IsCommandControlTag(ReadControlTag((dynamic)parent))) - { - AddSelectedCommandControl(controls, seen, parent!); - } - } - catch - { - } - - try - { - dynamic rangeControls = selectionRange.ContentControls; - int count = Convert.ToInt32(rangeControls.Count); - for (int index = 1; index <= count; index++) - { - dynamic control = rangeControls.Item(index); - if (IsCommandControlTag(ReadControlTag(control))) - { - AddSelectedCommandControl(controls, seen, control); - } - } - } - catch - { - } - - int selectionStart = GetRangeStart(selectionRange); - int selectionEnd = GetRangeEnd(selectionRange); - dynamic documentControls = CurrentDocument.ContentControls; - int documentControlCount = Convert.ToInt32(documentControls.Count); - for (int index = 1; index <= documentControlCount; index++) - { - dynamic control = documentControls.Item(index); - if (!IsCommandControlTag(ReadControlTag(control))) - { - continue; - } - - int controlStart = GetRangeStart(control.Range); - int controlEnd = GetRangeEnd(control.Range); - bool selected = selectionStart == selectionEnd - ? selectionStart >= controlStart && selectionStart < controlEnd - : RangesOverlap(selectionStart, selectionEnd, controlStart, controlEnd); - if (selected) - { - AddSelectedCommandControl(controls, seen, control); - } - } - - return controls; - } - - private static void AddSelectedCommandControl( - ICollection controls, - ISet seen, - object candidate) - { - dynamic control = candidate; - string key = GetRangeStart(control.Range).ToString(System.Globalization.CultureInfo.InvariantCulture) - + ":" - + GetRangeEnd(control.Range).ToString(System.Globalization.CultureInfo.InvariantCulture); - if (seen.Add(key)) - { - controls.Add(candidate); - } - } - - private static bool IsCommandControlTag(string tag) - { - return string.Equals(tag, ChapterBoundaryTag, StringComparison.Ordinal) - || string.Equals(tag, SectionBoundaryTag, StringComparison.Ordinal); - } - - private void DeleteCommandControl(object selected) - { - dynamic control = selected; - control.Delete(true); - } - - private IReadOnlyList FindSelectedReferenceFields() - { - dynamic selectionRange = _wordApplication.Selection.Range; - int selectionStart = GetRangeStart(selectionRange); - int selectionEnd = GetRangeEnd(selectionRange); - var fields = new List(); - var seen = new HashSet(StringComparer.Ordinal); - dynamic documentFields = CurrentDocument.Fields; - int fieldCount = Convert.ToInt32(documentFields.Count); - for (int index = 1; index <= fieldCount; index++) - { - dynamic field = documentFields.Item(index); - if (!IsLaTeXSnipperReferenceField(field)) - { - continue; - } - - int fieldStart = GetRangeStart(field.Result); - int fieldEnd = GetRangeEnd(field.Result); - bool selected = selectionStart == selectionEnd - ? selectionStart >= fieldStart && selectionStart <= fieldEnd - : RangesOverlap(selectionStart, selectionEnd, fieldStart, fieldEnd); - string key = fieldStart.ToString(System.Globalization.CultureInfo.InvariantCulture) - + ":" - + fieldEnd.ToString(System.Globalization.CultureInfo.InvariantCulture); - if (selected && seen.Add(key)) - { - fields.Add((object)field); - } - } - - return fields; - } - - private object? FindSelectedPendingReferencePlaceholder() - { - if (_pendingReferenceRange == null) - { - return null; - } - - dynamic selectionRange = _wordApplication.Selection.Range; - dynamic placeholderRange = _pendingReferenceRange; - int selectionStart = GetRangeStart(selectionRange); - int selectionEnd = GetRangeEnd(selectionRange); - int placeholderStart = GetRangeStart(placeholderRange); - int placeholderEnd = GetRangeEnd(placeholderRange); - bool selected = selectionStart == selectionEnd - ? selectionStart >= placeholderStart && selectionStart <= placeholderEnd - : RangesOverlap(selectionStart, selectionEnd, placeholderStart, placeholderEnd); - return selected ? _pendingReferenceRange : null; - } - - private static bool IsLaTeXSnipperReferenceField(dynamic field) - { - string code = Convert.ToString(field.Code.Text) ?? string.Empty; - return code.IndexOf(" REF " + WordEquationNumbering.BookmarkPrefix, StringComparison.Ordinal) >= 0; - } - - private static void DeleteReferenceField(object selected) - { - dynamic field = selected; - field.Delete(); - } - - private void DeletePendingReferencePlaceholder(object selected) - { - dynamic range = selected; - range.Delete(); - _pendingReferenceRange = null; - } - private static bool IsNumberingBoundary(dynamic control, out WordNumberingBoundary boundary) { string tag = Convert.ToString(control.Tag) ?? string.Empty; diff --git a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Selection.cs b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Selection.cs index 5ec45d8d..65fc53cf 100644 --- a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Selection.cs +++ b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Selection.cs @@ -457,67 +457,26 @@ public bool HasCustomFormulaScale(FormulaMetadata metadata) || Math.Abs(height / naturalHeight - 1) > 0.01; } - public Task> DeleteSelectedFormulaAsync(CancellationToken cancellationToken) + public Task DeleteSelectedFormulaAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var selectedFormulas = new List(CollectSelectedFormulas()); AddOleInlineShapesInsideSelection(selectedFormulas); - IReadOnlyList selectedCommandControls = FindSelectedCommandControls(); - IReadOnlyList selectedReferenceFields = FindSelectedReferenceFields(); - object? selectedPendingReference = FindSelectedPendingReferencePlaceholder(); - if (selectedFormulas.Count == 0 && - selectedCommandControls.Count == 0 && - selectedReferenceFields.Count == 0 && - selectedPendingReference == null) + if (selectedFormulas.Count == 0) { throw new InvalidOperationException(WordAddInText.Get("SelectedFormulaRequired")); } - string[] deletedEquationIds = selectedFormulas - .Select(formula => formula.Metadata.Identity.EquationId) - .Distinct(StringComparer.Ordinal) - .ToArray(); - var targets = new List<(int Start, int End, Action Delete)>(); - var formulaDeleteIds = new HashSet(StringComparer.Ordinal); - foreach (SelectedWordFormula selected in selectedFormulas) - { - formulaDeleteIds.Add(selected.Metadata.Identity.EquationId); - int start = GetFormulaStart(selected); - targets.Add((start, start, () => DeleteFormula(selected))); - } - - foreach (object selected in selectedCommandControls) - { - dynamic control = selected; - int start = GetRangeStart(control.Range); - int end = GetRangeEnd(control.Range); - targets.Add((start, end, () => DeleteCommandControl(selected))); - } - - foreach (object selected in selectedReferenceFields) - { - dynamic field = selected; - int start = GetRangeStart(field.Result); - int end = GetRangeEnd(field.Result); - targets.Add((start, end, () => DeleteReferenceField(selected))); - } - - if (selectedPendingReference != null) - { - dynamic range = selectedPendingReference; - int start = GetRangeStart(range); - int end = GetRangeEnd(range); - targets.Add((start, end, () => DeletePendingReferencePlaceholder(selectedPendingReference))); - } - + SelectedWordFormula[] targets = selectedFormulas.OrderByDescending(GetFormulaStart).ToArray(); ExecuteWithScreenUpdatingSuspended(() => { - foreach ((int _, int _, Action delete) in targets.OrderByDescending(target => target.Start)) + foreach (SelectedWordFormula selected in targets) { - delete(); + cancellationToken.ThrowIfCancellationRequested(); + DeleteFormula(selected); } }); - return Task.FromResult>(deletedEquationIds); + return Task.CompletedTask; } } diff --git a/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs b/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs index 42330659..2054c7c8 100644 --- a/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs +++ b/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs @@ -94,7 +94,7 @@ Task UpdateFormulaAsync( bool HasCustomFormulaScale(FormulaMetadata metadata); - Task> DeleteSelectedFormulaAsync(CancellationToken cancellationToken); + Task DeleteSelectedFormulaAsync(CancellationToken cancellationToken); Task RenumberAutomaticFormulasAsync(CancellationToken cancellationToken); diff --git a/office_plugin/hosts/WordAddIn/WordAddInText.cs b/office_plugin/hosts/WordAddIn/WordAddInText.cs index f7e523ab..01349ea6 100644 --- a/office_plugin/hosts/WordAddIn/WordAddInText.cs +++ b/office_plugin/hosts/WordAddIn/WordAddInText.cs @@ -56,7 +56,7 @@ private static string GetEn(string key) "SettingsTip" => "Open LaTeXSnipper settings.", "ShowTaskPaneTip" => "Show the status pane.", "LoadSelectedTip" => "Load the selected formula into the editor.", - "DeleteSelectedTip" => "Delete the selected plugin formulas.", + "DeleteSelectedTip" => "Delete selected formulas.", "EditorInsert" => "Insert", "EditorUpdate" => "Update", "Cancel" => "Cancel", @@ -78,7 +78,7 @@ private static string GetEn(string key) "OmmlInsertedStatus" => "Inserted OMML equation.", "UpdatedStatus" => "Updated equation in Word.", "UnchangedStatus" => "Equation unchanged.", - "DeletedStatus" => "Deleted selected control.", + "DeletedStatus" => "Deleted selected formula.", "LoadedStatus" => "Loaded selected equation.", "OcrWaitingStatus" => "Waiting for screenshot OCR.", "OcrRecognizingStatus" => "Recognizing screenshot formula.", @@ -187,7 +187,7 @@ private static string GetZh(string key) "SettingsTip" => "打开 LaTeXSnipper 设置。", "ShowTaskPaneTip" => "显示状态窗格。", "LoadSelectedTip" => "将所选公式加载到编辑器。", - "DeleteSelectedTip" => "删除所选插件公式。", + "DeleteSelectedTip" => "删除所选公式。", "EditorInsert" => "插入", "EditorUpdate" => "更新", "Cancel" => "取消", @@ -209,7 +209,7 @@ private static string GetZh(string key) "OmmlInsertedStatus" => "已插入 OMML 公式。", "UpdatedStatus" => "已更新公式。", "UnchangedStatus" => "公式未更改。", - "DeletedStatus" => "已删除所选控件。", + "DeletedStatus" => "已删除所选公式。", "LoadedStatus" => "已加载所选公式。", "OcrWaitingStatus" => "正在等待截图识别;请使用全局快捷键后框选公式区域。", "OcrRecognizingStatus" => "正在识别截图公式。", diff --git a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe index ebe2d6e4..68e2110e 100644 --- a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe +++ b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:087ce22279a4f3a357d31dacca3e628f86c4af3e655e38ce7fc617f5559e0416 -size 7570398 +oid sha256:3583a9f474a1cb9072b8217354e89bf39bd269d28b7204b1f337342334aedc66 +size 7568402 diff --git a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 index 59a3e6d9..8a968342 100644 --- a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 +++ b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 @@ -1 +1 @@ -087ce22279a4f3a357d31dacca3e628f86c4af3e655e38ce7fc617f5559e0416 LaTeXSnipperOffice_3.1.0_amd64.exe +3583a9f474a1cb9072b8217354e89bf39bd269d28b7204b1f337342334aedc66 LaTeXSnipperOffice_3.1.0_amd64.exe From 5acb1966947db6ea12532c692e605a26efcdc6c5 Mon Sep 17 00:00:00 2001 From: LaTeXSnipper Developer Date: Sat, 3 Oct 2026 15:18:13 +0800 Subject: [PATCH 5/5] docs(office): refresh help and update user manual --- office_plugin/README.md | 6 +- .../PowerPointAddIn/EditorAssets/help.html | 382 +++++++++++------- .../PowerPointAddIn/PowerPointAddInText.cs | 8 +- .../hosts/WordAddIn/EditorAssets/help.html | 382 +++++++++++------- .../hosts/WordAddIn/WordAddInText.cs | 8 +- .../LaTeXSnipperOffice_3.1.0_amd64.exe | 4 +- .../LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 | 2 +- user_manual/user_manual.md | 140 ++++--- user_manual/user_manual.typ | 148 ++++--- 9 files changed, 632 insertions(+), 448 deletions(-) diff --git a/office_plugin/README.md b/office_plugin/README.md index 8c1755ba..69a8ae8d 100644 --- a/office_plugin/README.md +++ b/office_plugin/README.md @@ -24,7 +24,7 @@ Office 2016 is not officially supported (requires manual .NET 4.8 and WebView2 i - Chapter/section-aware automatic numbering, references, boundaries, and Renumber All - In-place conversion between managed OLE and OMML formulas, plus explicit conversion of selected native Word OMML formulas to LaTeXSnipper OLE - Parsing of `$...$`, `\(...\)`, `$$...$$`, and `\[...\]` LaTeX in the selected range or main document body, including table cells -- Selected formula style reset and document-wide natural-size restoration +- Selected or document-wide reset to the configured formula style and natural size - Screenshot OCR via Automation API ### PowerPoint @@ -33,14 +33,14 @@ Office 2016 is not officially supported (requires manual .NET 4.8 and WebView2 i - Native inline equations at a text-box caret, using MathJax MathML and optional host-size inheritance - Load, update, and delete managed formulas - In-place conversion of selected managed formulas between OLE and PNG -- Selected formula style reset and presentation-wide natural-size restoration +- Selected or presentation-wide reset to the configured formula style and natural size - User-resized formulas preserve their scale when updated - Screenshot OCR via Automation API ### Shared - Double-click editing and independent cross-document copies of LaTeXSnipper OLE formulas -- Explicit conversion of unnumbered LaTeXSnipper OLE formulas to native MathType objects; Word and PowerPoint share direct MTEF/CFB encoding and MathJax vector previews. Multi-selection continues after individual failures and reports specific errors; MathType owns subsequent native editing and layout +- Conversion between LaTeXSnipper OLE and native MathType objects without MathType installed; Word numbered formulas are excluded. MathType is required for native double-click editing - Reusable WebView2/MathLive formula editor - 18-category shared symbol and formula library - Chinese and English Ribbon, task pane, editor, settings, and help diff --git a/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html b/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html index 79605a2f..ac285bee 100644 --- a/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html +++ b/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html @@ -5,195 +5,265 @@ LaTeXSnipper Office 帮助
    -
    LATEXSNIPPER · OFFICE
    +
    LaTeXSnipperOffice 插件帮助

    公式使用指南

    从新建公式到修改、编号与批量整理,按实际操作顺序查找。

    -
    -

    开始使用

    -
    -
    1 · 打开插件在 Word 或 PowerPoint 顶部的 LaTeXSnipper 选项卡中选择插入公式。
    -
    2 · 写入公式在上方可视化区域输入,或在下方源码区写 LaTeX;也可以从左侧符号库选择公式。
    -
    3 · 插入文档确认字号和颜色后点击“插入”。双击已有 OLE 公式,或选中公式后点击“加载所选”,可修改后点击“更新”。
    -
    -
    截图识别需要打开 LaTeXSnipper 桌面端,并在桌面端设置中启用自动化接口。普通公式编辑和插入可直接在 Office 插件中完成。
    -
    -
    -

    编辑公式

    -
      -
    • 可视化区与源码区:两者同步。源码含有可视化编辑器无法原样保留的写法时,上方只显示参考结果;继续在源码区编辑,或点击“用上方结果替换源码(可撤销)”。
    • -
    • 符号库与常用:左侧可搜索公式和符号,点击磁贴插入。右键点击磁贴可加入或移出“常用”;工具栏星形按钮可收藏当前公式。常用内容在升级后保留。
    • -
    • 字体与预览:工具栏的字体、字形、字号和颜色优先用于当前公式。全局颜色会同步到源码和可视化编辑区;右键可对选中部分上色、剪切、粘贴或全选,局部颜色会立即写入源码。“最终预览”按当前属性渲染,小字号会在屏幕上放大以便检查,插入仍使用选定字号;修改已有公式时沿用该公式保存的属性。
    • -
    • 快捷键:可视化区按 Shift + Enter 插入或更新。源码区按 Tab 在光标处缩进;补全列表按 Enter 接受。
    • -
    -
    -
    -

    设置与字体

    -

    在 LaTeXSnipper 选项卡点击“设置”。公式默认属性用于新建公式和主动执行的格式化;编辑器里临时选择的属性优先用于当前公式。导入、导出 JSON 可保存或应用当前 Word / PowerPoint 的公式默认属性。

    - - - - - - - - - -
    设置作用
    符号字体决定数学字母、符号及公式结构的字体方案。可选打包提供的 TeX 和 STIX2。
    数字字体单独指定普通数字使用的系统字体;选“跟随符号字体”时与公式符号保持一致。
    汉字字体指定公式内汉字使用的系统字体;使用与 Word 常用汉字字体一致的固定列表(等线到幼圆)。
    默认字形控制数学字符的正体、斜体、粗体、花体等表现,与字体方案分别选择。“自动”保留公式原有的数学排版;源码中明确指定的局部字形优先。
    字号、颜色决定新公式的默认大小和颜色。“新建时跟随文字字号”启用后优先使用光标或选区文字字号,无有效字号时使用设置中的固定值。
    -

    升级或重装会保留公式默认属性、插入方式、Word 编号布局和编辑器常用内容。导入公式预设只替换对应宿主的公式默认属性。

    -
    -
    -

    Word 中的常用操作

    -

    插入与编号

    -

    “插入行内公式”放在当前文字中;“插入行间公式”单独占一行;“插入带编号公式”按设置的左右位置与外框显示编号。设置中的“带编号公式默认布局”还可选择章、节编号、层级分隔符,以及是否显示章/节分隔符。

    -

    加载、转换和格式化

    -

    OLE 公式复制到另一份文档后仍可加载并独立编辑。选择普通 OLE 公式并点击“转为 MathType”,可得到 MathType 原生对象。双向转换无需安装 MathType;双击编辑转换后的对象需要 MathType。转换后不再保留插件源码与字体快照。Word 带编号公式暂不支持此转换。支持同时选择多个公式逐项转换,失败项保留原公式并继续处理其他项,结束后显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    -

    选中插件插入的公式后,用“加载所选”修改或用“删除所选”移除。设置中的“公式插入方式”可选 OLE 对象或 Word 原生公式;“转换为 OLE / Word 原生公式”可处理已选公式。“格式化所选 / 全文”按当前默认属性整理公式。

    -

    解析正文中的 LaTeX

    -

    “解析所选 / 全文”识别完整的 $...$、\(...\)、$$...$$ 和 \[...\]。未闭合或转换失败的内容会保留原文;行间公式中的有效 \tag{...} 可作为自定义编号。

    -

    重编号与引用

    -

    “自动编号”可给未编号的行间公式添加编号;插入章/节分隔符后,后续编号会按层级更新。“重编号”重新计算全文插件公式编号。用“插入引用”创建占位符,再选择目标编号公式或其所在段落完成引用。

    -

    侧边栏保留独立的新公式草稿;加载并更新已有公式请使用编辑器窗口。

    -
    -
    -

    PowerPoint 中的常用操作

    -

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。“转为 MathType”将所选插件 OLE 公式转换为 MathType 原生对象。双向转换无需安装 MathType;双击编辑转换后的对象需要 MathType。转换后不再保留插件源码和字体快照。支持多选批量转换;失败项保留原公式,继续处理其他项,并显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    -

    在文本框内把光标放在文字中间并点击“插入公式”,会得到随文字排版的 PowerPoint 原生公式,可在 PowerPoint 中继续编辑。光标不在文字中时,新公式作为居中的独立幻灯片对象插入,并按设置选择 OLE 对象或 PNG 图片。

    -

    选中插件公式后,可“加载所选”修改、“删除所选”、在 OLE 与 PNG 间转换,或用“格式化所选 / 全文”应用当前默认属性。更新与转换会尽量保留对象在幻灯片中的位置和手动缩放。

    -

    侧边栏用于新公式草稿;“加载所选”、OLE / PNG 转换和批量格式化适用于插件插入的独立对象。文本内原生公式由 PowerPoint 管理。

    -
    -
    -

    社区与支持

    -

    项目免费开源,没有广告和内购。欢迎给仓库点 Star、反馈问题,或通过下面的二维码赞助维护并加入中文交流群。

    -
    -
    支付宝收款码
    支付宝赞助
    -
    微信收款码
    微信赞助
    -
    LaTeXSnipper 中文交流群二维码
    中文交流群
    -
    -
    -
    LaTeXSnipper Office 插件
    +
    +
    +

    开始使用

    +
    +
    01打开插件

    在 Word 或 PowerPoint 顶部的 LaTeXSnipper 选项卡中选择插入公式。

    +
    02写入公式

    在上方可视化区域输入,或在下方源码区写 LaTeX;也可以从左侧符号库选择公式。

    +
    03插入文档

    确认字号和颜色后点击“插入”。双击已有 OLE 公式,或选中公式后点击“加载所选”,可修改后点击“更新”。

    +
    +
    截图识别需要打开 LaTeXSnipper 桌面端,并在桌面端设置中启用自动化接口。普通公式编辑和插入可直接在 Office 插件中完成。
    +
    +
    +

    编辑公式

    +
      +
    • 可视化区与源码区:两者同步。源码含有可视化编辑器无法原样保留的写法时,上方只显示参考结果;继续在源码区编辑,或点击“用上方结果替换源码(可撤销)”。
    • +
    • 符号库与常用:左侧可搜索公式和符号,点击磁贴插入。右键点击磁贴可加入或移出“常用”;工具栏星形按钮可收藏当前公式。常用内容在升级后保留。
    • +
    • 字体与颜色:工具栏的字体、字形、字号和颜色优先用于当前公式。全局颜色会同步到源码和可视化编辑区;右键可对选中部分上色、剪切、粘贴或全选,局部颜色会立即写入源码。
    • +
    • 最终预览:按当前属性渲染,小字号会在屏幕上放大以便检查,插入仍使用选定字号;修改已有公式时沿用该公式保存的属性。
    • +
    • 快捷键:可视化区按 Shift + Enter 插入或更新。源码区按 Tab 在光标处缩进;补全列表按 Enter 接受。
    • +
    +
    +
    +

    设置与字体

    +

    在 LaTeXSnipper 选项卡点击“设置”。公式默认属性用于新建公式和主动执行的格式化;编辑器里临时选择的属性优先用于当前公式。导入、导出 JSON 可保存或应用当前 Word / PowerPoint 的公式默认属性。

    + + + + + + + + + +
    设置作用
    符号字体决定数学字母、符号及公式结构的字体方案。可选打包提供的 TeX 和 STIX2。
    数字字体单独指定普通数字使用的系统字体;选“跟随符号字体”时与公式符号保持一致。
    汉字字体指定公式内汉字使用的系统字体;使用与 Word 常用汉字字体一致的固定列表(等线到幼圆)。
    默认字形控制数学字符的正体、斜体、粗体、花体等表现,与字体方案分别选择。“自动”保留公式原有的数学排版;源码中明确指定的局部字形优先。
    字号、颜色决定新公式的默认大小和颜色。“新建时跟随文字字号”启用后优先使用光标或选区文字字号,无有效字号时使用设置中的固定值。
    +

    升级或重装会保留公式默认属性、插入方式、Word 编号布局和编辑器常用内容。导入公式预设只替换对应宿主的公式默认属性。

    +
    +
    +

    Word 中的常用操作

    +

    插入与编号

    +

    “插入行内公式”放在当前文字中;“插入行间公式”单独占一行;“插入带编号公式”按设置的左右位置与外框显示编号。设置中的“带编号公式默认布局”还可选择章、节编号、层级分隔符,以及是否显示章/节分隔符。

    +

    加载、转换和格式化

    +

    OLE 公式复制到另一份文档后仍可加载并独立编辑。选中插件公式后,用“加载所选”修改或用“删除所选”移除。

    +
      +
    • 转为 OLE / Word:在插件 OLE 与 Word 原生公式间转换;“转为 OLE”也支持 MathType 公式。
    • +
    • 转为 MathType:将普通插件 OLE 公式转为 MathType 原生对象,带编号公式暂不支持。
    • +
    • 格式化所选 / 全文:恢复插件公式的默认样式和自然大小。
    • +
    +

    设置中的“公式插入方式”可选 OLE 对象或 Word 原生公式。

    +

    MathType 双向转换无需安装 MathType;双击编辑 MathType 对象需要安装。转换不保留原插件源码与字体快照,转回 OLE 根据对象当前内容生成。

    +

    解析正文中的 LaTeX

    +

    “解析所选 / 全文”识别完整的 $...$、\(...\)、$$...$$ 和 \[...\]。未闭合或转换失败的内容会保留原文;行间公式中的有效 \tag{...} 可作为自定义编号。

    +

    重编号与引用

    +

    “自动编号”可给未编号的行间公式添加编号;插入章/节分隔符后,后续编号会按层级更新。“重编号”重新计算全文插件公式编号。用“插入引用”创建占位符,再选择目标编号公式或其所在段落完成引用。

    +

    侧边栏保留独立的新公式草稿;加载并更新已有公式请使用编辑器窗口。

    +
    +
    +

    PowerPoint 中的常用操作

    +

    插入公式

    +

    在文本框内把光标放在文字中间并点击“插入公式”,会得到随文字排版的 PowerPoint 原生公式,可在 PowerPoint 中继续编辑。光标不在文字中时,新公式作为居中的独立幻灯片对象插入,并按设置选择 OLE 对象或 PNG 图片。

    +

    加载、转换和格式化

    +

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。选中插件公式后,用“加载所选”修改或用“删除所选”移除。

    +
      +
    • 转为 OLE / PNG:在插件 OLE 与 PNG 间转换;“转为 OLE”也支持 MathType 公式。
    • +
    • 转为 MathType:将所选插件 OLE 公式转为 MathType 原生对象。
    • +
    • 格式化所选 / 全文:恢复插件公式的默认样式和自然大小。
    • +
    +

    更新与转换会尽量保留对象在幻灯片中的位置和手动缩放。

    +

    MathType 双向转换无需安装 MathType;双击编辑 MathType 对象需要安装。转换不保留原插件源码与字体快照,转回 OLE 根据对象当前内容生成。

    +

    侧边栏用于新公式草稿;“加载所选”、OLE / PNG 转换和批量格式化适用于插件插入的独立对象。文本内原生公式由 PowerPoint 管理。

    +
    +
    +

    社区与支持

    +

    项目免费开源,没有广告和内购。欢迎给仓库点 Star、反馈问题,或通过下面的二维码赞助维护并加入中文交流群。

    +
    +
    支付宝收款码
    支付宝赞助
    +
    微信收款码
    微信赞助
    +
    LaTeXSnipper 中文交流群二维码
    中文交流群
    +
    +
    +
    LaTeXSnipper Office 插件
    +
    -
    LATEXSNIPPER · OFFICE
    +
    LaTeXSnipperOffice plugin help

    Formula guide

    Find the next step, from inserting a formula to editing, numbering, and formatting it.

    -
    -

    Get started

    -
    -
    1 · Open the pluginUse the LaTeXSnipper tab at the top of Word or PowerPoint to insert a formula.
    -
    2 · Write the formulaType in the visual area or the LaTeX source pane, or choose a formula from the symbol library.
    -
    3 · Insert itCheck the size and color, then select Insert. Double-click an existing OLE formula, or select a formula and use Load Selected, then Update.
    -
    -
    Screenshot recognition requires the LaTeXSnipper desktop app with the Automation API enabled in its settings. Ordinary formula editing and insertion work inside the Office plugin.
    -
    -
    -

    Edit formulas

    -
      -
    • Visual and source panes:They stay in sync. When the visual editor cannot preserve source exactly, the upper pane is a reference. Keep editing below, or choose “Replace source with result (undoable)”.
    • -
    • Library and Common:Search and click a tile to insert it. Right-click a tile to add or remove it from Common. Use the toolbar star to save the current formula. Common survives upgrades.
    • -
    • Typography and preview:Toolbar choices apply to the current formula. The formula color is synchronized with the source and visual editor. Right-click in the visual editor to color a selection, cut, paste, or select all; local colors are written to the source immediately. Final Preview enlarges small formulas on screen for inspection; insertion still uses the selected point size. Existing formulas reopen with their saved properties.
    • -
    • Keys:In the visual pane, Shift + Enter inserts or updates. In source, Tab indents at the caret and Enter accepts a completion.
    • -
    -
    -
    -

    Settings and fonts

    -

    Open Settings from the LaTeXSnipper tab. Default formula properties apply to new formulas and explicit Format commands. Editor choices take priority for the current formula. Import and export JSON to save or apply the current host's formula defaults.

    - - - - - - - - - -
    SettingWhat it controls
    Symbol fontThe font scheme for mathematical letters, symbols, and formula structures. The bundled choices are TeX and STIX2.
    Number fontAn optional system font for ordinary digits. Follow Symbol Font keeps numbers with the mathematical symbols.
    CJK fontA system font for Chinese characters within formulas. The fixed list contains common Chinese fonts, from DengXian to YouYuan.
    Default math styleUpright, italic, bold, script, and other mathematical shapes, selected independently from the font scheme. Automatic preserves normal mathematical styling; explicit source styles take priority.
    Size and colorDefaults for new formulas. Follow Text Size uses the text at the insertion point when available and falls back to the fixed size.
    -

    Upgrades and reinstalls preserve formula defaults, insertion choices, Word numbering preferences, and Common formulas. Importing a formula preset replaces only that host's formula defaults.

    -
    -
    -

    Common tasks in Word

    -

    Insert and number

    -

    Insert Inline places a formula within text. Insert Display gives it a separate line. Insert Numbered positions the number and enclosure according to settings. Default Numbered Formula Layout also controls chapter and section numbering, separators, and boundary visibility.

    -

    Load, convert, and format

    -

    Copied OLE formulas remain independently editable in another document. To MathType converts an unnumbered OLE formula into a native MathType object. Conversion in either direction works without MathType installed; double-click editing of native MathType objects requires MathType. The plugin source and font snapshot are not retained after conversion. You can convert multiple selected formulas; failed items remain unchanged while the rest continue, followed by a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    -

    Select a plugin formula to Load Selected or Delete Selected. Formula Insertion offers OLE objects or native Word equations. Convert to OLE / Word Equation changes selected formulas. Format Selected / All applies the current default properties.

    -

    Parse LaTeX in document text

    -

    Parse Selected / All recognizes complete $...$, \(...\), $$...$$, and \[...\] expressions. Incomplete or failed conversions leave the original text in place. A valid \tag{...} can provide a custom number for a display formula.

    -

    Renumber and reference

    -

    Auto Number numbers an unnumbered display formula. Chapter and section boundaries affect later numbers. Renumber recalculates plugin formula numbers across the document. Insert Reference creates a placeholder; select a numbered formula or its paragraph to complete it.

    -

    The side pane keeps a separate draft for a new formula. Use the editor window to update an existing one.

    -
    -
    -

    Common tasks in PowerPoint

    -

    Plugin OLE formulas remain independently editable after copying to another presentation. To MathType converts the selected plugin OLE formula into a native MathType object. Conversion in either direction works without MathType installed; double-click editing of native MathType objects requires MathType. The plugin source and font snapshot are not retained after conversion. Multiple selected formulas are converted individually; failed items remain unchanged while the rest continue, with a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    -

    Place the text caret inside a text box to insert a native PowerPoint equation that flows with the surrounding text and remains editable in PowerPoint. Outside text, Insert Formula adds a centered slide object using the OLE or PNG insertion setting.

    -

    Select a plugin formula to Load Selected, Delete Selected, convert between OLE and PNG, or apply Format Selected / All. Updates and conversions preserve the slide position and manual scale where possible.

    -

    The side pane holds a new formula draft. Load Selected, OLE / PNG conversion, and bulk formatting apply to standalone objects inserted by the plugin. PowerPoint manages equations within text.

    -
    -
    -

    Community and support

    -

    LaTeXSnipper is free, open source, ad-free, and has no in-app purchases. Star the repository, report an issue, support maintenance, or join the Chinese community using the QR codes below.

    -
    -
    Alipay donation QR code
    Alipay
    -
    WeChat Pay donation QR code
    WeChat Pay
    -
    LaTeXSnipper community chat QR code
    Community chat
    -
    -
    -
    LaTeXSnipper Office Plugin
    +
    +
    +

    Get started

    +
    +
    01Open the plugin

    Use the LaTeXSnipper tab at the top of Word or PowerPoint to insert a formula.

    +
    02Write the formula

    Type in the visual area or the LaTeX source pane, or choose a formula from the symbol library.

    +
    03Insert it

    Check the size and color, then select Insert. Double-click an existing OLE formula, or select a formula and use Load Selected, then Update.

    +
    +
    Screenshot recognition requires the LaTeXSnipper desktop app with the Automation API enabled in its settings. Ordinary formula editing and insertion work inside the Office plugin.
    +
    +
    +

    Edit formulas

    +
      +
    • Visual and source panes: They stay in sync. When the visual editor cannot preserve source exactly, the upper pane is a reference. Keep editing below, or choose “Replace source with result (undoable)”.
    • +
    • Library and Common: Search and click a tile to insert it. Right-click a tile to add or remove it from Common. Use the toolbar star to save the current formula. Common survives upgrades.
    • +
    • Typography and color: Toolbar choices apply to the current formula. The formula color is synchronized with the source and visual editor. Right-click in the visual editor to color a selection, cut, paste, or select all; local colors are written to the source immediately.
    • +
    • Final Preview: Small formulas are enlarged on screen for inspection; insertion still uses the selected point size. Existing formulas reopen with their saved properties.
    • +
    • Keys: In the visual pane, Shift + Enter inserts or updates. In source, Tab indents at the caret and Enter accepts a completion.
    • +
    +
    +
    +

    Settings and fonts

    +

    Open Settings from the LaTeXSnipper tab. Default formula properties apply to new formulas and explicit Format commands. Editor choices take priority for the current formula. Import and export JSON to save or apply the current host's formula defaults.

    + + + + + + + + + +
    SettingWhat it controls
    Symbol fontThe font scheme for mathematical letters, symbols, and formula structures. The bundled choices are TeX and STIX2.
    Number fontAn optional system font for ordinary digits. Follow Symbol Font keeps numbers with the mathematical symbols.
    CJK fontA system font for Chinese characters within formulas. The fixed list contains common Chinese fonts, from DengXian to YouYuan.
    Default math styleUpright, italic, bold, script, and other mathematical shapes, selected independently from the font scheme. Automatic preserves normal mathematical styling; explicit source styles take priority.
    Size and colorDefaults for new formulas. Follow Text Size uses the text at the insertion point when available and falls back to the fixed size.
    +

    Upgrades and reinstalls preserve formula defaults, insertion choices, Word numbering preferences, and Common formulas. Importing a formula preset replaces only that host's formula defaults.

    +
    +
    +

    Common tasks in Word

    +

    Insert and number

    +

    Insert Inline places a formula within text. Insert Display gives it a separate line. Insert Numbered positions the number and enclosure according to settings. Default Numbered Formula Layout also controls chapter and section numbering, separators, and boundary visibility.

    +

    Load, convert, and format

    +

    Copied OLE formulas remain independently editable in another document. Select a plugin formula to Load Selected or Delete Selected.

    +
      +
    • To OLE / Word: Converts between plugin OLE formulas and native Word equations. To OLE also accepts MathType equations.
    • +
    • To MathType: Converts an unnumbered plugin OLE formula into a native MathType object. Numbered formulas are not supported.
    • +
    • Format Selected / All: Restores plugin formulas to the default style and natural size.
    • +
    +

    Formula Insertion offers OLE objects or native Word equations.

    +

    MathType conversion in either direction works without MathType installed; double-click editing requires MathType. Conversion does not retain the original plugin source or font snapshot. Converting back to OLE uses the object's current content.

    +

    Parse LaTeX in document text

    +

    Parse Selected / All recognizes complete $...$, \(...\), $$...$$, and \[...\] expressions. Incomplete or failed conversions leave the original text in place. A valid \tag{...} can provide a custom number for a display formula.

    +

    Renumber and reference

    +

    Auto Number numbers an unnumbered display formula. Chapter and section boundaries affect later numbers. Renumber recalculates plugin formula numbers across the document. Insert Reference creates a placeholder; select a numbered formula or its paragraph to complete it.

    +

    The side pane keeps a separate draft for a new formula. Use the editor window to update an existing one.

    +
    +
    +

    Common tasks in PowerPoint

    +

    Insert formulas

    +

    Place the text caret inside a text box to insert a native PowerPoint equation that flows with the surrounding text and remains editable in PowerPoint. Outside text, Insert Formula adds a centered slide object using the OLE or PNG insertion setting.

    +

    Load, convert, and format

    +

    Plugin OLE formulas remain independently editable after copying to another presentation. Select a plugin formula to Load Selected or Delete Selected.

    +
      +
    • To OLE / PNG: Converts between plugin OLE formulas and PNG. To OLE also accepts MathType equations.
    • +
    • To MathType: Converts the selected plugin OLE formula into a native MathType object.
    • +
    • Format Selected / All: Restores plugin formulas to the default style and natural size.
    • +
    +

    Updates and conversions preserve the slide position and manual scale where possible.

    +

    MathType conversion in either direction works without MathType installed; double-click editing requires MathType. Conversion does not retain the original plugin source or font snapshot. Converting back to OLE uses the object's current content.

    +

    The side pane holds a new formula draft. Load Selected, OLE / PNG conversion, and bulk formatting apply to standalone objects inserted by the plugin. PowerPoint manages equations within text.

    +
    +
    +

    Community and support

    +

    LaTeXSnipper is free, open source, ad-free, and has no in-app purchases. Star the repository, report an issue, support maintenance, or join the Chinese community using the QR codes below.

    +
    +
    Alipay donation QR code
    Alipay
    +
    WeChat Pay donation QR code
    WeChat Pay
    +
    LaTeXSnipper community chat QR code
    Community chat
    +
    +
    +
    LaTeXSnipper Office Plugin
    +
    diff --git a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs index 3a5cd87a..82c4464c 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointAddInText.cs @@ -48,10 +48,10 @@ private static string GetEnglish(string key) "DeleteSelectedTip" => "Delete selected formulas.", "ToOleTip" => "Convert selected PNG or MathType equations to plugin OLE formulas.", "ToPngTip" => "Convert the selected OLE formulas to PNG.", - "FormatSelectedTip" => "Reset selected formulas to the default style and natural size.", + "FormatSelectedTip" => "Reset selected plugin formulas to the default style and natural size.", "FormatAllTip" => "Reset all plugin formulas to the default style and natural size.", "ShowTaskPaneTip" => "Show the status pane.", - "SettingsTip" => "Open LaTeXSnipper settings.", + "SettingsTip" => "Open plugin settings.", "HelpTip" => "View plugin help.", "OfficePluginLabel" => "Office plugin", "EquationLabel" => "Formula", @@ -132,10 +132,10 @@ private static string GetChinese(string key) "DeleteSelectedTip" => "删除所选公式。", "ToOleTip" => "将所选 PNG 或 MathType 公式转为插件 OLE 公式。", "ToPngTip" => "将所选 OLE 公式转换为 PNG。", - "FormatSelectedTip" => "恢复所选公式的默认样式和自然大小。", + "FormatSelectedTip" => "恢复所选插件公式的默认样式和自然大小。", "FormatAllTip" => "恢复全文插件公式的默认样式和自然大小。", "ShowTaskPaneTip" => "显示状态窗格。", - "SettingsTip" => "打开 LaTeXSnipper 设置。", + "SettingsTip" => "打开插件设置。", "HelpTip" => "查看插件帮助。", "OfficePluginLabel" => "Office 插件", "EquationLabel" => "公式", diff --git a/office_plugin/hosts/WordAddIn/EditorAssets/help.html b/office_plugin/hosts/WordAddIn/EditorAssets/help.html index 79605a2f..ac285bee 100644 --- a/office_plugin/hosts/WordAddIn/EditorAssets/help.html +++ b/office_plugin/hosts/WordAddIn/EditorAssets/help.html @@ -5,195 +5,265 @@ LaTeXSnipper Office 帮助
    -
    LATEXSNIPPER · OFFICE
    +
    LaTeXSnipperOffice 插件帮助

    公式使用指南

    从新建公式到修改、编号与批量整理,按实际操作顺序查找。

    -
    -

    开始使用

    -
    -
    1 · 打开插件在 Word 或 PowerPoint 顶部的 LaTeXSnipper 选项卡中选择插入公式。
    -
    2 · 写入公式在上方可视化区域输入,或在下方源码区写 LaTeX;也可以从左侧符号库选择公式。
    -
    3 · 插入文档确认字号和颜色后点击“插入”。双击已有 OLE 公式,或选中公式后点击“加载所选”,可修改后点击“更新”。
    -
    -
    截图识别需要打开 LaTeXSnipper 桌面端,并在桌面端设置中启用自动化接口。普通公式编辑和插入可直接在 Office 插件中完成。
    -
    -
    -

    编辑公式

    -
      -
    • 可视化区与源码区:两者同步。源码含有可视化编辑器无法原样保留的写法时,上方只显示参考结果;继续在源码区编辑,或点击“用上方结果替换源码(可撤销)”。
    • -
    • 符号库与常用:左侧可搜索公式和符号,点击磁贴插入。右键点击磁贴可加入或移出“常用”;工具栏星形按钮可收藏当前公式。常用内容在升级后保留。
    • -
    • 字体与预览:工具栏的字体、字形、字号和颜色优先用于当前公式。全局颜色会同步到源码和可视化编辑区;右键可对选中部分上色、剪切、粘贴或全选,局部颜色会立即写入源码。“最终预览”按当前属性渲染,小字号会在屏幕上放大以便检查,插入仍使用选定字号;修改已有公式时沿用该公式保存的属性。
    • -
    • 快捷键:可视化区按 Shift + Enter 插入或更新。源码区按 Tab 在光标处缩进;补全列表按 Enter 接受。
    • -
    -
    -
    -

    设置与字体

    -

    在 LaTeXSnipper 选项卡点击“设置”。公式默认属性用于新建公式和主动执行的格式化;编辑器里临时选择的属性优先用于当前公式。导入、导出 JSON 可保存或应用当前 Word / PowerPoint 的公式默认属性。

    - - - - - - - - - -
    设置作用
    符号字体决定数学字母、符号及公式结构的字体方案。可选打包提供的 TeX 和 STIX2。
    数字字体单独指定普通数字使用的系统字体;选“跟随符号字体”时与公式符号保持一致。
    汉字字体指定公式内汉字使用的系统字体;使用与 Word 常用汉字字体一致的固定列表(等线到幼圆)。
    默认字形控制数学字符的正体、斜体、粗体、花体等表现,与字体方案分别选择。“自动”保留公式原有的数学排版;源码中明确指定的局部字形优先。
    字号、颜色决定新公式的默认大小和颜色。“新建时跟随文字字号”启用后优先使用光标或选区文字字号,无有效字号时使用设置中的固定值。
    -

    升级或重装会保留公式默认属性、插入方式、Word 编号布局和编辑器常用内容。导入公式预设只替换对应宿主的公式默认属性。

    -
    -
    -

    Word 中的常用操作

    -

    插入与编号

    -

    “插入行内公式”放在当前文字中;“插入行间公式”单独占一行;“插入带编号公式”按设置的左右位置与外框显示编号。设置中的“带编号公式默认布局”还可选择章、节编号、层级分隔符,以及是否显示章/节分隔符。

    -

    加载、转换和格式化

    -

    OLE 公式复制到另一份文档后仍可加载并独立编辑。选择普通 OLE 公式并点击“转为 MathType”,可得到 MathType 原生对象。双向转换无需安装 MathType;双击编辑转换后的对象需要 MathType。转换后不再保留插件源码与字体快照。Word 带编号公式暂不支持此转换。支持同时选择多个公式逐项转换,失败项保留原公式并继续处理其他项,结束后显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    -

    选中插件插入的公式后,用“加载所选”修改或用“删除所选”移除。设置中的“公式插入方式”可选 OLE 对象或 Word 原生公式;“转换为 OLE / Word 原生公式”可处理已选公式。“格式化所选 / 全文”按当前默认属性整理公式。

    -

    解析正文中的 LaTeX

    -

    “解析所选 / 全文”识别完整的 $...$、\(...\)、$$...$$ 和 \[...\]。未闭合或转换失败的内容会保留原文;行间公式中的有效 \tag{...} 可作为自定义编号。

    -

    重编号与引用

    -

    “自动编号”可给未编号的行间公式添加编号;插入章/节分隔符后,后续编号会按层级更新。“重编号”重新计算全文插件公式编号。用“插入引用”创建占位符,再选择目标编号公式或其所在段落完成引用。

    -

    侧边栏保留独立的新公式草稿;加载并更新已有公式请使用编辑器窗口。

    -
    -
    -

    PowerPoint 中的常用操作

    -

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。“转为 MathType”将所选插件 OLE 公式转换为 MathType 原生对象。双向转换无需安装 MathType;双击编辑转换后的对象需要 MathType。转换后不再保留插件源码和字体快照。支持多选批量转换;失败项保留原公式,继续处理其他项,并显示统计及具体错误。选择 MathType 公式后点击“转为 OLE”,可以用其当前 MathML 内容重新生成插件公式,支持多选和失败续跑;不会恢复转换前的 LaTeX 与字体快照。

    -

    在文本框内把光标放在文字中间并点击“插入公式”,会得到随文字排版的 PowerPoint 原生公式,可在 PowerPoint 中继续编辑。光标不在文字中时,新公式作为居中的独立幻灯片对象插入,并按设置选择 OLE 对象或 PNG 图片。

    -

    选中插件公式后,可“加载所选”修改、“删除所选”、在 OLE 与 PNG 间转换,或用“格式化所选 / 全文”应用当前默认属性。更新与转换会尽量保留对象在幻灯片中的位置和手动缩放。

    -

    侧边栏用于新公式草稿;“加载所选”、OLE / PNG 转换和批量格式化适用于插件插入的独立对象。文本内原生公式由 PowerPoint 管理。

    -
    -
    -

    社区与支持

    -

    项目免费开源,没有广告和内购。欢迎给仓库点 Star、反馈问题,或通过下面的二维码赞助维护并加入中文交流群。

    -
    -
    支付宝收款码
    支付宝赞助
    -
    微信收款码
    微信赞助
    -
    LaTeXSnipper 中文交流群二维码
    中文交流群
    -
    -
    -
    LaTeXSnipper Office 插件
    +
    +
    +

    开始使用

    +
    +
    01打开插件

    在 Word 或 PowerPoint 顶部的 LaTeXSnipper 选项卡中选择插入公式。

    +
    02写入公式

    在上方可视化区域输入,或在下方源码区写 LaTeX;也可以从左侧符号库选择公式。

    +
    03插入文档

    确认字号和颜色后点击“插入”。双击已有 OLE 公式,或选中公式后点击“加载所选”,可修改后点击“更新”。

    +
    +
    截图识别需要打开 LaTeXSnipper 桌面端,并在桌面端设置中启用自动化接口。普通公式编辑和插入可直接在 Office 插件中完成。
    +
    +
    +

    编辑公式

    +
      +
    • 可视化区与源码区:两者同步。源码含有可视化编辑器无法原样保留的写法时,上方只显示参考结果;继续在源码区编辑,或点击“用上方结果替换源码(可撤销)”。
    • +
    • 符号库与常用:左侧可搜索公式和符号,点击磁贴插入。右键点击磁贴可加入或移出“常用”;工具栏星形按钮可收藏当前公式。常用内容在升级后保留。
    • +
    • 字体与颜色:工具栏的字体、字形、字号和颜色优先用于当前公式。全局颜色会同步到源码和可视化编辑区;右键可对选中部分上色、剪切、粘贴或全选,局部颜色会立即写入源码。
    • +
    • 最终预览:按当前属性渲染,小字号会在屏幕上放大以便检查,插入仍使用选定字号;修改已有公式时沿用该公式保存的属性。
    • +
    • 快捷键:可视化区按 Shift + Enter 插入或更新。源码区按 Tab 在光标处缩进;补全列表按 Enter 接受。
    • +
    +
    +
    +

    设置与字体

    +

    在 LaTeXSnipper 选项卡点击“设置”。公式默认属性用于新建公式和主动执行的格式化;编辑器里临时选择的属性优先用于当前公式。导入、导出 JSON 可保存或应用当前 Word / PowerPoint 的公式默认属性。

    + + + + + + + + + +
    设置作用
    符号字体决定数学字母、符号及公式结构的字体方案。可选打包提供的 TeX 和 STIX2。
    数字字体单独指定普通数字使用的系统字体;选“跟随符号字体”时与公式符号保持一致。
    汉字字体指定公式内汉字使用的系统字体;使用与 Word 常用汉字字体一致的固定列表(等线到幼圆)。
    默认字形控制数学字符的正体、斜体、粗体、花体等表现,与字体方案分别选择。“自动”保留公式原有的数学排版;源码中明确指定的局部字形优先。
    字号、颜色决定新公式的默认大小和颜色。“新建时跟随文字字号”启用后优先使用光标或选区文字字号,无有效字号时使用设置中的固定值。
    +

    升级或重装会保留公式默认属性、插入方式、Word 编号布局和编辑器常用内容。导入公式预设只替换对应宿主的公式默认属性。

    +
    +
    +

    Word 中的常用操作

    +

    插入与编号

    +

    “插入行内公式”放在当前文字中;“插入行间公式”单独占一行;“插入带编号公式”按设置的左右位置与外框显示编号。设置中的“带编号公式默认布局”还可选择章、节编号、层级分隔符,以及是否显示章/节分隔符。

    +

    加载、转换和格式化

    +

    OLE 公式复制到另一份文档后仍可加载并独立编辑。选中插件公式后,用“加载所选”修改或用“删除所选”移除。

    +
      +
    • 转为 OLE / Word:在插件 OLE 与 Word 原生公式间转换;“转为 OLE”也支持 MathType 公式。
    • +
    • 转为 MathType:将普通插件 OLE 公式转为 MathType 原生对象,带编号公式暂不支持。
    • +
    • 格式化所选 / 全文:恢复插件公式的默认样式和自然大小。
    • +
    +

    设置中的“公式插入方式”可选 OLE 对象或 Word 原生公式。

    +

    MathType 双向转换无需安装 MathType;双击编辑 MathType 对象需要安装。转换不保留原插件源码与字体快照,转回 OLE 根据对象当前内容生成。

    +

    解析正文中的 LaTeX

    +

    “解析所选 / 全文”识别完整的 $...$、\(...\)、$$...$$ 和 \[...\]。未闭合或转换失败的内容会保留原文;行间公式中的有效 \tag{...} 可作为自定义编号。

    +

    重编号与引用

    +

    “自动编号”可给未编号的行间公式添加编号;插入章/节分隔符后,后续编号会按层级更新。“重编号”重新计算全文插件公式编号。用“插入引用”创建占位符,再选择目标编号公式或其所在段落完成引用。

    +

    侧边栏保留独立的新公式草稿;加载并更新已有公式请使用编辑器窗口。

    +
    +
    +

    PowerPoint 中的常用操作

    +

    插入公式

    +

    在文本框内把光标放在文字中间并点击“插入公式”,会得到随文字排版的 PowerPoint 原生公式,可在 PowerPoint 中继续编辑。光标不在文字中时,新公式作为居中的独立幻灯片对象插入,并按设置选择 OLE 对象或 PNG 图片。

    +

    加载、转换和格式化

    +

    插件 OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。选中插件公式后,用“加载所选”修改或用“删除所选”移除。

    +
      +
    • 转为 OLE / PNG:在插件 OLE 与 PNG 间转换;“转为 OLE”也支持 MathType 公式。
    • +
    • 转为 MathType:将所选插件 OLE 公式转为 MathType 原生对象。
    • +
    • 格式化所选 / 全文:恢复插件公式的默认样式和自然大小。
    • +
    +

    更新与转换会尽量保留对象在幻灯片中的位置和手动缩放。

    +

    MathType 双向转换无需安装 MathType;双击编辑 MathType 对象需要安装。转换不保留原插件源码与字体快照,转回 OLE 根据对象当前内容生成。

    +

    侧边栏用于新公式草稿;“加载所选”、OLE / PNG 转换和批量格式化适用于插件插入的独立对象。文本内原生公式由 PowerPoint 管理。

    +
    +
    +

    社区与支持

    +

    项目免费开源,没有广告和内购。欢迎给仓库点 Star、反馈问题,或通过下面的二维码赞助维护并加入中文交流群。

    +
    +
    支付宝收款码
    支付宝赞助
    +
    微信收款码
    微信赞助
    +
    LaTeXSnipper 中文交流群二维码
    中文交流群
    +
    +
    +
    LaTeXSnipper Office 插件
    +
    -
    LATEXSNIPPER · OFFICE
    +
    LaTeXSnipperOffice plugin help

    Formula guide

    Find the next step, from inserting a formula to editing, numbering, and formatting it.

    -
    -

    Get started

    -
    -
    1 · Open the pluginUse the LaTeXSnipper tab at the top of Word or PowerPoint to insert a formula.
    -
    2 · Write the formulaType in the visual area or the LaTeX source pane, or choose a formula from the symbol library.
    -
    3 · Insert itCheck the size and color, then select Insert. Double-click an existing OLE formula, or select a formula and use Load Selected, then Update.
    -
    -
    Screenshot recognition requires the LaTeXSnipper desktop app with the Automation API enabled in its settings. Ordinary formula editing and insertion work inside the Office plugin.
    -
    -
    -

    Edit formulas

    -
      -
    • Visual and source panes:They stay in sync. When the visual editor cannot preserve source exactly, the upper pane is a reference. Keep editing below, or choose “Replace source with result (undoable)”.
    • -
    • Library and Common:Search and click a tile to insert it. Right-click a tile to add or remove it from Common. Use the toolbar star to save the current formula. Common survives upgrades.
    • -
    • Typography and preview:Toolbar choices apply to the current formula. The formula color is synchronized with the source and visual editor. Right-click in the visual editor to color a selection, cut, paste, or select all; local colors are written to the source immediately. Final Preview enlarges small formulas on screen for inspection; insertion still uses the selected point size. Existing formulas reopen with their saved properties.
    • -
    • Keys:In the visual pane, Shift + Enter inserts or updates. In source, Tab indents at the caret and Enter accepts a completion.
    • -
    -
    -
    -

    Settings and fonts

    -

    Open Settings from the LaTeXSnipper tab. Default formula properties apply to new formulas and explicit Format commands. Editor choices take priority for the current formula. Import and export JSON to save or apply the current host's formula defaults.

    - - - - - - - - - -
    SettingWhat it controls
    Symbol fontThe font scheme for mathematical letters, symbols, and formula structures. The bundled choices are TeX and STIX2.
    Number fontAn optional system font for ordinary digits. Follow Symbol Font keeps numbers with the mathematical symbols.
    CJK fontA system font for Chinese characters within formulas. The fixed list contains common Chinese fonts, from DengXian to YouYuan.
    Default math styleUpright, italic, bold, script, and other mathematical shapes, selected independently from the font scheme. Automatic preserves normal mathematical styling; explicit source styles take priority.
    Size and colorDefaults for new formulas. Follow Text Size uses the text at the insertion point when available and falls back to the fixed size.
    -

    Upgrades and reinstalls preserve formula defaults, insertion choices, Word numbering preferences, and Common formulas. Importing a formula preset replaces only that host's formula defaults.

    -
    -
    -

    Common tasks in Word

    -

    Insert and number

    -

    Insert Inline places a formula within text. Insert Display gives it a separate line. Insert Numbered positions the number and enclosure according to settings. Default Numbered Formula Layout also controls chapter and section numbering, separators, and boundary visibility.

    -

    Load, convert, and format

    -

    Copied OLE formulas remain independently editable in another document. To MathType converts an unnumbered OLE formula into a native MathType object. Conversion in either direction works without MathType installed; double-click editing of native MathType objects requires MathType. The plugin source and font snapshot are not retained after conversion. You can convert multiple selected formulas; failed items remain unchanged while the rest continue, followed by a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    -

    Select a plugin formula to Load Selected or Delete Selected. Formula Insertion offers OLE objects or native Word equations. Convert to OLE / Word Equation changes selected formulas. Format Selected / All applies the current default properties.

    -

    Parse LaTeX in document text

    -

    Parse Selected / All recognizes complete $...$, \(...\), $$...$$, and \[...\] expressions. Incomplete or failed conversions leave the original text in place. A valid \tag{...} can provide a custom number for a display formula.

    -

    Renumber and reference

    -

    Auto Number numbers an unnumbered display formula. Chapter and section boundaries affect later numbers. Renumber recalculates plugin formula numbers across the document. Insert Reference creates a placeholder; select a numbered formula or its paragraph to complete it.

    -

    The side pane keeps a separate draft for a new formula. Use the editor window to update an existing one.

    -
    -
    -

    Common tasks in PowerPoint

    -

    Plugin OLE formulas remain independently editable after copying to another presentation. To MathType converts the selected plugin OLE formula into a native MathType object. Conversion in either direction works without MathType installed; double-click editing of native MathType objects requires MathType. The plugin source and font snapshot are not retained after conversion. Multiple selected formulas are converted individually; failed items remain unchanged while the rest continue, with a summary and the specific error. Select MathType equations and choose To OLE to generate plugin objects from their current MathML, with the same batch handling. This does not restore the original LaTeX or font snapshot.

    -

    Place the text caret inside a text box to insert a native PowerPoint equation that flows with the surrounding text and remains editable in PowerPoint. Outside text, Insert Formula adds a centered slide object using the OLE or PNG insertion setting.

    -

    Select a plugin formula to Load Selected, Delete Selected, convert between OLE and PNG, or apply Format Selected / All. Updates and conversions preserve the slide position and manual scale where possible.

    -

    The side pane holds a new formula draft. Load Selected, OLE / PNG conversion, and bulk formatting apply to standalone objects inserted by the plugin. PowerPoint manages equations within text.

    -
    -
    -

    Community and support

    -

    LaTeXSnipper is free, open source, ad-free, and has no in-app purchases. Star the repository, report an issue, support maintenance, or join the Chinese community using the QR codes below.

    -
    -
    Alipay donation QR code
    Alipay
    -
    WeChat Pay donation QR code
    WeChat Pay
    -
    LaTeXSnipper community chat QR code
    Community chat
    -
    -
    -
    LaTeXSnipper Office Plugin
    +
    +
    +

    Get started

    +
    +
    01Open the plugin

    Use the LaTeXSnipper tab at the top of Word or PowerPoint to insert a formula.

    +
    02Write the formula

    Type in the visual area or the LaTeX source pane, or choose a formula from the symbol library.

    +
    03Insert it

    Check the size and color, then select Insert. Double-click an existing OLE formula, or select a formula and use Load Selected, then Update.

    +
    +
    Screenshot recognition requires the LaTeXSnipper desktop app with the Automation API enabled in its settings. Ordinary formula editing and insertion work inside the Office plugin.
    +
    +
    +

    Edit formulas

    +
      +
    • Visual and source panes: They stay in sync. When the visual editor cannot preserve source exactly, the upper pane is a reference. Keep editing below, or choose “Replace source with result (undoable)”.
    • +
    • Library and Common: Search and click a tile to insert it. Right-click a tile to add or remove it from Common. Use the toolbar star to save the current formula. Common survives upgrades.
    • +
    • Typography and color: Toolbar choices apply to the current formula. The formula color is synchronized with the source and visual editor. Right-click in the visual editor to color a selection, cut, paste, or select all; local colors are written to the source immediately.
    • +
    • Final Preview: Small formulas are enlarged on screen for inspection; insertion still uses the selected point size. Existing formulas reopen with their saved properties.
    • +
    • Keys: In the visual pane, Shift + Enter inserts or updates. In source, Tab indents at the caret and Enter accepts a completion.
    • +
    +
    +
    +

    Settings and fonts

    +

    Open Settings from the LaTeXSnipper tab. Default formula properties apply to new formulas and explicit Format commands. Editor choices take priority for the current formula. Import and export JSON to save or apply the current host's formula defaults.

    + + + + + + + + + +
    SettingWhat it controls
    Symbol fontThe font scheme for mathematical letters, symbols, and formula structures. The bundled choices are TeX and STIX2.
    Number fontAn optional system font for ordinary digits. Follow Symbol Font keeps numbers with the mathematical symbols.
    CJK fontA system font for Chinese characters within formulas. The fixed list contains common Chinese fonts, from DengXian to YouYuan.
    Default math styleUpright, italic, bold, script, and other mathematical shapes, selected independently from the font scheme. Automatic preserves normal mathematical styling; explicit source styles take priority.
    Size and colorDefaults for new formulas. Follow Text Size uses the text at the insertion point when available and falls back to the fixed size.
    +

    Upgrades and reinstalls preserve formula defaults, insertion choices, Word numbering preferences, and Common formulas. Importing a formula preset replaces only that host's formula defaults.

    +
    +
    +

    Common tasks in Word

    +

    Insert and number

    +

    Insert Inline places a formula within text. Insert Display gives it a separate line. Insert Numbered positions the number and enclosure according to settings. Default Numbered Formula Layout also controls chapter and section numbering, separators, and boundary visibility.

    +

    Load, convert, and format

    +

    Copied OLE formulas remain independently editable in another document. Select a plugin formula to Load Selected or Delete Selected.

    +
      +
    • To OLE / Word: Converts between plugin OLE formulas and native Word equations. To OLE also accepts MathType equations.
    • +
    • To MathType: Converts an unnumbered plugin OLE formula into a native MathType object. Numbered formulas are not supported.
    • +
    • Format Selected / All: Restores plugin formulas to the default style and natural size.
    • +
    +

    Formula Insertion offers OLE objects or native Word equations.

    +

    MathType conversion in either direction works without MathType installed; double-click editing requires MathType. Conversion does not retain the original plugin source or font snapshot. Converting back to OLE uses the object's current content.

    +

    Parse LaTeX in document text

    +

    Parse Selected / All recognizes complete $...$, \(...\), $$...$$, and \[...\] expressions. Incomplete or failed conversions leave the original text in place. A valid \tag{...} can provide a custom number for a display formula.

    +

    Renumber and reference

    +

    Auto Number numbers an unnumbered display formula. Chapter and section boundaries affect later numbers. Renumber recalculates plugin formula numbers across the document. Insert Reference creates a placeholder; select a numbered formula or its paragraph to complete it.

    +

    The side pane keeps a separate draft for a new formula. Use the editor window to update an existing one.

    +
    +
    +

    Common tasks in PowerPoint

    +

    Insert formulas

    +

    Place the text caret inside a text box to insert a native PowerPoint equation that flows with the surrounding text and remains editable in PowerPoint. Outside text, Insert Formula adds a centered slide object using the OLE or PNG insertion setting.

    +

    Load, convert, and format

    +

    Plugin OLE formulas remain independently editable after copying to another presentation. Select a plugin formula to Load Selected or Delete Selected.

    +
      +
    • To OLE / PNG: Converts between plugin OLE formulas and PNG. To OLE also accepts MathType equations.
    • +
    • To MathType: Converts the selected plugin OLE formula into a native MathType object.
    • +
    • Format Selected / All: Restores plugin formulas to the default style and natural size.
    • +
    +

    Updates and conversions preserve the slide position and manual scale where possible.

    +

    MathType conversion in either direction works without MathType installed; double-click editing requires MathType. Conversion does not retain the original plugin source or font snapshot. Converting back to OLE uses the object's current content.

    +

    The side pane holds a new formula draft. Load Selected, OLE / PNG conversion, and bulk formatting apply to standalone objects inserted by the plugin. PowerPoint manages equations within text.

    +
    +
    +

    Community and support

    +

    LaTeXSnipper is free, open source, ad-free, and has no in-app purchases. Star the repository, report an issue, support maintenance, or join the Chinese community using the QR codes below.

    +
    +
    Alipay donation QR code
    Alipay
    +
    WeChat Pay donation QR code
    WeChat Pay
    +
    LaTeXSnipper community chat QR code
    Community chat
    +
    +
    +
    LaTeXSnipper Office Plugin
    +
    diff --git a/office_plugin/hosts/WordAddIn/WordAddInText.cs b/office_plugin/hosts/WordAddIn/WordAddInText.cs index 01349ea6..52eba142 100644 --- a/office_plugin/hosts/WordAddIn/WordAddInText.cs +++ b/office_plugin/hosts/WordAddIn/WordAddInText.cs @@ -53,7 +53,7 @@ private static string GetEn(string key) "AutoNumberTip" => "Add an automatic number to the selected display formula.", "RenumberTip" => "Renumber all automatically numbered formulas in document order.", "HelpTip" => "View plugin help.", - "SettingsTip" => "Open LaTeXSnipper settings.", + "SettingsTip" => "Open plugin settings.", "ShowTaskPaneTip" => "Show the status pane.", "LoadSelectedTip" => "Load the selected formula into the editor.", "DeleteSelectedTip" => "Delete selected formulas.", @@ -117,7 +117,7 @@ private static string GetEn(string key) "InsertSectionBoundaryTip" => "Mark the start of a section for automatic numbering.", "FormatSelectedButton" => "Format Selected", "FormatAllButton" => "Format All", - "FormatSelectedTip" => "Reset selected formulas to the default style and natural size.", + "FormatSelectedTip" => "Reset selected plugin formulas to the default style and natural size.", "FormatAllTip" => "Reset all plugin formulas to the default style and natural size.", "ParseSelectedButton" => "Parse Selected", "ParseAllButton" => "Parse All", @@ -184,7 +184,7 @@ private static string GetZh(string key) "AutoNumberTip" => "为选中的行间公式添加自动编号。", "RenumberTip" => "重排所有自动编号的公式。", "HelpTip" => "查看插件帮助。", - "SettingsTip" => "打开 LaTeXSnipper 设置。", + "SettingsTip" => "打开插件设置。", "ShowTaskPaneTip" => "显示状态窗格。", "LoadSelectedTip" => "将所选公式加载到编辑器。", "DeleteSelectedTip" => "删除所选公式。", @@ -248,7 +248,7 @@ private static string GetZh(string key) "InsertSectionBoundaryTip" => "标记自动编号的新节起点。", "FormatSelectedButton" => "格式化所选", "FormatAllButton" => "格式化全文", - "FormatSelectedTip" => "恢复所选公式的默认样式和自然大小。", + "FormatSelectedTip" => "恢复所选插件公式的默认样式和自然大小。", "FormatAllTip" => "恢复全文插件公式的默认样式和自然大小。", "ParseSelectedButton" => "解析所选", "ParseAllButton" => "解析全文", diff --git a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe index 68e2110e..0057b6cc 100644 --- a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe +++ b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3583a9f474a1cb9072b8217354e89bf39bd269d28b7204b1f337342334aedc66 -size 7568402 +oid sha256:bc33c7c5f4ec7c69a8b70b227aae882b7d6eb93b54a97c8ca371f1fc988dd105 +size 7567815 diff --git a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 index 8a968342..77992a0e 100644 --- a/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 +++ b/office_plugin/release/LaTeXSnipperOffice_3.1.0_amd64.exe.sha256 @@ -1 +1 @@ -3583a9f474a1cb9072b8217354e89bf39bd269d28b7204b1f337342334aedc66 LaTeXSnipperOffice_3.1.0_amd64.exe +bc33c7c5f4ec7c69a8b70b227aae882b7d6eb93b54a97c8ca371f1fc988dd105 LaTeXSnipperOffice_3.1.0_amd64.exe diff --git a/user_manual/user_manual.md b/user_manual/user_manual.md index 44d60b75..b404da75 100644 --- a/user_manual/user_manual.md +++ b/user_manual/user_manual.md @@ -1122,20 +1122,23 @@ LaTeXSnipperOffice_3.1.0_amd64.exe /dir="<安装目录>" - **公式组:** 行内公式、行间公式、带编号公式、截图识别 - **编辑组:** 加载所选、删除所选 -- **转换组:** 转为 OLE、转为 Word +- **转换组:** 转为 MathType、转为 OLE、转为 Word - **公式解析组:** 解析所选、解析全文 -- **引用组:** 插入引用 +- **交叉引用组:** 插入引用 - **编号组:** 添加编号、重编号 -- **边界组:** 插入章分隔符、插入节边界 -- **格式化组:** 格式化所选、格式化全部 +- **编号边界组:** 章分隔符、节分隔符 +- **公式样式组:** 格式化所选、格式化全文 - **工具组:** 状态窗格、设置、帮助 -### 插入 OMML 公式 +### 公式插入方式 -Word 加载项通过本地 OMML 转换引擎将 LaTeX 转换为 Word 原生 OMML(Office Math Markup Language)公式。OMML 公式是 Word 原生格式,可以像手动插入的公式一样编辑。 +Word 支持插件 OLE 对象和 Word 原生 OMML(Office Math Markup Language)公式,在“设置 → 公式插入方式”中选择: + +- **OLE 对象(默认):** 本地渲染的矢量公式对象,双击或“加载所选”可打开插件编辑器。复制到另一份文档后仍可加载并独立编辑。 +- **Word 原生公式:** 可直接使用 Word 的公式工具编辑,也可通过插件加载、更新和转换。 > [!NOTE] -> OMML 公式是 Word 原生格式,卸载加载项后公式仍然可以正常显示和编辑。加载项插入的公式会附带 LaTeXSnipper 管理元数据(LaTeX 源码、显示模式、编号模式),用于后续加载、更新和重编号。普通 Word 公式没有 LaTeXSnipper 元数据,插件不会猜测其 LaTeX 来源。 +> OMML 公式是 Word 原生格式,卸载加载项后公式仍然可以正常显示和编辑。加载项插入的公式会附带 LaTeXSnipper 管理元数据(公式源码、显示模式、编号模式),用于后续加载、更新和重编号。普通 Word 公式没有 LaTeXSnipper 元数据,插件不会猜测其 LaTeX 来源。 ### 三种插入模式 @@ -1153,16 +1156,16 @@ Word 加载项通过本地 OMML 转换引擎将 LaTeX 转换为 Word 原生 OMML 选中由 LaTeXSnipper 插入的公式后: -- **加载所选:** 将公式的完整 LaTeX 源码加载到公式编辑器,可以修改后更新原公式 +- **加载所选:** 将公式保存的源码加载到公式编辑器,可以修改后更新原公式 - **更新:** 在编辑器中修改后点击更新,新公式会替换原公式,保留元数据 -- **删除所选:** 删除公式及对应的元数据 +- **删除所选:** 删除选中的插件公式;删除带编号公式时同时删除其编号段落 ### 添加编号与重编号 Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并按文档顺序维护自动编号: - **添加编号:** 为选中的行间公式添加自动编号。如果公式已有编号,不会重复添加 -- **重编号全部:** 按文档顺序更新所有自动编号公式的 SEQ 字段、编号书签和引用字段,不重新渲染公式内容;缺失元数据或编号字段的异常公式会被跳过并计数 +- **重编号:** 按文档顺序更新所有自动编号公式的 SEQ 字段、编号书签和引用字段,不重新渲染公式内容;缺失元数据或编号字段的异常公式会被跳过并计数 - **编号位置:** 设置中可选择右编号或左编号;该设置只影响新插入的编号公式和之后被自动编号的普通公式,不改变已有公式的编号属性 ### 章/节分隔符与层级编号 @@ -1170,7 +1173,7 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 支持多层级公式编号体系。在文档中插入章或节分隔符后,自动编号会按层级生成(例如 `1.1`、`1.2`)。 - **插入章分隔符:** 标记新章开始,后续自动编号重置为当前章号 -- **插入节边界:** 标记新节开始,后续自动编号追加当前节号 +- **插入节分隔符:** 标记新节开始,后续自动编号追加当前节号 - **包含章/节编号:** 设置中可控制编号是否包含章或节的层级部分 - **分隔符样式:** 层级分隔符可在设置中选择(`.`、`-`、`·`、`:`、`/`) - **隐藏分隔符:** 设置中可隐藏插入文档中的分隔符标记 @@ -1184,16 +1187,22 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 ### 公式转换 -支持在已有的管理公式之间转换格式,也支持把选中的 Word 原生 OMML 公式显式转换为 LaTeXSnipper OLE: +选中公式后,通过功能区转换组选择目标格式: -- **转为 OLE:** 将选中的 OMML 管理公式转为 OLE 公式对象(支持更多渲染选项) +- **转为 MathType:** 将普通插件 OLE 公式转为 MathType 原生对象;带编号公式暂不支持 +- **转为 OLE:** 将插件 OMML、Word 原生 OMML 或 MathType 公式转为插件 OLE 对象 - **转为 Word:** 将 OLE 公式转为 Word 原生 OMML 公式(可像 Word 公式一样直接编辑) -- **Word 原生 OMML 转 OLE:** 选中非 LaTeXSnipper 托管的 Word 原生公式后执行"转为 OLE",插件会读取原生 OMML,转换为 MathML,并把 MathML 作为源码存入 OLE 元数据。这类公式可加载和继续转换,但不应执行"格式化所选",因为格式化链路按 LaTeX 宏重写源码,MathML 源码会被破坏。 + +MathType 双向转换无需安装 MathType;双击编辑 MathType 对象需要安装。转为 MathType 不保留原插件源码与字体快照,转回 OLE 根据对象当前内容生成。 + +Word 原生 OMML 或 MathType 转为 OLE 后保存的是 MathML 源码,可加载、更新、格式化及继续转换,不会还原为原始 LaTeX。 ### 格式重置 -- **格式化所选:** 将选中的公式恢复为默认字体、颜色和自然尺寸 -- **格式化全部:** 将所有尺寸被修改过的公式恢复为自然尺寸 +- **格式化所选:** 恢复所选插件公式的默认样式和自然大小 +- **格式化全文:** 恢复全文插件公式的默认样式和自然大小 + +两者均使用当前设置中的公式默认属性,只处理样式或尺寸需要恢复的插件公式。 ### 性能与复杂度参考 @@ -1207,9 +1216,9 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 | 插入带编号公式 | `O(s + r + ole/xml + c)` | 不适用 | 公式插入、SEQ 编号域、书签、居中/右对齐制表位布局 | | 加载所选 | `O(c)` 最坏,通常接近 `O(1)` | `O(c)` 最坏,通常接近 `O(1)` | 从选区附近定位元数据,必要时扫描对象 | | 更新公式 | `O(s + r + replace)` | `O(s + r + replace)` | 重新渲染和替换原对象;OLE 会保留用户缩放 | -| 删除所选 | `O(c + f + n log n)` | `O(c + n)` | 定位并按文档位置删除公式、边界控件、引用域或引用占位符 | +| 删除所选 | `O(c + n log n)` | `O(c + n)` | 收集所选插件公式;Word 按文档位置倒序删除,带编号公式同时删除编号段落 | | 添加编号 | `O(s + r + replace + before)` | 不适用 | 将普通行间公式改为编号布局,只扫描当前位置前的编号时间线 | -| 重编号全部 | `O(c + f + m log m + refs)` | 不适用 | 收集并排序自动编号公式和章/节边界,一次建立 SEQ 字段映射,更新编号书签和 LaTeXSnipper 引用域 | +| 重编号 | `O(c + f + m log m + refs)` | 不适用 | 收集并排序自动编号公式和章/节边界,一次建立 SEQ 字段映射,更新编号书签和 LaTeXSnipper 引用域 | | 插入章/节分隔符 | `O(c + m log m + refs)` | 不适用 | 插入边界控件后立即重编号全部自动编号公式 | | 插入交叉引用 | `O(1)` 插入占位,选择目标时最坏 `O(c + f)` | 不适用 | 查找目标公式、定位编号书签并创建 Word REF 域 | | 公式解析 | `O(c + n * (s + r + replace))` | 不适用 | 安全范围扫描、公式渲染和 Office 对象替换 | @@ -1217,12 +1226,16 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 | Word 原生 OMath 转 OLE | `O(n * (s + xsl + r + ole))` | 不适用 | OMML2MML XSL、MathJax 按 MathML 渲染、OLE 创建 | | 转 Word OMML / PNG | `O(n * (s + r + xsl + xml))` | `O(n * (s + r + png))` | OLE/PNG 重新生成和 Office 对象替换 | | 格式化所选 | `O(n * (s + r + replace) + p)` | `O(n * (s + r + replace))` | 只有属性不同或尺寸被改过时才重新渲染/重置;Word 行内基线只扫描当前段落 | -| 格式化全部 | `O(m)` | `O(m)` | 扫描全文公式并恢复被用户缩放过的自然尺寸 | +| 格式化全文 | `O(m * (s + r + replace) + p)` | `O(m * (s + r + replace))` | 复用所选格式化流程,按当前默认样式重绘需要恢复的公式并重置自然大小 | | 截图识别 | `O(ocr + s + r)` | `O(ocr + s + r)` | 桌面端 OCR 和后续公式插入 | -多选转换、公式解析和多选格式化所选会分批执行并更新状态窗格;Word 每批使用短 undo record。长公式或大量公式时,主要耗时仍来自 `r`(MathJax 渲染/图片生成)和 `ole/xml/png`(Office COM 插入与对象替换)。建议长文档批量处理时分段执行并保存文档。 +转换、公式解析和格式化会分批执行并更新状态窗格;单项失败会报告具体错误,并继续处理其他项,结束后显示处理统计。重编号会跳过编号信息不完整的公式并计数。长公式或大量公式时,主要耗时仍来自 `r`(MathJax 渲染/图片生成)和 `ole/xml/png`(Office COM 插入与对象替换)。建议长文档批量处理时分段执行并保存文档。 -Word 原生 OMath 转 OLE 保存的是 MathML 源码,不是 LaTeX 源码;它用于保真转换,不进入 LaTeX 格式化链路。原生行内公式由 Word 自己维护外层公式输入框,少数单选场景下外层占位框可能由 Word 保留;继续转为 LaTeXSnipper 管理的 OMML 后会由插件接管并清理为插件格式。 +格式化保留公式源码,通过当前默认属性重新渲染;LaTeX 和 MathML 源码均可处理。原生行内公式由 Word 自己维护外层公式输入框,少数单选场景下外层占位框可能由 Word 保留;继续转为 LaTeXSnipper 管理的 OMML 后会由插件接管并清理为插件格式。 + +### 截图识别 + +打开 LaTeXSnipper 桌面端,并启用设置中的“自动化接口”。点击插件“截图识别”后,使用桌面端全局快捷键框选公式;识别结果会填入 Office 公式编辑器。等待期间再次点击“截图识别”可取消。普通公式编辑和插入无需启动桌面端。 --- @@ -1232,40 +1245,43 @@ Word 原生 OMath 转 OLE 保存的是 MathML 源码,不是 LaTeX 源码;它 PowerPoint 的 LaTeXSnipper 标签页结构与 Word 类似,但功能更精简: -- **公式组:** 插入公式、截图识别 +- **公式组:** 插入公式、截图识别(识别流程与 Word 相同) - **编辑组:** 加载所选、删除所选 -- **转换组:** 转为 OLE、转为 PNG -- **格式化组:** 格式化所选、格式化全文 +- **转换组:** 转为 MathType、转为 OLE、转为 PNG +- **公式样式组:** 格式化所选、格式化全文 - **工具组:** 状态窗格、设置、帮助 PowerPoint 不包含编号组、引用组、边界组——这些是 Word 文档专属功能。 -### 插入公式图片 +### 插入公式 + +光标位于文本框文字中时,点击“插入公式”生成随文字排版的 PowerPoint 原生公式,可用 PowerPoint 的公式工具编辑。 -PowerPoint 加载项支持两种渲染方式:OLE 公式对象(将 LaTeX 交由本机 OLE 处理器渲染)和高 DPI PNG 图片(由内置 MathJax 渲染为 SVG 后光栅化插入幻灯片)。 +其他位置按设置插入居中的独立 OLE 对象或高 DPI PNG 图片;OLE 支持双击编辑,PNG 可通过“加载所选”修改。 > [!NOTE] -> PowerPoint 公式以 OLE 对象或图片形状插入,不是原生 OMML。没有行内/行间区别——PowerPoint 幻灯片上的公式始终是独立对象。不支持自动编号和重编号(这些是 Word 文档专属功能)。 +> 加载、删除、转换和批量格式化面向插件插入的独立对象;文本内原生公式由 PowerPoint 管理。PowerPoint 不支持插件自动编号和重编号。 ### 加载与删除 -- **加载所选:** 选中幻灯片上的公式图片,点击加载所选,公式的 LaTeX 源码会载入独立公式编辑器。修改后重新插入时,新图片会替换原位置旧图片 -- **删除所选:** 删除选中的公式图片及元数据 +- **加载所选:** 将所选插件 OLE 或 PNG 公式载入编辑器;修改后点击“更新”,尽量保留原位置和手动缩放 +- **删除所选:** 删除选中的插件 OLE 或 PNG 公式 + +OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。 ### 公式转换与格式重置 -- **转为 OLE:** 将选中 PNG 格式的公式图片转换为 OLE 公式对象 +- **转为 MathType:** 将所选插件 OLE 公式转为 MathType 原生对象 +- **转为 OLE:** 将插件 PNG 或 MathType 公式转为插件 OLE 对象 - **转为 PNG:** 将 OLE 公式转换为 PNG 图片 -- **格式化所选:** 将选中的公式恢复为默认字体、颜色和自然尺寸 -- **格式化全文:** 将所有尺寸被修改过的公式恢复为自然尺寸 +- **格式化所选:** 恢复所选插件公式的默认样式和自然大小 +- **格式化全文:** 恢复全文插件公式的默认样式和自然大小 + +MathType 双向转换无需安装 MathType;双击编辑 MathType 对象需要安装。转为 MathType 不保留原插件源码与字体快照,转回 OLE 根据对象当前内容生成。 > [!TIP] > 如果你在幻灯片上找不到公式图片,可以检查形状的替代文本(Alt Text)。所有 LaTeXSnipper 公式图片的替代文本均以 "LaTeXSnipper formula" 开头。 -### 截图识别 - -与 Word 相同的截图 OCR 流程。识别结果会自动填入公式编辑器,可直接插入或修改后插入。 - --- ## 公式编辑器与侧边栏 @@ -1282,11 +1298,11 @@ PowerPoint 加载项支持两种渲染方式:OLE 公式对象(将 LaTeX 交 ### 独立公式编辑器 -通过功能区"行内公式""行间公式""带编号公式"按钮或"加载所选"打开的独立公式编辑器窗口,搭载 MathLive 可视化公式引擎和完整数学符号系统。 +通过 Word 功能区的“行内公式”“行间公式”“带编号公式”、PowerPoint 的“插入公式”或“加载所选”打开独立公式编辑器。 - **数学编辑区:** 全尺寸 MathLive 所见即所得编辑区,支持键盘快捷键(如 `+` `_` `/` `sqrt` 等标准 LaTeX 缩写自动识别)、光标导航和即时预览 - **LaTeX 源码区:** 编辑区下方的 LaTeX 源码输入区,与可视化编辑区实时双向同步,可直接粘贴或编辑 LaTeX 代码 -- **符号面板(右侧):** 内置 **18 分类、2121 个数学符号与公式模板**,覆盖从基础算术到前沿研究的全部符号体系: +- **符号面板(左侧):** 内置 **18 分类、2121 个数学符号与公式模板**,覆盖从基础算术到前沿研究的全部符号体系: - *基础(4 类 171 条):* 希腊字母(含变体和希伯来字母)、结构模板(矩阵/根式/行列式等,支持自选行列数)、分隔符(含大尺寸变体和装饰框线)、集合与逻辑符号 - *代数(1 类 174 条):* 线性代数(矩阵运算/特征系统)/抽象代数(群环域/同调代数/李理论/代数几何)/算子代数(C*/von Neumann/K-理论) - *几何(1 类 133 条):* 微分几何/黎曼曲率/陈类/Chern-Simons/Atiyah-Singer 指标/辛几何 @@ -1301,13 +1317,16 @@ PowerPoint 加载项支持两种渲染方式:OLE 公式对象(将 LaTeX 交 - *其他(1 类 56 条):* 杂项符号 - **标签内搜索:** 每个符号面板顶部设有搜索框,支持按中文名称或 LaTeX 源码实时过滤当前标签内的符号 - **全局搜索:** 标签列顶部设有全局搜索框,输入关键词后跨所有 18 个标签检索,按组归类展示结果 -- **底部工具栏:** 基础运算(计算/化简/数值化/求解/展开/因式分解)、多行排版布局、快捷插入片段、示例载入、复制(LaTeX / MathJSON) +- **样式工具栏:** 调整当前公式的符号字体、数字字体、汉字字体、字形、字号和颜色;“最终预览”显示实际渲染结果,小字号仅在屏幕上放大,插入仍使用选定字号 +- **常用公式:** 右键符号磁贴可加入或移出“常用”,工具栏星形按钮可收藏当前公式;升级或重装后保留收藏 - **插入 / 更新:** 底部按钮根据当前模式显示"插入"或"更新" - **取消:** 关闭编辑器,不应用更改 > [!TIP] > `Enter` 新建数学行;`Shift+Enter` 插入或更新当前公式;`Esc` 只收回 MathLive 虚拟键盘,不关闭编辑器。 +源码区按 `Tab` 在光标处缩进,补全列表按 `Enter` 接受。源码含有可视化编辑器无法原样保留的写法时,上方只显示参考结果;可继续编辑源码,或点击“用上方结果替换源码(可撤销)”。 + ### 平台界面差异 Word 侧边栏包含行间公式、自动编号、自定义编号输入框等 Word 专属控件。PowerPoint 侧边栏不包含这些选项,界面更简洁。 @@ -1322,10 +1341,10 @@ Word 侧边栏包含行间公式、自动编号、自定义编号输入框等 Wo **公式插入方式** -- **OLE 对象(默认):** 将 LaTeX 交由本机 OLE 处理器渲染为公式对象 -- **Word OMML:** 通过本地转换引擎转为 Word 原生 OMML 公式,可直接在 Word 中编辑 +- **Word:** OLE 对象(默认)或 Word 原生 OMML 公式 +- **PowerPoint:** OLE 对象(默认)或 PNG 图片;文本框内插入时使用 PowerPoint 原生公式 -**带编号公式默认布局** +**Word 带编号公式默认布局** - **编号位置:** 右编号(默认)或左编号 - **外框:** 无、圆括号(( ))、方括号([ ])、花括号({ }) @@ -1335,31 +1354,40 @@ Word 侧边栏包含行间公式、自动编号、自定义编号输入框等 Wo **公式默认属性** -- **字体颜色:** 拾色器选择新插入公式的默认字体颜色,支持恢复为黑色或白色 -- **默认字体:** TeX 原生字体、罗马正体、粗体、斜体、黑板粗体、花体、哥特体、无衬线、等宽等论文常用数学字体 +- **符号字体:** TeX 或 STIX2 字体方案,用于数学字母、符号和公式结构 +- **数字字体:** 指定普通数字的系统字体,或跟随符号字体 +- **汉字字体:** 指定公式内汉字使用的字体 +- **默认字形:** 自动、正体、斜体、粗体、黑板粗体、花体、哥特体、无衬线、等宽等数学样式,与字体方案分别选择 +- **字号与颜色:** 新公式和格式化命令使用的默认字号、颜色;“新建时跟随文字字号”优先采用光标或选区文字字号,无有效字号时使用固定值 +- **JSON 导入 / 导出:** 保存或应用当前 Word / PowerPoint 的公式默认属性 + +编辑器中的临时选择仅用于当前公式,修改已有公式时沿用其保存的属性。升级、重装和卸载会保留公式默认属性、插入偏好、Word 编号布局及编辑器收藏。 **编辑器键盘行为** | 快捷键 | 功能 | |---|---| -| `Enter` | 新建数学行 | -| `Shift` + `Enter` | 插入或更新当前公式 | -| `Ctrl` + `F` | 插入分式 | -| `Ctrl` + `R` | 插入根号 | -| `Ctrl` + `H` | 插入上标 | -| `Ctrl` + `L` | 插入下标 | -| `Ctrl` + `J` | 插入上下标 | +| 可视化区 `Enter` | 新建数学行 | +| 可视化区 `Shift` + `Enter` | 插入或更新当前公式 | +| 可视化区 `Ctrl` + `F` | 插入分式 | +| 可视化区 `Ctrl` + `R` | 插入根号 | +| 可视化区 `Ctrl` + `H` | 插入上标 | +| 可视化区 `Ctrl` + `L` | 插入下标 | +| 可视化区 `Ctrl` + `J` | 插入上下标 | +| 源码区 `Tab` | 在光标处缩进 | +| 源码补全列表 `Enter` | 接受当前补全项 | | `Esc` | 收回 MathLive 虚拟键盘 | ### 帮助窗口 通过功能区"帮助"按钮打开,根据当前宿主(Word 或 PowerPoint)显示对应内容: -- 系统要求详情 -- Word / PowerPoint 功能列表 -- 功能区按钮说明 -- 插件架构介绍 -- 使用边界说明 +- 开始使用与公式编辑 +- 设置、字体与预览 +- 当前宿主的插入、转换、格式化等常用操作 +- 社区与支持 + +宽窗口中目录固定在左侧,点击条目可跳转到对应章节;窄窗口中目录位于正文上方。页面随 Office 界面语言显示中文或英文。 --- diff --git a/user_manual/user_manual.typ b/user_manual/user_manual.typ index a05498d4..fbe594a1 100644 --- a/user_manual/user_manual.typ +++ b/user_manual/user_manual.typ @@ -1419,21 +1419,24 @@ LaTeXSnipperOffice_3.1.0_amd64.exe /dir="<安装目录>" - *公式组:* 行内公式、行间公式、带编号公式、截图识别 - *编辑组:* 加载所选、删除所选 - - *转换组:* 转为 OLE、转为 Word + - *转换组:* 转为 MathType、转为 OLE、转为 Word - *公式解析组:* 解析所选、解析全文 - - *引用组:* 插入引用 + - *交叉引用组:* 插入引用 - *编号组:* 添加编号、重编号 - - *边界组:* 插入章分隔符、插入节边界 - - *格式化组:* 格式化所选、格式化全部 + - *编号边界组:* 章分隔符、节分隔符 + - *公式样式组:* 格式化所选、格式化全文 - *工具组:* 状态窗格、设置、帮助 ] -== 插入 OMML 公式 +== 公式插入方式 -Word 加载项通过本地 OMML 转换引擎将 LaTeX 转换为 Word 原生 OMML(Office Math Markup Language)公式。OMML 公式是 Word 原生格式,可以像手动插入的公式一样编辑。 +Word 支持插件 OLE 对象和 Word 原生 OMML(Office Math Markup Language)公式,在“设置 → 公式插入方式”中选择: + +- *OLE 对象(默认):* 本地渲染的矢量公式对象,双击或“加载所选”可打开插件编辑器。复制到另一份文档后仍可加载并独立编辑。 +- *Word 原生公式:* 可直接使用 Word 的公式工具编辑,也可通过插件加载、更新和转换。 #info-block("OMML 公式", [ - OMML 公式是 Word 原生格式,卸载加载项后公式仍然可以正常显示和编辑。加载项插入的公式会附带 LaTeXSnipper 管理元数据(LaTeX 源码、显示模式、编号模式),用于后续加载、更新和重编号。普通 Word 公式没有 LaTeXSnipper 元数据,插件不会猜测其 LaTeX 来源。 + OMML 公式是 Word 原生格式,卸载加载项后公式仍然可以正常显示和编辑。加载项插入的公式会附带 LaTeXSnipper 管理元数据(公式源码、显示模式、编号模式),用于后续加载、更新和重编号。普通 Word 公式没有 LaTeXSnipper 元数据,插件不会猜测其 LaTeX 来源。 ]) == 三种插入模式 @@ -1465,9 +1468,9 @@ Word 加载项通过本地 OMML 转换引擎将 LaTeX 转换为 Word 原生 OMML )[ #set par(spacing: 0.25em) - - #text(weight: "bold")[加载所选:] 将公式的完整 LaTeX 源码加载到公式编辑器,可以修改后更新原公式 + - #text(weight: "bold")[加载所选:] 将公式保存的源码加载到公式编辑器,可以修改后更新原公式 - #text(weight: "bold")[更新:] 在编辑器中修改后点击更新,新公式会替换原公式,保留元数据 - - #text(weight: "bold")[删除所选:] 删除公式及对应的元数据 + - #text(weight: "bold")[删除所选:] 删除选中的插件公式;删除带编号公式时同时删除其编号段落 ] == 添加编号与重编号 @@ -1481,7 +1484,7 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 #set par(spacing: 0.25em) - *添加编号:* 为选中的行间公式添加自动编号。如果公式已有编号,不会重复添加 - - *重编号全部:* 按文档顺序更新所有自动编号公式的 SEQ 字段、编号书签和引用字段,不重新渲染公式内容;缺失元数据或编号字段的异常公式会被跳过并计数 + - *重编号:* 按文档顺序更新所有自动编号公式的 SEQ 字段、编号书签和引用字段,不重新渲染公式内容;缺失元数据或编号字段的异常公式会被跳过并计数 - *编号位置:* 设置中可选择右编号或左编号;该设置只影响新插入的编号公式和之后被自动编号的普通公式,不改变已有公式的编号属性 ] @@ -1496,7 +1499,7 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 #set par(spacing: 0.25em) - *插入章分隔符:* 标记新章开始,后续自动编号重置为当前章号 - - *插入节边界:* 标记新节开始,后续自动编号追加当前节号 + - *插入节分隔符:* 标记新节开始,后续自动编号追加当前节号 - *包含章/节编号:* 设置中可控制编号是否包含章或节的层级部分 - *分隔符样式:* 层级分隔符可在设置中选择(`.`、`-`、`·`、`:`、`/`) - *隐藏分隔符:* 设置中可隐藏插入文档中的分隔符标记 @@ -1518,7 +1521,7 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 == 公式转换 -支持在已有的管理公式之间转换格式,也支持把选中的 Word 原生 OMML 公式显式转换为 LaTeXSnipper OLE: +选中公式后,通过功能区转换组选择目标格式: #block( inset: 12pt, @@ -1526,11 +1529,15 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 )[ #set par(spacing: 0.25em) - - *转为 OLE:* 将选中的 OMML 管理公式转为 OLE 公式对象(支持更多渲染选项) + - *转为 MathType:* 将普通插件 OLE 公式转为 MathType 原生对象;带编号公式暂不支持 + - *转为 OLE:* 将插件 OMML、Word 原生 OMML 或 MathType 公式转为插件 OLE 对象 - *转为 Word:* 将 OLE 公式转为 Word 原生 OMML 公式(可像 Word 公式一样直接编辑) - - *Word 原生 OMML 转 OLE:* 选中非 LaTeXSnipper 托管的 Word 原生公式后执行"转为 OLE",插件会读取原生 OMML,转换为 MathML,并把 MathML 作为源码存入 OLE 元数据。这类公式可加载和继续转换,但不应执行"格式化所选",因为格式化链路按 LaTeX 宏重写源码,MathML 源码会被破坏。 ] +MathType 双向转换无需安装 MathType;双击编辑 MathType 对象需要安装。转为 MathType 不保留原插件源码与字体快照,转回 OLE 根据对象当前内容生成。 + +Word 原生 OMML 或 MathType 转为 OLE 后保存的是 MathML 源码,可加载、更新、格式化及继续转换,不会还原为原始 LaTeX。 + == 格式重置 #block( @@ -1539,10 +1546,12 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 )[ #set par(spacing: 0.25em) - - *格式化所选:* 将选中的公式恢复为默认字体、颜色和自然尺寸 - - *格式化全部:* 将所有尺寸被修改过的公式恢复为自然尺寸 + - *格式化所选:* 恢复所选插件公式的默认样式和自然大小 + - *格式化全文:* 恢复全文插件公式的默认样式和自然大小 ] +两者均使用当前设置中的公式默认属性,只处理样式或尺寸需要恢复的插件公式。 + == 性能与复杂度参考 下面的复杂度用于估算长文档中的响应时间。设 `n` 为参与操作的公式数,`m` 为文档中 LaTeXSnipper 管理公式总数,`c` 为文档中的内容控件、图片/OLE 对象或形状数量,`f` 为 Word 域数量,`p` 为当前段落内公式对象数量,`s` 为单个公式源码或 OMML/MathML XML 的长度,`r` 为一次 MathJax 渲染或图片/OLE 生成成本。 @@ -1552,16 +1561,16 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 inset: 5pt, stroke: 0.5pt + gray, align: horizon, - [操作], [Word 复杂度], [PowerPoint 复杂度], [主要耗时来源], + table.header([操作], [Word 复杂度], [PowerPoint 复杂度], [主要耗时来源]), [插入 OLE 公式], [`O(s + r + ole)`], [`O(s + r + ole)`], [MathJax 渲染、EMF/OLE 对象创建、Office COM 插入], [插入 Word OMML], [`O(s + r + xsl + xml)`], [不适用], [MathJax 转 MathML、MML2OMML XSL、Word `InsertXML`], [插入 PowerPoint PNG], [不适用], [`O(s + r + png)`], [MathJax 渲染、SVG/PNG 光栅化、形状插入], [插入带编号公式], [`O(s + r + ole/xml + c)`], [不适用], [公式插入、SEQ 编号域、书签、居中/右对齐制表位布局], [加载所选], [`O(c)` 最坏,通常接近 `O(1)`], [`O(c)` 最坏,通常接近 `O(1)`], [从选区附近定位元数据,必要时扫描对象], [更新公式], [`O(s + r + replace)`], [`O(s + r + replace)`], [重新渲染和替换原对象;OLE 会保留用户缩放], - [删除所选], [`O(c + f + n log n)`], [`O(c + n)`], [定位并按文档位置删除公式、边界控件、引用域或引用占位符], + [删除所选], [`O(c + n log n)`], [`O(c + n)`], [收集所选插件公式;Word 按文档位置倒序删除,带编号公式同时删除编号段落], [添加编号], [`O(s + r + replace + before)`], [不适用], [将普通行间公式改为编号布局,只扫描当前位置前的编号时间线], - [重编号全部], [`O(c + f + m log m + refs)`], [不适用], [收集并排序自动编号公式和章/节边界,一次建立 SEQ 字段映射,更新编号书签和 LaTeXSnipper 引用域], + [重编号], [`O(c + f + m log m + refs)`], [不适用], [收集并排序自动编号公式和章/节边界,一次建立 SEQ 字段映射,更新编号书签和 LaTeXSnipper 引用域], [插入章/节分隔符], [`O(c + m log m + refs)`], [不适用], [插入边界控件后立即重编号全部自动编号公式], [插入交叉引用], [`O(1)` 插入占位,选择目标时最坏 `O(c + f)`], [不适用], [查找目标公式、定位编号书签并创建 Word REF 域], [公式解析], [`O(c + n * (s + r + replace))`], [不适用], [安全范围扫描、公式渲染和 Office 对象替换], @@ -1569,7 +1578,7 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 [Word 原生 OMath 转 OLE], [`O(n * (s + xsl + r + ole))`], [不适用], [OMML2MML XSL、MathJax 按 MathML 渲染、OLE 创建], [转 Word OMML / PNG], [`O(n * (s + r + xsl + xml))`], [`O(n * (s + r + png))`], [OLE/PNG 重新生成和 Office 对象替换], [格式化所选], [`O(n * (s + r + replace) + p)`], [`O(n * (s + r + replace))`], [只有属性不同或尺寸被改过时才重新渲染/重置;Word 行内基线只扫描当前段落], - [格式化全部], [`O(m)`], [`O(m)`], [扫描全文公式并恢复被用户缩放过的自然尺寸], + [格式化全文], [`O(m * (s + r + replace) + p)`], [`O(m * (s + r + replace))`], [复用所选格式化流程,按当前默认样式重绘需要恢复的公式并重置自然大小], [截图识别], [`O(ocr + s + r)`], [`O(ocr + s + r)`], [桌面端 OCR 和后续公式插入], ) @@ -1577,14 +1586,14 @@ Word 支持将未编号的 LaTeXSnipper 公式转换为自动编号公式,并 inset: 12pt, width: 100%, )[ - 多选转换、公式解析和多选格式化所选会分批执行并更新状态窗格;Word 每批使用短 undo record。长公式或大量公式时,主要耗时仍来自 `r`(MathJax 渲染/图片生成)和 `ole/xml/png`(Office COM 插入与对象替换)。建议长文档批量处理时分段执行并保存文档。 + 转换、公式解析和格式化会分批执行并更新状态窗格;单项失败会报告具体错误,并继续处理其他项,结束后显示处理统计。重编号会跳过编号信息不完整的公式并计数。长公式或大量公式时,主要耗时仍来自 `r`(MathJax 渲染/图片生成)和 `ole/xml/png`(Office COM 插入与对象替换)。建议长文档批量处理时分段执行并保存文档。 - Word 原生 OMath 转 OLE 保存的是 MathML 源码,不是 LaTeX 源码;它用于保真转换,不进入 LaTeX 格式化链路。原生行内公式由 Word 自己维护外层公式输入框,少数单选场景下外层占位框可能由 Word 保留;继续转为 LaTeXSnipper 管理的 OMML 后会由插件接管并清理为插件格式。 + 格式化保留公式源码,通过当前默认属性重新渲染;LaTeX 和 MathML 源码均可处理。原生行内公式由 Word 自己维护外层公式输入框,少数单选场景下外层占位框可能由 Word 保留;继续转为 LaTeXSnipper 管理的 OMML 后会由插件接管并清理为插件格式。 ] == 截图识别 -点击"截图识别"后,加载项等待 LaTeXSnipper 的下一次识别结果,但不会主动打开截图界面。用户仍需在桌面端自行发起识别;桌面端保持正常显示结果,同时将结果副本自动填入 Office 公式编辑器。若需取消,可点击功能区"取消识别"按钮或在等待过程中再次点击"截图识别"按钮。 +打开 LaTeXSnipper 桌面端,并启用设置中的“自动化接口”。点击插件“截图识别”后,使用桌面端全局快捷键框选公式;识别结果会填入 Office 公式编辑器。等待期间再次点击“截图识别”可取消。普通公式编辑和插入无需启动桌面端。 #pagebreak() @@ -1603,30 +1612,21 @@ PowerPoint 的 LaTeXSnipper 标签页结构与 Word 类似,但功能更精简 )[ #set par(spacing: 0.25em) - - *公式组:* 插入公式、截图识别 + - *公式组:* 插入公式、截图识别(识别流程与 Word 相同) - *编辑组:* 加载所选、删除所选 - - *转换组:* 转为 OLE、转为 PNG - - *格式化组:* 格式化所选、格式化全文 + - *转换组:* 转为 MathType、转为 OLE、转为 PNG + - *公式样式组:* 格式化所选、格式化全文 - *工具组:* 状态窗格、设置、帮助 ] -== 插入公式图片 +== 插入公式 -PowerPoint 加载项支持两种渲染方式: +光标位于文本框文字中时,点击“插入公式”生成随文字排版的 PowerPoint 原生公式,可用 PowerPoint 的公式工具编辑。 -#block( - inset: 12pt, - width: 100%, -)[ - #set par(spacing: 0.25em) - - - *OLE 公式对象:* 将 LaTeX 交由本机 OLE 公式对象处理器渲染,以 OLE 对象形式嵌入幻灯片,保留原始版式 - - *高 DPI PNG 图片:* 由内置 MathJax 渲染为 SVG 后光栅化为高 DPI PNG 图片插入幻灯片 -] +其他位置按设置插入居中的独立 OLE 对象或高 DPI PNG 图片;OLE 支持双击编辑,PNG 可通过“加载所选”修改。 #info-block("与 Word 的区别", [ - - 没有行内/行间区别——PowerPoint 幻灯片上的公式始终是独立对象 - - 不支持自动编号和重编号(这些是 Word 文档专属功能) + 加载、删除、转换和批量格式化面向插件插入的独立对象;文本内原生公式由 PowerPoint 管理。PowerPoint 不支持插件自动编号和重编号。 ]) == 加载与删除 @@ -1637,10 +1637,12 @@ PowerPoint 加载项支持两种渲染方式: )[ #set par(spacing: 0.25em) - - *加载所选:* 选中幻灯片上的公式图片,点击加载所选,公式的 LaTeX 源码会载入独立公式编辑器。修改后重新插入时,新图片会替换原位置旧图片 - - *删除所选:* 删除选中的公式图片及元数据 + - *加载所选:* 将所选插件 OLE 或 PNG 公式载入编辑器;修改后点击“更新”,尽量保留原位置和手动缩放 + - *删除所选:* 删除选中的插件 OLE 或 PNG 公式 ] +OLE 公式复制到另一份演示文稿后仍可加载并独立编辑。 + == 公式转换与格式重置 #block( @@ -1649,20 +1651,19 @@ PowerPoint 加载项支持两种渲染方式: )[ #set par(spacing: 0.25em) - - *转为 OLE:* 将选中 PNG 格式的公式图片转换为 OLE 公式对象 + - *转为 MathType:* 将所选插件 OLE 公式转为 MathType 原生对象 + - *转为 OLE:* 将插件 PNG 或 MathType 公式转为插件 OLE 对象 - *转为 PNG:* 将 OLE 公式转换为 PNG 图片 - - *格式化所选:* 将选中的公式恢复为默认字体、颜色和自然尺寸 - - *格式化全文:* 将所有尺寸被修改过的公式恢复为自然尺寸 + - *格式化所选:* 恢复所选插件公式的默认样式和自然大小 + - *格式化全文:* 恢复全文插件公式的默认样式和自然大小 ] +MathType 双向转换无需安装 MathType;双击编辑 MathType 对象需要安装。转为 MathType 不保留原插件源码与字体快照,转回 OLE 根据对象当前内容生成。 + #tip-block("提示", [ 如果你在幻灯片上找不到公式图片,可以检查形状的替代文本(Alt Text)。所有 LaTeXSnipper 公式图片的替代文本均以 "LaTeXSnipper formula" 开头。 ]) -== 截图识别 - -与 Word 相同的截图 OCR 流程。识别结果会自动填入公式编辑器,可直接插入或修改后插入。 - #pagebreak() // ═══════════════════════════════════════════ @@ -1689,7 +1690,7 @@ PowerPoint 加载项支持两种渲染方式: == 独立公式编辑器 -通过功能区"行内公式""行间公式""带编号公式"按钮或"加载所选"打开的独立公式编辑器窗口,搭载 MathLive 可视化公式引擎和完整数学符号系统。 +通过 Word 功能区的“行内公式”“行间公式”“带编号公式”、PowerPoint 的“插入公式”或“加载所选”打开独立公式编辑器。 #block( inset: 12pt, @@ -1699,7 +1700,7 @@ PowerPoint 加载项支持两种渲染方式: - *数学编辑区:* 全尺寸 MathLive 所见即所得编辑区,支持键盘快捷键(如 `+` `_` `/` `sqrt` 等标准 LaTeX 缩写自动识别)、光标导航和即时预览 - *LaTeX 源码区:* 编辑区下方的 LaTeX 源码输入区,与可视化编辑区实时双向同步,可直接粘贴或编辑 LaTeX 代码 - - *符号面板(右侧):* 内置 *18 分类、2121 个数学符号与公式模板*,覆盖从基础算术到前沿研究的全部符号体系: + - *符号面板(左侧):* 内置 *18 分类、2121 个数学符号与公式模板*,覆盖从基础算术到前沿研究的全部符号体系: #set par(spacing: 0.3em) - *基础(4 类 171 条):* 希腊字母(含变体和希伯来字母)、结构模板(矩阵/根式/行列式等,支持自选行列数)、分隔符(含大尺寸变体和装饰框线)、集合与逻辑符号 - *代数(1 类 174 条):* 线性代数(矩阵运算/特征系统)/抽象代数(群环域/同调代数/李理论/代数几何)/算子代数(C\* 代数/von Neumann/K-理论) @@ -1715,7 +1716,8 @@ PowerPoint 加载项支持两种渲染方式: - *其他(1 类 56 条):* 杂项符号 - *标签内搜索:* 每个符号面板顶部设有搜索框,支持按中文名称或 LaTeX 源码实时过滤当前标签内的符号 - *全局搜索:* 标签列顶部设有全局搜索框,输入关键词后跨所有 18 个标签检索,按组归类展示结果 - - *底部工具栏:* 基础运算(计算/化简/数值化/求解/展开/因式分解)、多行排版布局、快捷插入片段、示例载入、复制(LaTeX / MathJSON) + - *样式工具栏:* 调整当前公式的符号字体、数字字体、汉字字体、字形、字号和颜色;“最终预览”显示实际渲染结果,小字号仅在屏幕上放大,插入仍使用选定字号 + - *常用公式:* 右键符号磁贴可加入或移出“常用”,工具栏星形按钮可收藏当前公式;升级或重装后保留收藏 - *插入 / 更新:* 底部按钮根据当前模式显示"插入"或"更新" - *取消:* 关闭编辑器,不应用更改 ] @@ -1726,6 +1728,8 @@ PowerPoint 加载项支持两种渲染方式: - `Esc`:只收回 MathLive 虚拟键盘,不关闭编辑器 ]) +源码区按 `Tab` 在光标处缩进,补全列表按 `Enter` 接受。源码含有可视化编辑器无法原样保留的写法时,上方只显示参考结果;可继续编辑源码,或点击“用上方结果替换源码(可撤销)”。 + == 平台界面差异 Word 侧边栏包含行间公式、自动编号、自定义编号输入框等 Word 专属控件。PowerPoint 侧边栏不包含这些选项,界面更简洁。 @@ -1747,16 +1751,23 @@ Word 侧边栏包含行间公式、自动编号、自定义编号输入框等 Wo )[ #set par(spacing: 0.25em) - - *公式插入方式:* OLE 对象(默认)或 Word OMML(本地转换) + - *Word 公式插入方式:* OLE 对象(默认)或 Word 原生 OMML 公式 + - *PowerPoint 公式插入方式:* OLE 对象(默认)或 PNG 图片;文本框内插入时使用 PowerPoint 原生公式 - *编号位置:* 右编号(默认)或左编号 - *外框:* 无、圆括号(())、方括号([])、花括号({}) - *层级分隔符:* `.`、`-`、`·`、`:`、`/` - *包含章编号 / 包含节编号:* 控制编号是否包含层级部分 - *隐藏章分隔符 / 隐藏节分隔符:* 控制文档中是否显示分隔符标记 - - *字体颜色:* 拾色器选择新插入公式的默认字体颜色,支持恢复为黑色或白色 - - *默认字体:* TeX 原生字体、罗马正体、粗体、斜体、黑板粗体、花体、哥特体、无衬线、等宽等论文常用数学字体 + - *符号字体:* TeX 或 STIX2 字体方案,用于数学字母、符号和公式结构 + - *数字字体:* 指定普通数字的系统字体,或跟随符号字体 + - *汉字字体:* 指定公式内汉字使用的字体 + - *默认字形:* 自动、正体、斜体、粗体、黑板粗体、花体、哥特体、无衬线、等宽等数学样式,与字体方案分别选择 + - *字号与颜色:* 新公式和格式化命令使用的默认字号、颜色;“新建时跟随文字字号”优先采用光标或选区文字字号,无有效字号时使用固定值 + - *JSON 导入 / 导出:* 保存或应用当前 Word / PowerPoint 的公式默认属性 ] +编号位置、外框、层级与分隔符设置仅适用于 Word。编辑器中的临时选择仅用于当前公式,修改已有公式时沿用其保存的属性。升级、重装和卸载会保留公式默认属性、插入偏好、Word 编号布局及编辑器收藏。 + *编辑器键盘行为* #table( @@ -1764,16 +1775,19 @@ Word 侧边栏包含行间公式、自动编号、自定义编号输入框等 Wo inset: 8pt, stroke: 0.5pt + rgb("#BDBDBD"), [*快捷键*], [*功能*], - [`Enter`], [新建数学行], - [`Shift + Enter`], [插入或更新当前公式], - [`Ctrl + F`], [插入分式], - [`Ctrl + R`], [插入根号], - [`Ctrl + H`], [插入上标], - [`Ctrl + L`], [插入下标], - [`Ctrl + J`], [插入上下标], + [可视化区 `Enter`], [新建数学行], + [可视化区 `Shift + Enter`], [插入或更新当前公式], + [可视化区 `Ctrl + F`], [插入分式], + [可视化区 `Ctrl + R`], [插入根号], + [可视化区 `Ctrl + H`], [插入上标], + [可视化区 `Ctrl + L`], [插入下标], + [可视化区 `Ctrl + J`], [插入上下标], + [源码区 `Tab`], [在光标处缩进], + [源码补全列表 `Enter`], [接受当前补全项], [`Esc`], [收回 MathLive 虚拟键盘], ) +#block(breakable: false)[ == 帮助窗口 通过功能区"帮助"按钮打开,根据当前宿主(Word 或 PowerPoint)显示对应内容: @@ -1784,11 +1798,13 @@ Word 侧边栏包含行间公式、自动编号、自定义编号输入框等 Wo )[ #set par(spacing: 0.25em) - - 系统要求详情 - - Word / PowerPoint 功能列表 - - 功能区按钮说明 - - 插件架构介绍 - - 使用边界说明 + - 开始使用与公式编辑 + - 设置、字体与预览 + - 当前宿主的插入、转换、格式化等常用操作 + - 社区与支持 +] + +宽窗口中目录固定在左侧,点击条目可跳转到对应章节;窄窗口中目录位于正文上方。页面随 Office 界面语言显示中文或英文。 ] #pagebreak()