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..fad8307a 100644 --- a/docs/office_plugin_formula_workflows.md +++ b/docs/office_plugin_formula_workflows.md @@ -6,9 +6,10 @@ - 新建和格式化从当前设置产生样式快照;普通编辑、重编号及转换保留公式已有快照。 - 局部字体与颜色命令优先于默认样式。默认样式在 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 项字形选项。字体列表顺序固定,当前选择不改变顺序。 ## 用户偏好与升级保留 @@ -20,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` 时删除该文件并重建默认值,不执行配置迁移;此时注册表中的插入与编号偏好和编辑器常用内容仍保留。 @@ -42,62 +43,50 @@ `Typography` 保存完整快照;文档重绘不依赖本机预设。只读取 schema 3,损坏或不支持的版本在入口拒绝,不迁移或删除原对象。 +## OLE 内容与宿主实例 + +Word OLE 识别先验证 `OLEFormat.ProgID` 为本产品对象,再通过 `OleFormulaContent` 的 `IDispatch.GetPayload` 读取 schema 3 内容,恢复完整源码、样式、显示/编号信息与自然尺寸。损坏内容或 COM 读取失败保留具体错误,不当作普通选区忽略。 + +原生对象的 `Payload` / `PresentationEmf` 随复制和文档保存。创建时同步写入存储;Word 的 `UpdatePayload` 校验内容并准备新预览后才提交,失败保留原内容和预览。重编号通过同一接口更新相关内容字段。Word 宿主变量不重复保存 OLE 的源码、样式或自然尺寸。 + +PowerPoint 保留已验证的完整 shape tags 读取路径;复制后的 `OLEFormat.Object` 激活返回 `REGDB_E_CLASSNOTREG` 的具体原因尚未查明,不能把这一接口作为 PowerPoint 内容读取的前提。PowerPoint 编辑、格式化与转换统一读取 tags 中的 `FormulaMetadata`,更新时由同一快照生成对象 Payload 和 tags;不使用当前设置替代既有属性,不增加内部读取失败后的第二条兜底分支。 + +同文档副本和跨文档副本在读取有效内容后建立新的实例身份。关闭来源文档后仍能加载,编辑一个副本不会更新其他副本。编辑会话固定文档和具体对象,切换活动文档不改变保存目标。 + ## Word 元数据存储 -Word 中公式对象自身只保存短标签: +对象使用短标签 `latexsnipper-eq-{equationId}|{revision}`;OMML 放在 content control `Tag`,OLE 放在 inline shape `AlternativeText`。文档变量名为 `LS.E.{equationId}.{revision}`,每次绑定生成新的 10 位 revision。 -```text -latexsnipper-eq-{equationId}|{revision} -``` +- **OLE:**变量只保存 schema、文档 ID 和公式 ID。完整内容与自然尺寸来自 OLE Payload。复制导致变量或标签缺失时,验证对象内容后重新绑定当前实例。 +- **OMML:**变量保存完整 `FormulaMetadata`。加载仍需要控制项短标签与对应文档变量;跨文档复制后的源码精确保留本轮未接入,不能用近似 LaTeX 覆盖原始内容。 -短标签写入: +OMML 的自然字号取 `Typography.FontSizePoints`。 -- OMML 公式:content control 的 `Tag` -- OLE 公式:inline shape 的 `AlternativeText` +## PowerPoint 元数据存储 -完整 JSON 元数据写入 Word `Document.Variables`: +OLE / PNG 的 shape tags 保存 `LaTeXSnipperDocumentId`、`LaTeXSnipperEquationId`、`LaTeXSnipperSchemaVersion`、`LaTeXSnipperRenderEngine`、显示模式和自然宽高;PNG 另保存临时图片路径。完整源码与样式 JSON 按 UTF-8 十六进制分块保存,前缀分别为 `LaTeXSnipperLatex` / `LaTeXSnipperTypography`,含 `Bytes`、`Chunks` 和 `0000` 起的分块。每块 200 个字符,读取校验长度、块数和十六进制内容。 -```text -LS.E.{equationId}.{revision} -``` +OLE / PNG 均要求完整 tags;复制时保留这些数据并重新绑定实例身份。丢弃 tags 的粘贴方式不能恢复原始源码。临时图片路径只用于本机文件清理。 -这样可以避开 Word content control tag 的 64 字符限制。每次保存都会生成新的 10 位 revision,并把短标签指向新 revision。加载时必须同时拿到 equationId 和 revision,然后从 `Document.Variables` 读取完整 JSON。 +## 双击编辑 -OLE 公式还会在同一份 JSON 中保存自然宽高: +加载项在独立消息线程检测系统双击手势,限定前台 Office 进程;回调只投递点击位置,随后在 Office UI 线程确认自己的 OLE 对象并复用“加载所选”流程。Word 通过范围命中和对象屏幕边界定位,PowerPoint 在普通视图中等待 Office 完成对象选中,再通过 `PointsToScreenPixelsX/Y` 核对实际点击是否落在所选 OLE 对象的边界内;不使用不存在的 `DocumentWindow.HWND`,也不依赖会对 OLE 返回空的 `RangeFromPoint`。普通文本、其他 OLE 与编辑器内部点击不打开公式编辑器。 -| 字段 | 用途 | -| --- | --- | -| `naturalWidthPoints` | OLE 初始自然宽度 | -| `naturalHeightPoints` | OLE 初始自然高度 | +监听启动失败显示具体错误,其他插件功能继续可用;卸载释放钩子和待处理请求。对象内容读取与保存不依赖双击监听。 -这些字段用于判断用户是否手动拉伸公式,并支持格式化时恢复自然尺寸。 +## 转为 MathType -OMML 的自然字号直接取 `Typography.FontSizePoints`,不另建文档变量。 +独立的“转为 MathType”入口处理所选普通 LaTeXSnipper OLE;Word 带编号公式暂不纳入。转换前固定选区目标,从后向前执行,沿用命令互斥、取消、超时及成功/失败/跳过统计。单项失败保留原公式并继续其余项。 -## PowerPoint 元数据存储 +Word 和 PowerPoint 共用 MathJax 样式链路生成 MathML 与 EMF,编码 MTEF v5、字号及颜色,回读验证后写入标准 `Equation.DSMT4` CFB,并生成 WMF 矢量预览。双向转换均不激活 MathType、不调用 SDK。尚须在未安装 MathType 的干净 Office 环境完成隔离验收。 -PowerPoint 公式以 shape 为单位保存元数据。短字段写入 shape tags: +Word 的 WMF 预览、VML 显示框和原始尺寸使用同一组原 OLE 显示宽高。适配器在 Office STA 上用 `Range.InsertXML` 插入最小 Flat OPC,持有插入范围及返回的新对象,验证内容、数量和尺寸后删除原对象;失败或取消删除本项插入范围。 -| Tag | 用途 | -| --- | --- | -| `LaTeXSnipperDocumentId` | 当前演示文稿标识 | -| `LaTeXSnipperEquationId` | 公式 ID | -| `LaTeXSnipperDisplayMode` | 显示模式 | -| `LaTeXSnipperSchemaVersion` | schema | -| `LaTeXSnipperRenderEngine` | 渲染类型 | -| `LaTeXSnipperNaturalWidthPoints` | 自然宽度 | -| `LaTeXSnipperNaturalHeightPoints` | 自然高度 | -| `LaTeXSnipperImagePath` | PNG 插入时的临时图片路径 | - -完整 LaTeX 源码使用 UTF-8 转十六进制后分块存储: - -| Tag | 用途 | -| --- | --- | -| `LaTeXSnipperLatexBytes` | 原始 UTF-8 字节数 | -| `LaTeXSnipperLatexChunks` | 分块数量 | -| `LaTeXSnipperLatex0000` 起 | 十六进制源码分块 | +PowerPoint 将 CFB 与预览封装到单页 PPTX,在 Office STA 上打开隐藏的临时演示文稿,将对象复制到捕获的目标页并恢复原位置。原显示宽高由包内几何信息直接保留,同时保持原层次;验证对象身份、当前原生内容和几何后删除原公式,失败或取消移除新对象。复制过程暂存并恢复剪贴板,临时演示文稿随后关闭并清理。 + +“转为 OLE”接收 MathType 原生对象及混合选择。Word 从对象对应的 Flat OPC/CFB 读取当前 `Equation Native`,PowerPoint 从当前演示文稿的临时保存副本按页序和 shape ID 定位 CFB。两者解码当前 MathML 与字号,使用 MathJax 渲染并持久化到插件 OLE。PowerPoint 保留实例显示宽高、位置和层次;Word 沿用高度和等比缩放语义。新 OLE 唯一源码为当前 MathML。 -每块长度为 200 个十六进制字符,并校验字节数、分块数量和十六进制解析结果。样式 JSON 使用同一编码与分块实现,前缀为 `LaTeXSnipperTypography`,保存 `Bytes`、`Chunks` 和 `0000` 起的分块。这样同时保留长源码、中文字体名称和字体名称的大小写。 +转换后的对象由 MathType 原生内容管理;用户双击原生编辑仍须安装 MathType。默认字形、可表达的结构与样式是验收范围,任意数学字体方案不承诺字形完全相同。不支持的结构或局部字号会明确报错并保留原公式。 ## 设置项影响边界 @@ -135,9 +124,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 +137,7 @@ PowerPoint 没有 Word 编号、引用、章/节分隔符链路。 ### 插入带编号公式 1. Ribbon 调用 `InsertNumberedAsync`。 -2. 打开编辑器时只记录编号意图,不把默认字体/颜色写入编辑器草稿。 +2. 打开编辑器时记录编号意图,并使用同一套全局默认样式与字号解析。 3. 提交后生成 display 公式,并设置 `NumberingMode`。 4. 自动编号公式插入时按当前位置前的编号时间线计算当前章/节前缀和是否需要重置 `SEQ`,只写入当前公式的编号字段。 5. 段落使用制表位布局:公式在正文区域中线对齐,编号按设置位于左侧或右侧。 @@ -216,6 +205,8 @@ REF LaTeXSnipperEq_{equationId} \h 章/节分隔符只服务插件自动编号,不绑定 Word 标题级别,也不改变 Word 文档大纲。 +隐藏使用 Word 的 `Font.Hidden`。如果 Word 开启“隐藏文字”或“显示所有格式标记”,隐藏的分隔符仍会显示;可在“文件 → 选项 → 显示 → 始终在屏幕上显示这些格式标记”中关闭这两项。 + ### 插入引用 插入引用的链路: @@ -234,7 +225,7 @@ REF LaTeXSnipperEq_{equationId} \h 加载所选只读取被选中的公式对象: - OMML:读取 content control `Tag`,再读取 `Document.Variables`。 -- OLE:读取 inline shape `AlternativeText`,再读取 `Document.Variables`。 +- OLE:验证类身份并读取对象 Payload;inline shape `AlternativeText` 与文档变量只承担实例绑定。 加载后: @@ -245,19 +236,7 @@ REF LaTeXSnipperEq_{equationId} \h ## Word 删除所选 -删除所选支持: - -- 选中的托管公式。 -- 选区内的 OLE 公式。 -- 选中的章/节编号边界控件。 -- 选中的 REF 引用字段。 -- 当前待完成的引用占位符。 - -删除时先收集目标,再按文档位置倒序删除,避免前面的删除动作改变后续范围。删除操作在一个 Word undo record 内执行。 - -当前不支持“只选中编号数字就删除对应公式”。这是有意收窄后的边界,避免编号字段和公式对象之间产生不稳定的反向选择关系。 - -删除引用字段只删除引用本身,不删除目标公式。删除章/节分隔符只删除边界控件;如果需要刷新后续自动编号,需要再执行“重编号”。 +删除所选只处理选中的插件 OMML 和 OLE 公式。先收集公式目标,再按文档位置倒序删除,操作在同一个 Word undo record 内执行。 ## Word 转换链路 @@ -387,7 +366,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。 @@ -397,11 +376,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 418f4bc7..69a8ae8d 100644 --- a/office_plugin/README.md +++ b/office_plugin/README.md @@ -24,29 +24,34 @@ 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 - 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 +- 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 +- 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 -- Status task pane with connection test and formula preview +- Status task pane with connection test and shared visual/source formula editing ## Project Layout Desktop and Office share pinned MathJax 4.1.3 with independent resource profiles and asynchronous conversion. See [runtime maintenance and verification](../tools/mathjax/README.md) and the [current formula workflows and metadata](../docs/office_plugin_formula_workflows.md). +Word and PowerPoint convert between LaTeXSnipper OLE and native MathType objects without activating MathType or calling its SDK. PowerPoint preserves the original display width, height, position and layer directly. Double-click editing of native MathType objects requires MathType. Conversion has not yet been verified in a clean Office environment without MathType. + | Path | Role | |---|---| | `src/LaTeXSnipper.OfficePlugin.Abstractions` | Stable contracts shared by hosts, renderer, editor, and automation client | @@ -58,7 +63,10 @@ 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 | +| `tests/OfficeE2E` | Optional checks against an installed MathType editing server; excluded from product assemblies | | `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 +75,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 +83,38 @@ 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 +# Include native MathType conversion and save/reopen checks: +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -IncludeMathType +# Word conversion checks only; native-edit verification additionally requires MathType: +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope Word -WordBackend Ole -WordMathTypeOnly +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope Word -WordBackend Ole -WordMathTypeOnly -WordMathTypeNativeEdit +# PowerPoint conversion checks only; native-edit verification additionally requires MathType: +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode MathType +office_plugin\tools\Test-OfficeTypographyE2E.ps1 -HostScope PowerPoint -PowerPointMode MathType -PowerPointMathTypeNativeEdit +# 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; 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..b12e0736 --- /dev/null +++ b/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.MathType.cs @@ -0,0 +1,213 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using LaTeXSnipper.OfficePlugin.Abstractions; +using LaTeXSnipper.OfficePlugin.Rendering; + +namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; + +public sealed partial class DynamicPowerPointApplicationAdapter +{ + private byte[]? mathTypeTemplate; + + 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 ReadMathTypeAsync(MathTypeFormulaTarget target, CancellationToken cancellationToken) + => _officeThread.InvokeAsync(() => + { + dynamic shape = FindMathTypeFormula(target); + byte[] native = ReadMathTypeNative(target.Document, target.ContainerIndex, Convert.ToInt32(shape.Id)); + return new MathTypeFormulaContent(MathTypeNativeEquation.ReadMathMl(native), MathTypeNativeEquation.ReadFontSizePoints(native)); + }, cancellationToken); + + public Task ReplaceMathTypeWithOleAsync(MathTypeFormulaTarget target, FormulaMetadata metadata, + OlePresentationResult presentation, CancellationToken cancellationToken) + => _officeThread.InvokeAsync(() => + { + dynamic original = FindMathTypeFormula(target); + dynamic? converted = null; + try + { + converted = CreateOleObjectAt(original.Parent, metadata, presentation, + Convert.ToSingle(original.Left), Convert.ToSingle(original.Top), Convert.ToSingle(original.Width), Convert.ToSingle(original.Height)); + PreserveMathTypeLayer(original, converted); + cancellationToken.ThrowIfCancellationRequested(); + original.Delete(); + } + catch + { + if (converted != null) TryDeleteShape(converted); + 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 Task ReplaceWithMathTypeAsync(PowerPointFormulaEditTarget target, byte[] compoundFile, + OlePresentationResult presentation, CancellationToken cancellationToken) + => _officeThread.InvokeAsync(() => + { + dynamic original = FindFormulaShapeById(target.Presentation, target.Metadata.Identity.EquationId); + if (!OleFormulaContent.IsFormula((object)original)) + throw new InvalidOperationException(PowerPointAddInText.Get("SelectedFormulaRequired")); + dynamic document = target.Presentation, slide = original.Parent; + float left = Convert.ToSingle(original.Left), top = Convert.ToSingle(original.Top); + float width = Convert.ToSingle(original.Width), height = Convert.ToSingle(original.Height); + float rotation = Convert.ToSingle(original.Rotation); + int shapeCount = Convert.ToInt32(slide.Shapes.Count); + byte[] expected = MathTypeCompoundFile.ReadNative(compoundFile); + byte[] package = MathTypePowerPointPackage.Create(GetMathTypeTemplate(), compoundFile, presentation, + left, top, width, height, rotation, + Convert.ToSingle(document.PageSetup.SlideWidth), Convert.ToSingle(document.PageSetup.SlideHeight)); + string path = MathTypeTemporaryPath(); + dynamic? staging = null, converted = null; + try + { + File.WriteAllBytes(path, package); + staging = _application.Presentations.Open(path, MsoTrue, MsoFalse, MsoFalse); + if (Convert.ToInt32(staging.Slides.Item(1).Shapes.Count) != 1) + throw new InvalidDataException("MathType insertion package did not produce one shape."); + object sourceShape = staging.Slides.Item(1).Shapes.Item(1); + PowerPointClipboard.WithSavedClipboard(() => + { + ((dynamic)sourceShape).Copy(); + dynamic pasted = slide.Shapes.Paste(); + if (Convert.ToInt32(pasted.Count) != 1) + { + pasted.Delete(); + throw new InvalidDataException("MathType insertion produced multiple shapes."); + } + converted = pasted.Item(1); + return true; + }); + staging.Close(); + staging = null; + File.Delete(path); + dynamic inserted = converted ?? throw new InvalidDataException("PowerPoint did not insert the MathType object."); + if (!MathTypeOleBridge.IsEquation((object)inserted)) + throw new InvalidDataException("PowerPoint did not preserve the native MathType class."); + // PowerPoint offsets pasted shapes even when their package has the correct coordinates. + inserted.Left = left; + inserted.Top = top; + if (Convert.ToInt32(slide.Shapes.Count) != shapeCount + 1 + || Math.Abs(Convert.ToSingle(inserted.Width) - width) > 0.1f + || Math.Abs(Convert.ToSingle(inserted.Height) - height) > 0.1f + || Math.Abs(Convert.ToSingle(inserted.Left) - left) > 0.1f + || Math.Abs(Convert.ToSingle(inserted.Top) - top) > 0.1f + || Math.Abs(Convert.ToSingle(inserted.Rotation) - rotation) > 0.1f) + throw new InvalidDataException($"PowerPoint did not preserve the equation geometry: " + + $"({left}, {top}, {width}, {height}, {rotation}) -> " + + $"({inserted.Left}, {inserted.Top}, {inserted.Width}, {inserted.Height}, {inserted.Rotation})."); + byte[] actual = ReadMathTypeNative(target.Presentation, Convert.ToInt32(slide.SlideIndex), Convert.ToInt32(inserted.Id)); + if (MathTypeNativeEquation.ReadMathMl(actual) != MathTypeNativeEquation.ReadMathMl(expected) + || Math.Abs(MathTypeNativeEquation.ReadFontSizePoints(actual) - MathTypeNativeEquation.ReadFontSizePoints(expected)) > 0.001) + throw new InvalidDataException("PowerPoint did not preserve the native MathType content."); + inserted.AlternativeText = string.Empty; + PreserveMathTypeLayer(original, inserted); + cancellationToken.ThrowIfCancellationRequested(); + original.Delete(); + } + catch + { + if (converted != null) TryDeleteShape(converted); + throw; + } + finally + { + if (staging != null) TryCom(() => staging.Close()); + File.Delete(path); + } + TryCom(() => converted.Select()); + return true; + }, cancellationToken); + + private byte[] GetMathTypeTemplate() + { + if (mathTypeTemplate != null) return mathTypeTemplate; + string path = MathTypeTemporaryPath(); + dynamic? blank = null; + try + { + blank = _application.Presentations.Add(MsoFalse); + blank.Slides.Add(1, 12); + blank.SaveCopyAs(path, 24); + mathTypeTemplate = File.ReadAllBytes(path); + return mathTypeTemplate; + } + finally + { + if (blank != null) TryCom(() => blank.Close()); + File.Delete(path); + } + } + + private static byte[] ReadMathTypeNative(object presentation, int slideIndex, int shapeId) + { + string path = MathTypeTemporaryPath(); + try + { + ((dynamic)presentation).SaveCopyAs(path, 24); + return MathTypePowerPointPackage.ReadNative(path, slideIndex, shapeId); + } + finally { File.Delete(path); } + } + + private static string MathTypeTemporaryPath() + => Path.Combine(Path.GetTempPath(), "latexsnipper-mathtype-" + Guid.NewGuid().ToString("N") + ".pptx"); + + private static void PreserveMathTypeLayer(dynamic original, dynamic converted) + { + int position = Convert.ToInt32(original.ZOrderPosition) + 1; + while (Convert.ToInt32(converted.ZOrderPosition) > position) converted.ZOrder(3); + while (Convert.ToInt32(converted.ZOrderPosition) < position) converted.ZOrder(2); + } + +} diff --git a/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs b/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs index 0d911943..28981af4 100644 --- a/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs +++ b/office_plugin/hosts/PowerPointAddIn/DynamicPowerPointApplicationAdapter.cs @@ -1,14 +1,14 @@ using System; using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; +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, IDisposable +public sealed partial class DynamicPowerPointApplicationAdapter : IPowerPointApplicationAdapter, IDisposable { private const int MsoFalse = 0; private const int MsoTrue = -1; @@ -16,49 +16,61 @@ public sealed class DynamicPowerPointApplicationAdapter : IPowerPointApplication private const float DefaultTopPoints = 96f; private const string OleFormulaProgId = "LaTeXSnipper.Formula"; - private readonly dynamic _application; - private readonly Control _officeThreadControl; - private readonly int _officeThreadId; + 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 线程上初始化。"); - _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 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(); @@ -93,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( @@ -132,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, @@ -159,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( @@ -198,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, @@ -290,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) @@ -351,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); } } @@ -418,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); } @@ -437,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) @@ -455,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) { @@ -463,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) @@ -503,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( @@ -521,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, @@ -664,129 +673,110 @@ private static string ReadTag(dynamic shape, string tagName) } } - private static float ReadRequiredFloatTag(dynamic shape, string tagName) + private dynamic GetActiveSlide() { - string value = ReadTag(shape, tagName); - if (!float.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float result) || result <= 0) + try + { + return _application.ActiveWindow.View.Slide; + } + catch (Exception exc) { - throw new InvalidOperationException(PowerPointAddInText.Get("SelectedFormulaMetadataMissing")); + throw new InvalidOperationException("请先打开一个 PowerPoint 幻灯片,再插入公式。", exc); } + } - return result; + 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 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 = PowerPointClipboard.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)); - 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(() => - { - try - { - cancellationToken.ThrowIfCancellationRequested(); - action(); - completion.TrySetResult(true); - } - catch (Exception error) { completion.TrySetException(error); } - })); - 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 - { - dynamic insertionRange = shape.TextFrame.TextRange.Characters(target.Start, 0); - 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..ac285bee 100644 --- a/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html +++ b/office_plugin/hosts/PowerPointAddIn/EditorAssets/help.html @@ -5,191 +5,265 @@ LaTeXSnipper Office 帮助
-
LATEXSNIPPER · OFFICE
+
LaTeXSnipperOffice 插件帮助

公式使用指南

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

-
-

开始使用

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

编辑公式

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

设置与字体

-

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

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

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

-
-
-

Word 中的常用操作

-

插入与编号

-

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

-

加载、转换和格式化

-

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

-

解析正文中的 LaTeX

-

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

-

重编号与引用

-

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

-

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

-
-
-

PowerPoint 中的常用操作

-

在文本框内把光标放在文字中间并点击“插入公式”,会得到随文字排版的 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. To change an existing formula, select it 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, 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.
  • -
-
-
-

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 list shows fonts found to contain Chinese glyphs.
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

-

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

-

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/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/IPowerPointApplicationAdapter.cs b/office_plugin/hosts/PowerPointAddIn/IPowerPointApplicationAdapter.cs index 43c0690b..638823db 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, byte[] compoundFile, OlePresentationResult presentation, CancellationToken cancellationToken); + + Task> LoadConversionEntriesAsync(bool includeMathType, CancellationToken cancellationToken); + + Task ReadMathTypeAsync(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 f24f9fd7..82c4464c 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,22 +8,13 @@ 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) { + if (key == "ToMathTypeButton" || key == "ToMathTypeTip" || key.StartsWith("MathType", StringComparison.Ordinal)) + return MathTypeText.Get(key); return CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "zh" ? GetChinese(key) : GetEnglish(key); @@ -51,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 the selected PNG formulas to OLE.", + "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 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 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.", - "HelpTip" => "Show Office plugin help.", + "SettingsTip" => "Open plugin settings.", + "HelpTip" => "View plugin help.", "OfficePluginLabel" => "Office plugin", "EquationLabel" => "Formula", "ConnectButton" => "Connect", @@ -90,9 +82,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}.", @@ -133,16 +127,16 @@ private static string GetChinese(string key) "SettingsButton" => "设置", "HelpButton" => "帮助", "InsertFormulaTip" => "打开公式编辑器。", - "ScreenshotOcrTip" => "等待 LaTeXSnipper 的下一次识别结果;再次单击可取消。", - "LoadSelectedTip" => "将所选公式加载到编辑器中。", - "DeleteSelectedTip" => "删除所选受管理公式。", - "ToOleTip" => "将所选 PNG 公式转换为 OLE。", + "ScreenshotOcrTip" => "等待截图识别结果,再次点击取消。", + "LoadSelectedTip" => "将所选公式加载到编辑器。", + "DeleteSelectedTip" => "删除所选公式。", + "ToOleTip" => "将所选 PNG 或 MathType 公式转为插件 OLE 公式。", "ToPngTip" => "将所选 OLE 公式转换为 PNG。", - "FormatSelectedTip" => "将所选公式恢复为默认字体、颜色和自然大小。", - "FormatAllTip" => "将全文插件公式应用当前默认字体和颜色,并恢复自然大小。", + "FormatSelectedTip" => "恢复所选插件公式的默认样式和自然大小。", + "FormatAllTip" => "恢复全文插件公式的默认样式和自然大小。", "ShowTaskPaneTip" => "显示状态窗格。", - "SettingsTip" => "打开 LaTeXSnipper 设置。", - "HelpTip" => "显示 Office 插件帮助。", + "SettingsTip" => "打开插件设置。", + "HelpTip" => "查看插件帮助。", "OfficePluginLabel" => "Office 插件", "EquationLabel" => "公式", "ConnectButton" => "连接", @@ -172,9 +166,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/PowerPointClipboard.cs similarity index 72% rename from office_plugin/hosts/PowerPointAddIn/PowerPointMathMlClipboard.cs rename to office_plugin/hosts/PowerPointAddIn/PowerPointClipboard.cs index 185fc9b7..d857bb43 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointMathMlClipboard.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointClipboard.cs @@ -1,6 +1,5 @@ using System; using System.Drawing; -using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -10,19 +9,26 @@ namespace LaTeXSnipper.OfficePlugin.PowerPointAddIn; -internal static class PowerPointMathMlClipboard +internal static class PowerPointClipboard { - 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)); + 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 { - Clipboard.SetText(CreateHtmlClipboardPayload(CompactMathMl(mathMl)), TextDataFormat.Html); - dynamic pasted = range.PasteSpecial(PpPasteHtml); - return (Convert.ToInt32(pasted.Start), Convert.ToInt32(pasted.Length)); + return action(); } finally { @@ -88,19 +94,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/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 aaf351e0..abca4cbb 100644 --- a/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs +++ b/office_plugin/hosts/PowerPointAddIn/PowerPointPluginController.DocumentCommands.cs @@ -1,10 +1,13 @@ using System; 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; @@ -12,6 +15,50 @@ 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); + 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; } + 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); @@ -35,9 +82,11 @@ 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; + string? firstFailure = null; for (int batchStart = 0; batchStart < entries.Count; batchStart += BatchFormulaOperationSize) { PowerPointFormulaEntry[] batch = entries @@ -47,31 +96,59 @@ private async Task ConvertSelectedAsync(RenderEngineKind target, CancellationTok foreach (PowerPointFormulaEntry entry in batch) { cancellationToken.ThrowIfCancellationRequested(); - if (entry.Metadata.RenderEngine == target) + try { - continue; - } + if (entry.MathTypeTarget is MathTypeFormulaTarget mathType) + { + MathTypeFormulaContent content = await _powerPointAdapter.ReadMathTypeAsync(mathType, cancellationToken); + var imported = new FormulaMetadata( + new FormulaIdentity(mathType.DocumentId, Guid.NewGuid().ToString("N")), + 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++; + 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.MathTypeTarget?.Location.ToString() ?? 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 +158,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 +169,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 +265,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..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,8 +554,7 @@ public void Dispose() _editorSession.Dispose(); _automationClient.Dispose(); _mathJaxRenderer.Dispose(); - if (_powerPointAdapter is IDisposable disposableAdapter) disposableAdapter.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/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/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" /> +