diff --git a/.gitignore b/.gitignore index 5b6511f3..656aead3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ office_plugin/**/*.user.props office_plugin/**/*.pfx office_plugin/installer/vsto-signing.cer office_plugin/installer/devcert.cer +office_plugin/tests/**/artifacts/ !LaTeXSnipper-linux.spec !LaTeXSnipper-macos.spec !LaTeXSnipper.spec diff --git a/docs/office_plugin_formula_workflows.md b/docs/office_plugin_formula_workflows.md index e99a11a0..143486c5 100644 --- a/docs/office_plugin_formula_workflows.md +++ b/docs/office_plugin_formula_workflows.md @@ -101,10 +101,10 @@ PowerPoint 公式以 shape 为单位保存元数据。短字段写入 shape tags | 设置项 | 影响链路 | 不影响链路 | | --- | --- | --- | -| 插入后端:OLE / Word OMML | 新插入公式、更新公式、转换目标渲染引擎 | 加载所选、删除所选、引用字段 | -| 公式默认颜色 | 新插入公式、格式化所选 | 加载所选、更新公式、打开编辑器预览、格式化全文、重编号、引用 | -| 公式默认字体 | 新插入公式、格式化所选 | 加载所选、更新公式、打开编辑器预览、格式化全文、重编号、引用 | -| 公式缩放 | 新插入公式、格式化所选、自然尺寸记录 | 加载所选、重编号、引用 | +| 插入后端:OLE / Word OMML | 新插入公式、公式解析、更新公式、转换目标渲染引擎 | 加载所选、删除所选、引用字段 | +| 公式默认颜色 | 新插入公式、公式解析、格式化所选 | 加载所选、更新公式、打开编辑器预览、格式化全文、重编号、引用 | +| 公式默认字体 | 新插入公式、公式解析、格式化所选 | 加载所选、更新公式、打开编辑器预览、格式化全文、重编号、引用 | +| 公式缩放 | 新插入公式、公式解析、格式化所选、自然尺寸记录 | 加载所选、重编号、引用 | | 编号位置:左 / 右 | 新插入编号公式、给已有公式添加编号、更新编号公式时重建布局 | 已存在编号公式不会仅因保存设置或执行重编号自动左右移动 | | 编号外框 | 新插入自动编号、给已有公式添加编号、重编号、手动编号显示 | 引用字段自身不独立生成外框,只引用目标编号书签 | | 包含章编号 | 新插入自动编号、给已有公式添加编号、重编号 | 手动编号文本 | @@ -294,6 +294,41 @@ REF LaTeXSnipperEq_{equationId} \h `OMML 转 OLE` 命令会额外识别当前选区内的 Word 原生 OMML 公式,并按上面的 MathML 源码链路转换为 LaTeXSnipper OLE。该能力只属于显式转换入口。 +## Word 公式解析链路 + +“解析所选”和“解析全文”把带明确 LaTeX 定界符的普通文本转换为现有 LaTeXSnipper 托管公式。解析结果不使用新的元数据格式,后续可继续加载、编辑、转换、格式化、编号和引用。 + +### 扫描范围与定界符 + +- 识别 `$...$`、`\(...\)`、`$$...$$` 和 `\[...\]`,其中 `$$...$$` 优先于 `$...$`。 +- `$...$` 与 `\(...\)` 生成无编号行内公式;`$$...$$` 与 `\[...\]` 生成行间公式。 +- 只处理完整闭合且内容非空的公式;行内公式不能跨段落。未闭合、空内容或转换失败的源码原样保留。 +- 定界符是否转义按其前方连续反斜杠数量判断;奇数表示转义,偶数表示有效定界符。`\$` 因此是普通美元符号。 +- `\begin{...}...\end{...}` 只可作为定界符内部内容,不作为外层公式边界。 +- 正文与每个表格单元格独立扫描,定界符不能跨越单元格或不安全区域闭合。 +- “解析所选”要求完整起止定界符都位于非空选区中;部分选择和折叠光标不扩展扫描范围。 +- “解析全文”只扫描主正文 story 及其中的表格,不扫描页眉、页脚、脚注、尾注、批注或文本框。 + +扫描时会把已有 LaTeXSnipper 公式、Word 原生公式、字段、超链接、content control、公式引用、编号边界及受保护内容作为硬边界。解析不会进入这些对象,也不会利用定界符外的 `(1)`、`(1)` 或 `[1]` 推断编号。 + +### 设置、编号与预处理 + +解析开始时只读取一次当前设置和状态窗格编号选项,整批公式使用同一快照: + +1. 插入后端决定生成 Word OMML 还是 OLE。 +2. 默认字体和颜色写入完整 LaTeX 源码,缩放倍率写入既有 `FontScale` 字段。 +3. 行内公式始终无编号。 +4. 行间公式存在唯一、顶层、非注释且非转义的 `\tag{...}` 时,预处理先移除该命令,并把非空 tag 文本写入既有手动编号字段。这一优先级高于状态窗格编号选项,MathJax 不负责排版编号。 +5. 不含有效 `\tag` 的行间公式遵循状态窗格当前的自动编号或自定义编号选项;解析本身不执行全文重编号。 + +空 `\tag{}`、重复顶层 `\tag`、未闭合 tag、`\tag*`、嵌套分组中的 `\tag`,以及行内公式中的顶层 `\tag` 均视为当前公式解析失败。注释或转义的 `\tag` 保留在公式源码中但不作为插件编号;`\label`、`\ref` 和 `\eqref` 不参与插件编号或引用推断。 + +### 批处理、状态与重试 + +候选公式按文档位置倒序、每 5 个一批处理,每批使用短 Word undo record 并更新一次进度。单个公式准备或替换失败时保留原文并继续:无失败时最终只显示成功解析数量,有失败时显示处理总数、成功数和失败数;未发现候选时单独提示“未找到可解析的公式”。 + +命令取消或超时后,已经完成的批次仍是正常托管公式,未处理的定界文本保持原样。再次执行解析即可继续;扫描器会跳过已生成的托管公式,因此不会重复转换。 + ## Word 格式化链路 ### 格式化所选 @@ -349,6 +384,7 @@ PowerPoint 编辑器固定白底黑字,因为 PowerPoint 编辑画布背景板 | Word 重编号 | 中 | 一次扫描公式、章/节边界和字段;异常公式跳过,引用字段只更新 LaTeXSnipper REF | | Word 引用 | 中 | 占位符、目标公式选择、书签和 REF 字段组合;稳定入口是公式或公式所在段落 | | 批量转换所选 | 高 | 稳定快照、倒序分批、每批短 undo;主要耗时仍是 MathJax 渲染和 Office COM 替换 | +| Word 公式解析 | 高 | 安全范围扫描、tag 预处理、MathJax/OMML 或 OLE 渲染、倒序分批替换及失败续跑 | | 格式化所选 | 中 | 仅适合 LaTeX 源码公式;会重写顶层字体和颜色宏并重新渲染;多选时分批处理 | | 格式化全文 | 低 | 只恢复自然字号或自然尺寸,不批量改写源码 | | PowerPoint OLE/PNG | 中 | shape 元数据、自然尺寸、渲染引擎切换和位置缩放维护;多选转换和格式化按批处理 | @@ -360,6 +396,7 @@ PowerPoint 编辑器固定白底黑字,因为 PowerPoint 编辑画布背景板 - 添加编号/插入编号公式:编号状态计算优先按当前位置前的对象构建时间线,减少不必要的元数据读取。 - 格式化所选行内基线:只扫描当前段落对象,避免大文档中选一个公式也遍历全文。 - 批量转换和批量格式化所选:只收集稳定快照;不跨批持有 live COM object;按 5 个公式一批处理;转换和涉及替换的格式化按倒序执行;每批更新一次状态窗格;Word 每批使用短 undo record 和短屏幕刷新暂停区间。 +- 公式解析:先收集仅包含位置、原文、LaTeX 和显示模式的稳定候选;按文档位置倒序、每 5 个一批准备和替换;不跨批持有 live COM object;失败项保留原文并允许下次续跑。 ## OLE payload @@ -387,7 +424,7 @@ MathJax 3.2.2 的 SVG 对部分数学字母会输出 `` ## Undo 与外部传递 -Word 插入、更新、删除、编号、转换和格式化都在必要位置使用 Word undo record。用户撤销后,Word 会同时回撤对象、字段和文档变量的变化。 +Word 插入、更新、删除、编号、解析、转换和格式化都在必要位置使用 Word undo record。用户撤销后,Word 会同时回撤对象、字段和文档变量的变化。 文档发给其他用户再返回时,插件能否加载取决于 Office 是否保留: @@ -404,3 +441,5 @@ Word 插入、更新、删除、编号、转换和格式化都在必要位置使 - 不把默认字体/颜色作为编辑器打开时的临时样式。 - 不在加载所选时改写侧边栏用户草稿以外的持久源码。 - 不在格式化全文时批量重写所有公式源码。 +- 不把解析与全文重编号耦合;解析后的全局序号校正仍由用户显式执行“重编号”。 +- 不扫描非主正文 story,也不跨已有公式、字段、受保护对象或表格单元格边界配对定界符。 diff --git a/office_plugin/LaTeXSnipper.OfficePlugin.slnx b/office_plugin/LaTeXSnipper.OfficePlugin.slnx index e0e0a785..6100041f 100644 --- a/office_plugin/LaTeXSnipper.OfficePlugin.slnx +++ b/office_plugin/LaTeXSnipper.OfficePlugin.slnx @@ -9,4 +9,7 @@ + + + diff --git a/office_plugin/README.md b/office_plugin/README.md index a8b7d72c..c43b7989 100644 --- a/office_plugin/README.md +++ b/office_plugin/README.md @@ -23,6 +23,7 @@ Office 2016 is not officially supported (requires manual .NET 4.8 and WebView2 i - Load, update, and delete managed formulas - 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 - Screenshot OCR via desktop Bridge @@ -54,8 +55,9 @@ Office 2016 is not officially supported (requires manual .NET 4.8 and WebView2 i | `hosts/WordVstoAddIn` | Thin VSTO shell loaded by Word | | `hosts/PowerPointAddIn` | PowerPoint workflows: Ribbon, OLE/PNG insertion, metadata, controller | | `hosts/PowerPointVstoAddIn` | Thin VSTO shell loaded by PowerPoint | +| `tests/LaTeXSnipper.OfficePlugin.WordParsingE2E` | Real-Word OMML/OLE parsing regression test | | `installer/` | Inno Setup installer and release build entry point | -| `tools/` | Release-only VSTO build and installer cleanup support | +| `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 | Shared libraries target `net48;net9.0`. Office hosts target .NET Framework 4.8. The native OLE handler is built for x64 and Win32 Office. @@ -71,3 +73,13 @@ office_plugin\installer\build.bat 2.4.0 Release Output: `office_plugin\release\OfficePluginSetup-2.4.0.exe` Run the installer as administrator. Close Word and PowerPoint before installation, upgrade, or removal. + +## Word Parsing E2E Test + +Close every Word window and run: + +```powershell +office_plugin\tools\Test-WordFormulaParsingE2E.ps1 +``` + +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. diff --git a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Formatting.cs b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Formatting.cs index 2ce41766..8d451852 100644 --- a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Formatting.cs +++ b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Formatting.cs @@ -8,9 +8,10 @@ namespace LaTeXSnipper.OfficePlugin.WordAddIn; public sealed partial class DynamicWordApplicationAdapter { - public Task ResetCustomFormulaSizesAsync(CancellationToken cancellationToken) + public Task ResetCustomFormulaSizesAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + int formulaCount = 0; int resetCount = 0; ExecuteWithScreenUpdatingSuspended(() => { @@ -31,6 +32,7 @@ public Task ResetCustomFormulaSizesAsync(CancellationToken cancellationToke foreach (object candidate in equationControls) { cancellationToken.ThrowIfCancellationRequested(); + formulaCount++; dynamic control = candidate; string equationId = GetEquationId(control); if (!WordFormulaMetadataStore.TryLoadOmmlNaturalFontSize( @@ -64,6 +66,8 @@ public Task ResetCustomFormulaSizesAsync(CancellationToken cancellationToke continue; } + formulaCount++; + if (!WordFormulaMetadataStore.TryLoadOleNaturalSize( CurrentDocument, Convert.ToString(inlineShape.AlternativeText) ?? string.Empty, @@ -87,7 +91,7 @@ public Task ResetCustomFormulaSizesAsync(CancellationToken cancellationToke } }); - return Task.FromResult(resetCount); + return Task.FromResult(new WordFormattingResetResult(formulaCount, resetCount)); } public System.Threading.Tasks.Task ResetManagedEquationFormattingAsync( diff --git a/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Parsing.cs b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Parsing.cs new file mode 100644 index 00000000..8e1e838c --- /dev/null +++ b/office_plugin/hosts/WordAddIn/DynamicWordApplicationAdapter.Parsing.cs @@ -0,0 +1,672 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LaTeXSnipper.OfficePlugin.Abstractions; + +namespace LaTeXSnipper.OfficePlugin.WordAddIn; + +public sealed partial class DynamicWordApplicationAdapter +{ + private const int WdMainTextStory = 1; + private const int WdNoProtection = -1; + private const string ParsedFormulaSlot = "\u2060"; + + public Task> FindLatexParseCandidatesAsync( + bool all, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + dynamic scope = all ? CurrentDocument.Content.Duplicate : _wordApplication.Selection.Range.Duplicate; + if (GetStoryType(scope) != WdMainTextStory || IsCollapsedRange(scope) || IsDocumentProtected()) + { + return Task.FromResult>(Array.Empty()); + } + + int scopeStart = GetRangeStart(scope); + int scopeEnd = GetRangeEnd(scope); + IReadOnlyList excluded = CollectUnsafeSpans(scopeStart, scopeEnd); + var candidates = new List(); + foreach (RangeSpan region in BuildParseRegions(scopeStart, scopeEnd)) + { + foreach (RangeSpan safe in BuildSafeSpans(region.Start, region.End, excluded)) + { + cancellationToken.ThrowIfCancellationRequested(); + string text = ReadRangeText(safe.Start, safe.End); + foreach (LatexDelimiterMatch match in LatexDelimiterScanner.Scan(text)) + { + candidates.Add(new WordLatexParseCandidate( + safe.Start + match.Offset, + safe.Start + match.Offset + match.Length, + match.OriginalText, + match.Latex, + match.DisplayMode)); + } + } + } + + return Task.FromResult>(candidates); + } + + private IReadOnlyList BuildParseRegions(int scopeStart, int scopeEnd) + { + var tableSpans = new List(); + var cellSpans = new List(); + dynamic tables = CurrentDocument.Tables; + int tableCount = Convert.ToInt32(tables.Count); + for (int tableIndex = 1; tableIndex <= tableCount; tableIndex++) + { + dynamic table = tables.Item(tableIndex); + dynamic tableRange = table.Range; + int tableStart = GetRangeStart(tableRange); + int tableEnd = GetRangeEnd(tableRange); + AddClippedSpan(tableSpans, scopeStart, scopeEnd, tableStart, tableEnd); + + dynamic cells = tableRange.Cells; + int cellCount = Convert.ToInt32(cells.Count); + for (int cellIndex = 1; cellIndex <= cellCount; cellIndex++) + { + dynamic cellRange = cells.Item(cellIndex).Range; + int cellStart = GetRangeStart(cellRange); + int cellEnd = GetCellContentEnd(cellRange); + AddClippedSpan(cellSpans, scopeStart, scopeEnd, cellStart, cellEnd); + } + } + + var regions = new List(); + regions.AddRange(BuildSafeSpans(scopeStart, scopeEnd, MergeSpans(tableSpans))); + regions.AddRange(cellSpans); + return regions.OrderBy(span => span.Start).ThenBy(span => span.End).ToArray(); + } + + public Task ReplaceParsedOmmlFormulaAsync( + WordLatexParseCandidate candidate, + string ooxml, + FormulaMetadata metadata, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(ooxml)) + { + throw new ArgumentException("OMML OOXML is required.", nameof(ooxml)); + } + + ValidateParsedFormulaInput(candidate, metadata); + cancellationToken.ThrowIfCancellationRequested(); + ExecuteWithScreenUpdatingSuspended(() => + { + dynamic sourceRange = GetCurrentParsedSourceRange(candidate); + double fontSizePoints = ReadPointSize(sourceRange.Font.Size); + ParsedFormulaTarget target = PrepareParsedFormulaTarget( + sourceRange, + candidate, + metadata.DisplayMode == FormulaDisplayMode.Display, + useWholeDisplayParagraph: true); + int insertionPoint = GetRangeStart(target.Range); + try + { + target.Range.InsertXML(ooxml); + object equationControl = FindInsertedFormulaControl(insertionPoint, metadata.Identity.EquationId); + if (metadata.DisplayMode == FormulaDisplayMode.Inline) + { + RemoveParsedInlineParagraphBreak(equationControl); + } + double naturalFontSize = ScaleFontSize(fontSizePoints, metadata.FontScale); + ApplyManagedEquationFontSize(equationControl, naturalFontSize); + ShowContentControlChrome((dynamic)equationControl); + WordFormulaMetadataStore.SaveOmmlNaturalFontSize( + CurrentDocument, + metadata.Identity.EquationId, + naturalFontSize); + ApplyManagedEquationStyle(equationControl, metadata); + if (metadata.DisplayMode == FormulaDisplayMode.Inline) + { + ResetManagedEquationBaseline(equationControl); + } + else if (metadata.NumberingMode != NumberingMode.None) + { + ApplyNumberedFormulaParagraphLayout(((dynamic)equationControl).Range); + if (metadata.NumberingMode == NumberingMode.Automatic) + { + InsertManagedEquationNumber( + equationControl, + metadata, + BuildEquationNumberStateAtPosition(insertionPoint, WordPluginSettings.Load())); + } + } + + SaveFormulaMetadata(metadata); + } + catch + { + RestoreParsedFormulaText(candidate, metadata, target); + throw; + } + }); + return Task.CompletedTask; + } + + public Task ReplaceParsedOleFormulaAsync( + WordLatexParseCandidate candidate, + FormulaMetadata metadata, + OlePresentationResult presentation, + CancellationToken cancellationToken) + { + if (presentation == null) + { + throw new ArgumentNullException(nameof(presentation)); + } + + ValidateParsedFormulaInput(candidate, metadata); + cancellationToken.ThrowIfCancellationRequested(); + ExecuteWithScreenUpdatingSuspended(() => + { + dynamic sourceRange = GetCurrentParsedSourceRange(candidate); + ParsedFormulaTarget target = PrepareParsedFormulaTarget( + sourceRange, + candidate, + metadata.DisplayMode == FormulaDisplayMode.Display, + useWholeDisplayParagraph: false); + try + { + dynamic inlineShape = metadata.NumberingMode == NumberingMode.None + ? InsertPlainOleInlineShape(target.Range, metadata, presentation, metadata.DisplayMode == FormulaDisplayMode.Display) + : InsertNumberedOleInlineShape(target.Range, metadata, presentation); + _ = inlineShape; + SaveFormulaMetadata(metadata); + } + catch + { + RestoreParsedFormulaText(candidate, metadata, target); + throw; + } + }); + return Task.CompletedTask; + } + + private dynamic GetCurrentParsedSourceRange(WordLatexParseCandidate candidate) + { + dynamic range = CreateDocumentRange(candidate.Start, candidate.End); + string current = Convert.ToString(range.Text) ?? string.Empty; + if (!string.Equals(current, candidate.OriginalText, StringComparison.Ordinal)) + { + throw new InvalidOperationException(WordAddInText.Get("ParseSourceChangedError")); + } + + if (RangeIsUnsafe(range, candidate.Start, candidate.End)) + { + throw new InvalidOperationException("The formula source range is no longer safe to replace."); + } + + return range; + } + + private ParsedFormulaTarget PrepareParsedFormulaTarget( + dynamic sourceRange, + WordLatexParseCandidate candidate, + bool display, + bool useWholeDisplayParagraph) + { + string originalOoxml = ReadWordOpenXml(sourceRange); + if (!display) + { + sourceRange.Text = ParsedFormulaSlot; + dynamic inlineSlot = CreateDocumentRange(candidate.Start, candidate.Start + ParsedFormulaSlot.Length); + if (!useWholeDisplayParagraph) + { + inlineSlot.Delete(); + inlineSlot = CreateDocumentRange(candidate.Start, candidate.Start); + } + return new ParsedFormulaTarget( + inlineSlot, + originalOoxml, + hasLeadingParagraphBreak: false, + hasTrailingParagraphBreak: false); + } + + dynamic firstParagraph = sourceRange.Paragraphs.Item(1).Range; + dynamic lastParagraph = sourceRange.Paragraphs.Item(sourceRange.Paragraphs.Count).Range; + string leadingText = ReadRangeText(GetRangeStart(firstParagraph), candidate.Start); + string trailingText = ReadRangeText(candidate.End, GetParagraphContentEnd(lastParagraph)); + string prefix = HasVisibleText(leadingText) ? "\r" : string.Empty; + string suffix = HasVisibleText(trailingText) ? "\r" : string.Empty; + sourceRange.Text = prefix + ParsedFormulaSlot + suffix; + int slotStart = candidate.Start + prefix.Length; + dynamic slot = CreateDocumentRange(slotStart, slotStart + ParsedFormulaSlot.Length); + dynamic paragraph = slot.Paragraphs.Item(1).Range; + if (useWholeDisplayParagraph) + { + return new ParsedFormulaTarget(paragraph, originalOoxml, prefix.Length > 0, suffix.Length > 0); + } + + slot.Delete(); + return new ParsedFormulaTarget( + CreateDocumentRange(slotStart, slotStart), + originalOoxml, + prefix.Length > 0, + suffix.Length > 0); + } + + private void RestoreParsedFormulaText( + WordLatexParseCandidate candidate, + FormulaMetadata metadata, + ParsedFormulaTarget target) + { + try + { + dynamic restore = CreateParsedRestoreRange(candidate, metadata, target); + if (!string.IsNullOrWhiteSpace(target.OriginalOoxml)) + { + restore.InsertXML(target.OriginalOoxml); + } + else + { + restore.Text = candidate.OriginalText; + } + } + catch + { + try + { + dynamic restore = CreateParsedRestoreRange(candidate, metadata, target); + restore.Text = candidate.OriginalText; + } + catch + { + } + } + } + + private dynamic CreateParsedRestoreRange( + WordLatexParseCandidate candidate, + FormulaMetadata metadata, + ParsedFormulaTarget target) + { + int start = ClampDocumentPosition(candidate.Start); + if (candidate.DisplayMode == FormulaDisplayMode.Inline) + { + int end = start; + object? control = TryGetEquationControlById(metadata.Identity.EquationId); + if (control != null) + { + end = GetRangeEnd(((dynamic)control).Range); + } + else + { + object? inlineShape = TryFindOleInlineShapeById(metadata.Identity.EquationId); + if (inlineShape != null) + { + end = GetRangeEnd(((dynamic)inlineShape).Range); + } + } + + return CreateDocumentRange(start, Math.Max(start, end)); + } + + int formulaPosition = start + (target.HasLeadingParagraphBreak ? 1 : 0); + dynamic formulaParagraph = CreateDocumentRange(formulaPosition, formulaPosition).Paragraphs.Item(1).Range; + int endPosition = target.HasTrailingParagraphBreak + ? GetRangeEnd(formulaParagraph) + : GetParagraphContentEnd(formulaParagraph); + return CreateDocumentRange(start, Math.Max(start, endPosition)); + } + + private void RemoveParsedInlineParagraphBreak(object equationControl) + { + dynamic control = equationControl; + int position = GetRangeEnd(control.Range); + int documentEnd = GetRangeEnd(CurrentDocument.Content); + if (position >= documentEnd) + { + return; + } + + for (int cursor = position; cursor < Math.Min(documentEnd, position + 3); cursor++) + { + dynamic following = CreateDocumentRange(cursor, Math.Min(documentEnd, cursor + 1)); + string text = Convert.ToString(following.Text) ?? string.Empty; + if (text.Length == 0) + { + continue; + } + + if (string.Equals(text, "\r", StringComparison.Ordinal)) + { + following.Delete(); + } + return; + } + } + + private static string ReadWordOpenXml(dynamic range) + { + try + { + return Convert.ToString(range.WordOpenXML) ?? string.Empty; + } + catch + { + return string.Empty; + } + } + + private bool RangeIsUnsafe(dynamic range, int start, int end) + { + if (IsDocumentProtected() || GetStoryType(range) != WdMainTextStory) + { + return true; + } + + return CollectUnsafeSpans(start, end).Any(span => RangesOverlap(start, end, span.Start, span.End)); + } + + private IReadOnlyList CollectUnsafeSpans(int scopeStart, int scopeEnd) + { + var spans = new List(); + bool complete = true; + complete &= AddObjectRanges(spans, scopeStart, scopeEnd, "content controls", () => CurrentDocument.ContentControls, item => item.Range); + complete &= AddObjectRanges(spans, scopeStart, scopeEnd, "native equations", () => CurrentDocument.OMaths, item => item.Range); + complete &= AddObjectRanges(spans, scopeStart, scopeEnd, "inline shapes", () => CurrentDocument.InlineShapes, item => item.Range); + complete &= AddObjectRanges(spans, scopeStart, scopeEnd, "hyperlinks", () => CurrentDocument.Hyperlinks, item => item.Range); + complete &= AddObjectRanges(spans, scopeStart, scopeEnd, "comments", () => CurrentDocument.Comments, item => item.Scope); + complete &= AddObjectRanges(spans, scopeStart, scopeEnd, "revisions", () => CurrentDocument.Revisions, item => item.Range); + complete &= AddFieldRanges(spans, scopeStart, scopeEnd); + complete &= AddCodeStyleRanges(spans, scopeStart, scopeEnd); + if (!complete) + { + return new[] { new RangeSpan(scopeStart, scopeEnd) }; + } + + return MergeSpans(spans); + } + + private static bool AddObjectRanges( + ICollection spans, + int scopeStart, + int scopeEnd, + string collectionName, + Func collectionFactory, + Func rangeSelector) + { + try + { + dynamic collection = collectionFactory(); + int count = Convert.ToInt32(collection.Count); + for (int index = 1; index <= count; index++) + { + dynamic range = rangeSelector(collection.Item(index)); + AddClippedSpan( + spans, + scopeStart, + scopeEnd, + GetRangeStart(range), + GetRangeEnd(range)); + } + return true; + } + catch (Exception exception) + { + System.Diagnostics.Trace.TraceWarning( + "Formula parsing could not inspect {0}: {1}: {2}", + collectionName, + exception.GetType().Name, + exception.Message); + return false; + } + } + + private bool AddFieldRanges(ICollection spans, int scopeStart, int scopeEnd) + { + try + { + dynamic fields = CurrentDocument.Fields; + int count = Convert.ToInt32(fields.Count); + for (int index = 1; index <= count; index++) + { + dynamic field = fields.Item(index); + int start = Math.Min(GetRangeStart(field.Code), GetRangeStart(field.Result)); + int end = Math.Max(GetRangeEnd(field.Code), GetRangeEnd(field.Result)); + AddClippedSpan(spans, scopeStart, scopeEnd, Math.Max(0, start - 1), end + 1); + } + return true; + } + catch + { + return false; + } + } + + private bool AddCodeStyleRanges(ICollection spans, int scopeStart, int scopeEnd) + { + try + { + dynamic scope = CreateDocumentRange(scopeStart, scopeEnd); + dynamic paragraphs = scope.Paragraphs; + int count = Convert.ToInt32(paragraphs.Count); + for (int index = 1; index <= count; index++) + { + dynamic range = paragraphs.Item(index).Range; + if (IsCodeStyle(range)) + { + AddClippedSpan(spans, scopeStart, scopeEnd, GetRangeStart(range), GetRangeEnd(range)); + } + } + return true; + } + catch + { + return false; + } + } + + private static bool IsCodeStyle(dynamic range) + { + string name; + try + { + dynamic style = range.Style; + try + { + name = Convert.ToString(style.NameLocal) ?? string.Empty; + } + catch + { + name = Convert.ToString(style) ?? string.Empty; + } + } + catch + { + return false; + } + + string normalized = name.Trim().ToLowerInvariant(); + return normalized == "code" + || normalized == "html code" + || normalized == "html preformatted" + || normalized == "preformatted" + || normalized == "代码" + || normalized == "html 代码" + || normalized == "html 预设格式"; + } + + private bool IsDocumentProtected() + { + try + { + return Convert.ToInt32(CurrentDocument.ProtectionType) != WdNoProtection; + } + catch + { + return true; + } + } + + private static int GetStoryType(dynamic range) + { + try + { + return Convert.ToInt32(range.StoryType); + } + catch + { + return 0; + } + } + + private string ReadRangeText(int start, int end) + { + if (end <= start) + { + return string.Empty; + } + + return Convert.ToString(CreateDocumentRange(start, end).Text) ?? string.Empty; + } + + private static int GetParagraphContentEnd(dynamic paragraphRange) + { + int start = GetRangeStart(paragraphRange); + int end = GetRangeEnd(paragraphRange); + string text = Convert.ToString(paragraphRange.Text) ?? string.Empty; + int trailingMarkers = text.EndsWith("\r\a", StringComparison.Ordinal) ? 2 : text.EndsWith("\r", StringComparison.Ordinal) ? 1 : 0; + return Math.Max(start, end - trailingMarkers); + } + + private static int GetCellContentEnd(dynamic cellRange) + { + int start = GetRangeStart(cellRange); + int end = GetRangeEnd(cellRange); + string text = Convert.ToString(cellRange.Text) ?? string.Empty; + int trailingMarkers = text.EndsWith("\r\a", StringComparison.Ordinal) ? 1 : 0; + return Math.Max(start, end - trailingMarkers); + } + + private static bool HasVisibleText(string text) + { + return text.Any(character => !char.IsWhiteSpace(character) && character != '\a'); + } + + private static void AddClippedSpan( + ICollection spans, + int scopeStart, + int scopeEnd, + int start, + int end) + { + int clippedStart = Math.Max(scopeStart, start); + int clippedEnd = Math.Min(scopeEnd, end); + if (clippedEnd > clippedStart) + { + spans.Add(new RangeSpan(clippedStart, clippedEnd)); + } + } + + private static IReadOnlyList MergeSpans(IEnumerable spans) + { + RangeSpan[] ordered = spans.OrderBy(span => span.Start).ThenBy(span => span.End).ToArray(); + if (ordered.Length == 0) + { + return ordered; + } + + var merged = new List { ordered[0] }; + for (int index = 1; index < ordered.Length; index++) + { + RangeSpan current = ordered[index]; + RangeSpan previous = merged[merged.Count - 1]; + if (current.Start <= previous.End) + { + merged[merged.Count - 1] = new RangeSpan(previous.Start, Math.Max(previous.End, current.End)); + } + else + { + merged.Add(current); + } + } + + return merged; + } + + private static IReadOnlyList BuildSafeSpans( + int scopeStart, + int scopeEnd, + IReadOnlyList excluded) + { + var safe = new List(); + int cursor = scopeStart; + foreach (RangeSpan span in excluded) + { + if (span.Start > cursor) + { + safe.Add(new RangeSpan(cursor, Math.Min(span.Start, scopeEnd))); + } + + cursor = Math.Max(cursor, span.End); + if (cursor >= scopeEnd) + { + return safe; + } + } + + if (cursor < scopeEnd) + { + safe.Add(new RangeSpan(cursor, scopeEnd)); + } + + return safe; + } + + private static void ValidateParsedFormulaInput(WordLatexParseCandidate candidate, FormulaMetadata metadata) + { + if (candidate == null) + { + throw new ArgumentNullException(nameof(candidate)); + } + + if (metadata == null) + { + throw new ArgumentNullException(nameof(metadata)); + } + + if (candidate.DisplayMode != metadata.DisplayMode) + { + throw new ArgumentException("Parsed formula display mode does not match its metadata.", nameof(metadata)); + } + } + + private readonly struct RangeSpan + { + public RangeSpan(int start, int end) + { + Start = start; + End = end; + } + + public int Start { get; } + + public int End { get; } + } + + private sealed class ParsedFormulaTarget + { + public ParsedFormulaTarget( + object range, + string originalOoxml, + bool hasLeadingParagraphBreak, + bool hasTrailingParagraphBreak) + { + Range = range; + OriginalOoxml = originalOoxml; + HasLeadingParagraphBreak = hasLeadingParagraphBreak; + HasTrailingParagraphBreak = hasTrailingParagraphBreak; + } + + public dynamic Range { get; } + + public string OriginalOoxml { get; } + + public bool HasLeadingParagraphBreak { get; } + + public bool HasTrailingParagraphBreak { get; } + } +} diff --git a/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs b/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs index 99c43923..bc6a1e68 100644 --- a/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs +++ b/office_plugin/hosts/WordAddIn/IWordApplicationAdapter.cs @@ -18,6 +18,22 @@ public interface IWordApplicationAdapter double GetCurrentFontSizePoints(); + Task> FindLatexParseCandidatesAsync( + bool all, + CancellationToken cancellationToken); + + Task ReplaceParsedOmmlFormulaAsync( + WordLatexParseCandidate candidate, + string ooxml, + FormulaMetadata metadata, + CancellationToken cancellationToken); + + Task ReplaceParsedOleFormulaAsync( + WordLatexParseCandidate candidate, + FormulaMetadata metadata, + OlePresentationResult presentation, + CancellationToken cancellationToken); + Task InsertManagedEquationAsync( string ooxml, FormulaMetadata metadata, @@ -66,7 +82,7 @@ Task UpdateFormulaAsync( Task ResetManagedEquationFormattingAsync(FormulaMetadata metadata, CancellationToken cancellationToken); - Task ResetCustomFormulaSizesAsync(CancellationToken cancellationToken); + Task ResetCustomFormulaSizesAsync(CancellationToken cancellationToken); bool HasCustomFormulaScale(FormulaMetadata metadata); diff --git a/office_plugin/hosts/WordAddIn/LatexDelimiterScanner.cs b/office_plugin/hosts/WordAddIn/LatexDelimiterScanner.cs new file mode 100644 index 00000000..b4e5e204 --- /dev/null +++ b/office_plugin/hosts/WordAddIn/LatexDelimiterScanner.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using LaTeXSnipper.OfficePlugin.Abstractions; + +namespace LaTeXSnipper.OfficePlugin.WordAddIn; + +internal enum LatexDelimiterKind +{ + InlineDollar, + InlineParenthesis, + DisplayDollar, + DisplayBracket +} + +internal sealed class LatexDelimiterMatch +{ + public LatexDelimiterMatch( + int offset, + int length, + string originalText, + string latex, + LatexDelimiterKind delimiterKind, + FormulaDisplayMode displayMode) + { + Offset = offset; + Length = length; + OriginalText = originalText; + Latex = latex; + DelimiterKind = delimiterKind; + DisplayMode = displayMode; + } + + public int Offset { get; } + + public int Length { get; } + + public string OriginalText { get; } + + public string Latex { get; } + + public LatexDelimiterKind DelimiterKind { get; } + + public FormulaDisplayMode DisplayMode { get; } +} + +internal static class LatexDelimiterScanner +{ + private const char UnsafeBoundary = '\0'; + private const char TableCellBoundary = '\a'; + + public static IReadOnlyList Scan(string text) + { + var matches = new List(); + if (string.IsNullOrEmpty(text)) + { + return matches; + } + + int index = 0; + while (index < text.Length) + { + if (!TryReadOpeningDelimiter(text, index, out Delimiter delimiter)) + { + index++; + continue; + } + + int contentStart = index + delimiter.Open.Length; + int close = FindClosingDelimiter(text, contentStart, delimiter); + if (close < 0) + { + index += delimiter.Open.Length; + continue; + } + + if (ContainsTopLevelOpeningDelimiter(text, contentStart, close)) + { + index += delimiter.Open.Length; + continue; + } + + string latex = text.Substring(contentStart, close - contentStart).Trim(); + int end = close + delimiter.Close.Length; + if (latex.Length > 0) + { + matches.Add(new LatexDelimiterMatch( + index, + end - index, + text.Substring(index, end - index), + latex, + delimiter.Kind, + delimiter.DisplayMode)); + } + + index = end; + } + + return matches; + } + + private static bool TryReadOpeningDelimiter(string text, int index, out Delimiter delimiter) + { + delimiter = default; + if (IsHardBoundary(text[index])) + { + return false; + } + + if (Matches(text, index, "$$") && !IsEscaped(text, index)) + { + delimiter = new Delimiter("$$", "$$", LatexDelimiterKind.DisplayDollar, FormulaDisplayMode.Display); + return true; + } + + if (text[index] == '$' && !IsEscaped(text, index)) + { + delimiter = new Delimiter("$", "$", LatexDelimiterKind.InlineDollar, FormulaDisplayMode.Inline); + return true; + } + + if (Matches(text, index, "\\(") && !IsEscaped(text, index)) + { + delimiter = new Delimiter("\\(", "\\)", LatexDelimiterKind.InlineParenthesis, FormulaDisplayMode.Inline); + return true; + } + + if (Matches(text, index, "\\[") && !IsEscaped(text, index)) + { + delimiter = new Delimiter("\\[", "\\]", LatexDelimiterKind.DisplayBracket, FormulaDisplayMode.Display); + return true; + } + + return false; + } + + private static int FindClosingDelimiter(string text, int start, Delimiter delimiter) + { + for (int index = start; index <= text.Length - delimiter.Close.Length; index++) + { + char current = text[index]; + if (IsHardBoundary(current)) + { + return -1; + } + + if (delimiter.DisplayMode == FormulaDisplayMode.Inline && (current == '\r' || current == '\n')) + { + return -1; + } + + if (Matches(text, index, delimiter.Close) && !IsEscaped(text, index)) + { + return index; + } + } + + return -1; + } + + private static bool ContainsTopLevelOpeningDelimiter(string text, int start, int end) + { + int braceDepth = 0; + bool inComment = false; + for (int index = start; index < end; index++) + { + char current = text[index]; + if (inComment) + { + if (current == '\r' || current == '\n') + { + inComment = false; + } + + continue; + } + + if (current == '%' && !IsEscaped(text, index)) + { + inComment = true; + continue; + } + + if (current == '{' && !IsEscaped(text, index)) + { + braceDepth++; + continue; + } + + if (current == '}' && !IsEscaped(text, index)) + { + braceDepth = Math.Max(0, braceDepth - 1); + continue; + } + + if (braceDepth == 0 + && TryReadOpeningDelimiter(text, index, out _)) + { + return true; + } + } + + return false; + } + + private static bool Matches(string text, int index, string value) + { + return index >= 0 + && index + value.Length <= text.Length + && string.CompareOrdinal(text, index, value, 0, value.Length) == 0; + } + + private static bool IsEscaped(string text, int index) + { + int backslashes = 0; + for (int cursor = index - 1; cursor >= 0 && text[cursor] == '\\'; cursor--) + { + backslashes++; + } + + return backslashes % 2 != 0; + } + + private static bool IsHardBoundary(char value) + { + return value == UnsafeBoundary || value == TableCellBoundary; + } + + private readonly struct Delimiter + { + public Delimiter(string open, string close, LatexDelimiterKind kind, FormulaDisplayMode displayMode) + { + Open = open; + Close = close; + Kind = kind; + DisplayMode = displayMode; + } + + public string Open { get; } + + public string Close { get; } + + public LatexDelimiterKind Kind { get; } + + public FormulaDisplayMode DisplayMode { get; } + } +} diff --git a/office_plugin/hosts/WordAddIn/LatexTagPreprocessor.cs b/office_plugin/hosts/WordAddIn/LatexTagPreprocessor.cs new file mode 100644 index 00000000..0208292a --- /dev/null +++ b/office_plugin/hosts/WordAddIn/LatexTagPreprocessor.cs @@ -0,0 +1,181 @@ +using System; + +namespace LaTeXSnipper.OfficePlugin.WordAddIn; + +internal sealed class LatexTagPreprocessResult +{ + private LatexTagPreprocessResult(bool success, string latex, string? numberText) + { + Success = success; + Latex = latex; + NumberText = numberText; + } + + public bool Success { get; } + + public string Latex { get; } + + public string? NumberText { get; } + + public bool HasTag => NumberText != null; + + public static LatexTagPreprocessResult Valid(string latex, string? numberText = null) + { + return new LatexTagPreprocessResult(true, latex, numberText); + } + + public static LatexTagPreprocessResult Invalid() + { + return new LatexTagPreprocessResult(false, string.Empty, null); + } +} + +internal static class LatexTagPreprocessor +{ + private const string TagCommand = "\\tag"; + + public static LatexTagPreprocessResult Process(string latex, bool display) + { + if (string.IsNullOrWhiteSpace(latex)) + { + return LatexTagPreprocessResult.Invalid(); + } + + int braceDepth = 0; + int? tagStart = null; + int tagEnd = -1; + string? numberText = null; + bool inComment = false; + + for (int index = 0; index < latex.Length; index++) + { + char current = latex[index]; + if (inComment) + { + if (current == '\r' || current == '\n') + { + inComment = false; + } + continue; + } + + if (current == '%' && !IsEscaped(latex, index)) + { + inComment = true; + continue; + } + + if (current == '{' && !IsEscaped(latex, index)) + { + braceDepth++; + continue; + } + + if (current == '}' && !IsEscaped(latex, index)) + { + braceDepth = Math.Max(0, braceDepth - 1); + continue; + } + + if (!MatchesTagCommand(latex, index)) + { + continue; + } + + if (!display || braceDepth != 0 || tagStart.HasValue) + { + return LatexTagPreprocessResult.Invalid(); + } + + int cursor = index + TagCommand.Length; + if (cursor < latex.Length && latex[cursor] == '*') + { + return LatexTagPreprocessResult.Invalid(); + } + + while (cursor < latex.Length && char.IsWhiteSpace(latex[cursor])) + { + cursor++; + } + + if (cursor >= latex.Length || latex[cursor] != '{') + { + return LatexTagPreprocessResult.Invalid(); + } + + int closingBrace = FindBalancedGroupEnd(latex, cursor); + if (closingBrace < 0) + { + return LatexTagPreprocessResult.Invalid(); + } + + string value = latex.Substring(cursor + 1, closingBrace - cursor - 1).Trim(); + if (value.Length == 0) + { + return LatexTagPreprocessResult.Invalid(); + } + + tagStart = index; + tagEnd = closingBrace + 1; + numberText = value; + index = closingBrace; + } + + if (!tagStart.HasValue) + { + return LatexTagPreprocessResult.Valid(latex.Trim()); + } + + string cleaned = (latex.Substring(0, tagStart.Value) + latex.Substring(tagEnd)).Trim(); + return cleaned.Length == 0 + ? LatexTagPreprocessResult.Invalid() + : LatexTagPreprocessResult.Valid(cleaned, numberText); + } + + private static bool MatchesTagCommand(string latex, int index) + { + if (index < 0 + || index + TagCommand.Length > latex.Length + || string.CompareOrdinal(latex, index, TagCommand, 0, TagCommand.Length) != 0 + || IsEscaped(latex, index)) + { + return false; + } + + int end = index + TagCommand.Length; + return end >= latex.Length || !char.IsLetter(latex[end]); + } + + private static int FindBalancedGroupEnd(string latex, int openingBrace) + { + int depth = 0; + for (int index = openingBrace; index < latex.Length; index++) + { + if (latex[index] == '{' && !IsEscaped(latex, index)) + { + depth++; + } + else if (latex[index] == '}' && !IsEscaped(latex, index)) + { + depth--; + if (depth == 0) + { + return index; + } + } + } + + return -1; + } + + private static bool IsEscaped(string text, int index) + { + int backslashes = 0; + for (int cursor = index - 1; cursor >= 0 && text[cursor] == '\\'; cursor--) + { + backslashes++; + } + + return backslashes % 2 != 0; + } +} diff --git a/office_plugin/hosts/WordAddIn/Ribbon/WordRibbon.xml b/office_plugin/hosts/WordAddIn/Ribbon/WordRibbon.xml index 73558317..7c9bae16 100644 --- a/office_plugin/hosts/WordAddIn/Ribbon/WordRibbon.xml +++ b/office_plugin/hosts/WordAddIn/Ribbon/WordRibbon.xml @@ -53,6 +53,10 @@