Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="LaTeXSnipper_*_office_amd64.exe">
<pe-file path="LaTeXSnipperOffice_*_amd64.exe">
<authenticode-sign />
</pe-file>
</zip-file>
Expand Down
110 changes: 42 additions & 68 deletions docs/office_plugin_formula_workflows.md

Large diffs are not rendered by default.

46 changes: 33 additions & 13 deletions office_plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand All @@ -67,34 +75,46 @@ 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
```

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_<version>_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.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ namespace
{
volatile LONG g_objectCount = 0;
volatile LONG g_lockCount = 0;

void LogInterfaceQuery(REFIID iid, HRESULT result)
{
LPOLESTR iidText = nullptr;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -137,10 +140,6 @@ void FormulaOleObject::NotifyPresentationChanged()
}
}

if (clientSite_ != nullptr)
{
clientSite_->SaveObject();
}
}

STDMETHODIMP FormulaOleObject::QueryInterface(REFIID iid, void** object)
Expand Down Expand Up @@ -182,6 +181,10 @@ STDMETHODIMP FormulaOleObject::QueryInterface(REFIID iid, void** object)
{
*object = static_cast<IPersistStorage*>(this);
}
else if (iid == IID_IDispatch)
{
*object = static_cast<IDispatch*>(this);
}
else
{
*object = nullptr;
Expand Down Expand Up @@ -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<UINT>(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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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<unsigned int>(result));
WriteNativeOleLog(message);
return result;
}

STDMETHODIMP FormulaOleObject::Save(IStorage* storage, BOOL)
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class FormulaOleObject final
, public IOleCache
, public IExternalConnection
, public IPersistStorage
, public IDispatch
{
public:
FormulaOleObject();
Expand All @@ -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;
Expand Down
Loading